mirror of
https://github.com/TeamNewPipe/NewPipe
synced 2026-01-14 19:57:55 +00:00
Compare commits
1170 Commits
v0.22.0
...
fix/peertu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27b2d5de70 | ||
|
|
5c7a9a52f5 | ||
|
|
c1f0a945c0 | ||
|
|
e33bb676f9 | ||
|
|
30724dbc50 | ||
|
|
e765343162 | ||
|
|
62ce0b0408 | ||
|
|
3bbc606694 | ||
|
|
56eec9fed1 | ||
|
|
ea0d798ea0 | ||
|
|
5716d51112 | ||
|
|
d845a158f0 | ||
|
|
1a2fbd8122 | ||
|
|
8bdeed8f28 | ||
|
|
3c87462203 | ||
|
|
3622438a9d | ||
|
|
1848892ff8 | ||
|
|
72c6ed2804 | ||
|
|
42de2c7033 | ||
|
|
6bcc8691fa | ||
|
|
6cf13ed8fb | ||
|
|
ad75db40df | ||
|
|
4e3bf3c2f9 | ||
|
|
1925687f18 | ||
|
|
577301c4eb | ||
|
|
c87b42de1c | ||
|
|
c8e8915c2e | ||
|
|
17cdedfa85 | ||
|
|
677bb4070f | ||
|
|
fe82029dc7 | ||
|
|
0ab9961908 | ||
|
|
ecbf5d5ead | ||
|
|
df430badbc | ||
|
|
8639972a54 | ||
|
|
41038f452d | ||
|
|
2f31ea8864 | ||
|
|
e831059162 | ||
|
|
e109e8cf1c | ||
|
|
f1524b6aba | ||
|
|
51ee6f87e0 | ||
|
|
0bb3e7cb86 | ||
|
|
4bf063645a | ||
|
|
9866eab60f | ||
|
|
10c42de2f1 | ||
|
|
e1fd25fb71 | ||
|
|
2315b082ff | ||
|
|
023f6166ab | ||
|
|
d89a3c6c4d | ||
|
|
fb00ee8cf9 | ||
|
|
22671ca16c | ||
|
|
4e837e838d | ||
|
|
ed1781133c | ||
|
|
60fc662a26 | ||
|
|
43b0167a3a | ||
|
|
8519897089 | ||
|
|
60a5d02018 | ||
|
|
c377ffbce8 | ||
|
|
b567d428ad | ||
|
|
da30e539df | ||
|
|
f74d794b2a | ||
|
|
69ef4a987e | ||
|
|
78e1e0508e | ||
|
|
6d98ad7abc | ||
|
|
70b3ba310a | ||
|
|
2edc223e77 | ||
|
|
e18a6b09f8 | ||
|
|
f8c3ec4be7 | ||
|
|
ba3afd1e35 | ||
|
|
20f0011921 | ||
|
|
acebabd028 | ||
|
|
6243f34946 | ||
|
|
787758a436 | ||
|
|
a02b92fd59 | ||
|
|
a6ff85a208 | ||
|
|
41da8fc05f | ||
|
|
a4a9957a15 | ||
|
|
29318c64ed | ||
|
|
74bd28cbd9 | ||
|
|
365bb2d0e4 | ||
|
|
c08538d25d | ||
|
|
140ea8642c | ||
|
|
445d364193 | ||
|
|
4bb45c001d | ||
|
|
7350b1f32e | ||
|
|
4a33ee6045 | ||
|
|
704e9bd7b6 | ||
|
|
d2735607b8 | ||
|
|
3c72992c39 | ||
|
|
7689d1d15c | ||
|
|
65d8589e7a | ||
|
|
32cec6c9a7 | ||
|
|
72ca52a29b | ||
|
|
2ded8c7cc1 | ||
|
|
759a9080a8 | ||
|
|
2ba649949f | ||
|
|
c8d54ec6c7 | ||
|
|
96e9242431 | ||
|
|
3c74cb3439 | ||
|
|
7a8116b2cf | ||
|
|
d010384c88 | ||
|
|
07111d86d4 | ||
|
|
ec974a2b3d | ||
|
|
02906e8132 | ||
|
|
6f428d0c6b | ||
|
|
41da2bfb00 | ||
|
|
746b1f7eb2 | ||
|
|
03fd286956 | ||
|
|
39a5c8bdfb | ||
|
|
fb1b1c5be1 | ||
|
|
1a8aa8b17e | ||
|
|
2317864422 | ||
|
|
694418d30d | ||
|
|
ed06f559ae | ||
|
|
fdd3b03fe5 | ||
|
|
dbd6e4d11f | ||
|
|
61a14765f3 | ||
|
|
9b8ffdd2aa | ||
|
|
ef0a4cf8b2 | ||
|
|
7aed2eed8a | ||
|
|
87a88e4df7 | ||
|
|
366c39d4c6 | ||
|
|
77649d388c | ||
|
|
dba53d23aa | ||
|
|
208887d538 | ||
|
|
0cd1a86aa5 | ||
|
|
de7872d8f2 | ||
|
|
7c39421297 | ||
|
|
d06cc862c8 | ||
|
|
c5cf2f4514 | ||
|
|
3f8e44dc66 | ||
|
|
d33229a3b8 | ||
|
|
bb57f9cc9d | ||
|
|
23a20712da | ||
|
|
43f46e29ad | ||
|
|
7617f8cdc7 | ||
|
|
2e3490bce2 | ||
|
|
1dd0930b83 | ||
|
|
265de55a07 | ||
|
|
d8ed2c8503 | ||
|
|
73aebc1110 | ||
|
|
3cb76e4c34 | ||
|
|
a4767fc48a | ||
|
|
42d861688e | ||
|
|
2ee4c6e289 | ||
|
|
097c2368f4 | ||
|
|
80e0c6ab0e | ||
|
|
9067c770a7 | ||
|
|
f1a071b668 | ||
|
|
8e888ebdf7 | ||
|
|
612122997b | ||
|
|
4b050c0dd8 | ||
|
|
be4f3d9d62 | ||
|
|
24ff6a4313 | ||
|
|
c2968a3ff2 | ||
|
|
671dd4afd3 | ||
|
|
600ebdae18 | ||
|
|
5560cea470 | ||
|
|
39c500f33c | ||
|
|
624ad6a47c | ||
|
|
68ea99d6e6 | ||
|
|
bc29f40d69 | ||
|
|
42fb13f17a | ||
|
|
d5b54c85ed | ||
|
|
f0307b1b48 | ||
|
|
75292e099c | ||
|
|
e0cb2892b8 | ||
|
|
831f36e18e | ||
|
|
d2f8f31d1f | ||
|
|
8d43499e5b | ||
|
|
63375627e9 | ||
|
|
4903786b14 | ||
|
|
4cc653fdf1 | ||
|
|
4c5c2a3d79 | ||
|
|
c85af7861a | ||
|
|
dc1ecc19ed | ||
|
|
e947e86eae | ||
|
|
5d3955854e | ||
|
|
3ff4b713e8 | ||
|
|
68097568d5 | ||
|
|
cd8d57040c | ||
|
|
812efca08e | ||
|
|
1db1a00581 | ||
|
|
e0ba872b66 | ||
|
|
9c82441c19 | ||
|
|
3d36eb5baf | ||
|
|
d2d324f2dd | ||
|
|
353db0bc6c | ||
|
|
d1aed94d27 | ||
|
|
281cdf65da | ||
|
|
5af5c90492 | ||
|
|
ca421c28a1 | ||
|
|
711345eff7 | ||
|
|
102975aeb3 | ||
|
|
cd12503f99 | ||
|
|
1e724eba6c | ||
|
|
c70ce791db | ||
|
|
444ac5fe95 | ||
|
|
b9228df32c | ||
|
|
b6bf0ffc40 | ||
|
|
34e6e70be9 | ||
|
|
5b3f8a3d30 | ||
|
|
fceec71ad3 | ||
|
|
a69f74f51b | ||
|
|
e26c038565 | ||
|
|
52e39c3402 | ||
|
|
f2af168986 | ||
|
|
6e1ffb4e52 | ||
|
|
f88c1e1e8b | ||
|
|
ddda80a577 | ||
|
|
d758e50634 | ||
|
|
a6021730cd | ||
|
|
e9fcad4787 | ||
|
|
640d4b0280 | ||
|
|
b9378a7c1f | ||
|
|
abb6b4282d | ||
|
|
9ecd5dff09 | ||
|
|
aa41fec466 | ||
|
|
e4641cd427 | ||
|
|
dba24ec1f9 | ||
|
|
abe6dfb99c | ||
|
|
d08d7cf31f | ||
|
|
6e73c489de | ||
|
|
489df0ed7d | ||
|
|
7924bb5b6b | ||
|
|
c47d1af5e3 | ||
|
|
51af961e0d | ||
|
|
86997794ab | ||
|
|
2db29187f4 | ||
|
|
22c201be39 | ||
|
|
cdd5e89b86 | ||
|
|
764b6aa2b1 | ||
|
|
f766ef2033 | ||
|
|
ef4a6238c8 | ||
|
|
b3554a6a49 | ||
|
|
5fb7b3266b | ||
|
|
8b6e110635 | ||
|
|
f5a1f915be | ||
|
|
ac15339911 | ||
|
|
fdfeac081a | ||
|
|
31396a632f | ||
|
|
223150aa42 | ||
|
|
135fc08212 | ||
|
|
5e3caf68a5 | ||
|
|
262b3a2945 | ||
|
|
e44d09208c | ||
|
|
0546c9b9fc | ||
|
|
38c4a1ed85 | ||
|
|
fd8e92cf77 | ||
|
|
062570cc47 | ||
|
|
9514316be3 | ||
|
|
a15a5adacc | ||
|
|
b6e6d39985 | ||
|
|
48ae830262 | ||
|
|
03f5dd71a5 | ||
|
|
2afbe58722 | ||
|
|
0a64eac778 | ||
|
|
ad605e2c5a | ||
|
|
eed44b3231 | ||
|
|
944e295ae7 | ||
|
|
28109fef38 | ||
|
|
40442f3f82 | ||
|
|
61da167b4f | ||
|
|
c744f6756b | ||
|
|
de7057ac3a | ||
|
|
585bfff11d | ||
|
|
0f9c20c986 | ||
|
|
f860392ae9 | ||
|
|
391830558e | ||
|
|
c1f37d8591 | ||
|
|
b175774ad8 | ||
|
|
73e32889b6 | ||
|
|
400ee808e0 | ||
|
|
87976693f8 | ||
|
|
9c7ed80662 | ||
|
|
eb3363d4dd | ||
|
|
edff696ecc | ||
|
|
9c19e9813a | ||
|
|
2679a4bf1e | ||
|
|
e8216b2e80 | ||
|
|
e3062d7c66 | ||
|
|
fd55d85bbf | ||
|
|
f10d591462 | ||
|
|
3e15c77a05 | ||
|
|
1bb166a9e8 | ||
|
|
8fa949537b | ||
|
|
7454b31788 | ||
|
|
b6488fe342 | ||
|
|
b1d9080a0f | ||
|
|
50269d0f5e | ||
|
|
f17155bb3f | ||
|
|
7988fe0c5a | ||
|
|
f4a5b3bcbf | ||
|
|
cd0e585586 | ||
|
|
464247784d | ||
|
|
56800c24b9 | ||
|
|
6af2242d5d | ||
|
|
d21fac658b | ||
|
|
27f6c3b634 | ||
|
|
b3bfec9505 | ||
|
|
367ece8ffa | ||
|
|
661cd4c182 | ||
|
|
be856f71c8 | ||
|
|
97978033dd | ||
|
|
413a1b504a | ||
|
|
8078620977 | ||
|
|
69e8e4d63e | ||
|
|
fb1360b72a | ||
|
|
231e677b16 | ||
|
|
fcac53cdc0 | ||
|
|
b07f1a77aa | ||
|
|
c13b858f02 | ||
|
|
5d9bf8055e | ||
|
|
dfc46c3b6c | ||
|
|
d255d3e376 | ||
|
|
eea4f0f41c | ||
|
|
12796920a3 | ||
|
|
dfd6534a1c | ||
|
|
fedc26e3cb | ||
|
|
1ac62541a8 | ||
|
|
5942add141 | ||
|
|
9eb72d5a86 | ||
|
|
26579cc170 | ||
|
|
d70b768031 | ||
|
|
0c47fc7017 | ||
|
|
c537776826 | ||
|
|
7c5b4510af | ||
|
|
bf1ebf8733 | ||
|
|
8edfafcf09 | ||
|
|
10a5741f36 | ||
|
|
c7d392e77e | ||
|
|
161007fe92 | ||
|
|
5fc85fa2e0 | ||
|
|
4a27d371e0 | ||
|
|
a4c9e0a35e | ||
|
|
a6f57a8665 | ||
|
|
0df696739f | ||
|
|
86ee94eb04 | ||
|
|
0923594e51 | ||
|
|
3bb51875bc | ||
|
|
40225443ed | ||
|
|
10977eaefa | ||
|
|
3103fd7302 | ||
|
|
281ac13eed | ||
|
|
e5f30a07bf | ||
|
|
9c4d5526f4 | ||
|
|
77737a5687 | ||
|
|
869d46f15c | ||
|
|
1afb9cdba9 | ||
|
|
730664eefb | ||
|
|
6b210e1542 | ||
|
|
f1b15a95a4 | ||
|
|
1d53389ca9 | ||
|
|
8fc5fa979d | ||
|
|
074a8ff46a | ||
|
|
a2f2d562f6 | ||
|
|
bd6b3c53c5 | ||
|
|
8282b8a6c0 | ||
|
|
72a250b610 | ||
|
|
b0516fbf1d | ||
|
|
05903502c5 | ||
|
|
2bf58abb89 | ||
|
|
9d01d88eed | ||
|
|
f07886fc5e | ||
|
|
2984649106 | ||
|
|
60671c99ed | ||
|
|
bce77aaec7 | ||
|
|
f2e3020f9d | ||
|
|
e9ef9451e5 | ||
|
|
7c1d06e023 | ||
|
|
6b89b44dcd | ||
|
|
225f69b75b | ||
|
|
44bc6bf069 | ||
|
|
e5af1c93ae | ||
|
|
d6617007d4 | ||
|
|
8db90ba449 | ||
|
|
048b0972de | ||
|
|
a7989795e8 | ||
|
|
a40f035810 | ||
|
|
aad5e26f31 | ||
|
|
627c6e29a2 | ||
|
|
95c32d6f4a | ||
|
|
747df59741 | ||
|
|
a4e883c119 | ||
|
|
289f9105d9 | ||
|
|
5804483c89 | ||
|
|
16732905bf | ||
|
|
ef1e7e5b52 | ||
|
|
abf1cc536d | ||
|
|
c38f150562 | ||
|
|
d2b6bda7a2 | ||
|
|
9e5c68c575 | ||
|
|
88eed6cc23 | ||
|
|
a1773d166f | ||
|
|
5e2ef7ff0d | ||
|
|
cfda073aa5 | ||
|
|
ff774a1870 | ||
|
|
feb03f7e30 | ||
|
|
95a65d5704 | ||
|
|
5c1af6d296 | ||
|
|
6d812b86aa | ||
|
|
7b7ab3f419 | ||
|
|
ef35b36eba | ||
|
|
bb83d2b489 | ||
|
|
3dc1adb69e | ||
|
|
a95a5ed13e | ||
|
|
da61c9f915 | ||
|
|
9472c36cbd | ||
|
|
49c12a31e9 | ||
|
|
fc061599f8 | ||
|
|
b066457ccf | ||
|
|
2c5c7dfe3a | ||
|
|
4573407fc7 | ||
|
|
9912c11043 | ||
|
|
231c5e515f | ||
|
|
e9870d9e1d | ||
|
|
c274ee9873 | ||
|
|
c8caf48cda | ||
|
|
1de662f779 | ||
|
|
e4f97465a4 | ||
|
|
84887395f8 | ||
|
|
e333197ed5 | ||
|
|
bf766f1670 | ||
|
|
51bdc30ed0 | ||
|
|
4b892e2b30 | ||
|
|
43b2176956 | ||
|
|
00283fac30 | ||
|
|
78f6a86645 | ||
|
|
9d2ab61993 | ||
|
|
8fdd828de4 | ||
|
|
25795c3a96 | ||
|
|
7f3da04fee | ||
|
|
7864521cb4 | ||
|
|
31b83ba47a | ||
|
|
9524c6245d | ||
|
|
57d2fe113a | ||
|
|
2f6cb87bba | ||
|
|
3cef7f3201 | ||
|
|
2225933946 | ||
|
|
47259ef152 | ||
|
|
b2eb631a97 | ||
|
|
9e0f37a2de | ||
|
|
f712ea34e0 | ||
|
|
a44b7c9c9e | ||
|
|
4b32890b5f | ||
|
|
a41aa01461 | ||
|
|
2ed6819e2c | ||
|
|
ea875c59af | ||
|
|
a22162ffac | ||
|
|
83d16dc656 | ||
|
|
8ceefee1e3 | ||
|
|
8f157be7e0 | ||
|
|
38579e9a29 | ||
|
|
30a91f59ae | ||
|
|
0e169951f7 | ||
|
|
8b9db369f6 | ||
|
|
f7e10eb094 | ||
|
|
0d73d193ad | ||
|
|
40815086ad | ||
|
|
16860603fd | ||
|
|
c607089cbb | ||
|
|
28464344c1 | ||
|
|
ed68e3bd46 | ||
|
|
082d7a3f18 | ||
|
|
6eddaa0d38 | ||
|
|
1aa1a0287e | ||
|
|
3bfcb16f9a | ||
|
|
f37d869ea2 | ||
|
|
78547b4fa4 | ||
|
|
29e56b9f2d | ||
|
|
83357ca67e | ||
|
|
8482bf9fed | ||
|
|
2a98cca801 | ||
|
|
6277d4981c | ||
|
|
02deaa0f1a | ||
|
|
4a278ef102 | ||
|
|
7ab8f9f112 | ||
|
|
7fca0e0786 | ||
|
|
0b0dfd0a37 | ||
|
|
dd07bd91a4 | ||
|
|
ed4eb124e4 | ||
|
|
4070007c93 | ||
|
|
5b213a19e4 | ||
|
|
34d81d3bf2 | ||
|
|
8bc8355b68 | ||
|
|
ab99c14fd2 | ||
|
|
1047158a66 | ||
|
|
0c63950429 | ||
|
|
aa9cd8c88f | ||
|
|
3110b08988 | ||
|
|
fe227d5b94 | ||
|
|
489f052ef9 | ||
|
|
8313f6bb51 | ||
|
|
bf55ed262f | ||
|
|
f26bf33ead | ||
|
|
ca29f6cc1f | ||
|
|
cb80891a5f | ||
|
|
9db0133a5b | ||
|
|
28b34f3796 | ||
|
|
1f57c87859 | ||
|
|
fbf5549182 | ||
|
|
051c572e7f | ||
|
|
464a646671 | ||
|
|
ed87465565 | ||
|
|
f9109ebc81 | ||
|
|
4a7af6f9ac | ||
|
|
7fbef35daa | ||
|
|
e6391a860a | ||
|
|
ebce4c5b7e | ||
|
|
e7e61a0c4c | ||
|
|
131f78c0c2 | ||
|
|
67b5de38b1 | ||
|
|
f9994abb94 | ||
|
|
ca0f56eea8 | ||
|
|
500acce178 | ||
|
|
6805c75c9c | ||
|
|
75917c7f61 | ||
|
|
59d1ded94e | ||
|
|
973a966011 | ||
|
|
510efaae97 | ||
|
|
11bd2369e5 | ||
|
|
f80d1dc48d | ||
|
|
8bff445ec3 | ||
|
|
d73ca41cfe | ||
|
|
f3a9b81b67 | ||
|
|
3cc43e9fb9 | ||
|
|
bc33322d4b | ||
|
|
c054ea0737 | ||
|
|
ce6f3ca5df | ||
|
|
52dbfdee00 | ||
|
|
1e964a36a9 | ||
|
|
679e81e091 | ||
|
|
2e3e4f5a84 | ||
|
|
208dde631f | ||
|
|
4227866fcf | ||
|
|
335e682299 | ||
|
|
5c0ed22b09 | ||
|
|
e1b8a3fbdf | ||
|
|
1a432f2ee3 | ||
|
|
db45042a56 | ||
|
|
a50b9bd6ff | ||
|
|
2089f3e54c | ||
|
|
5e0788b99c | ||
|
|
67669c286b | ||
|
|
408a71cfdc | ||
|
|
6399e39507 | ||
|
|
f9443f7421 | ||
|
|
4f6b5b3b89 | ||
|
|
b9b09d325a | ||
|
|
50f3131f1a | ||
|
|
697b8411df | ||
|
|
fcaebc838e | ||
|
|
cde32a8aed | ||
|
|
ec3efea05a | ||
|
|
571bf397c5 | ||
|
|
737a331c85 | ||
|
|
2de33d8d07 | ||
|
|
7f21f6e80e | ||
|
|
0b11afaf2f | ||
|
|
e136a6f915 | ||
|
|
74921d3afa | ||
|
|
edd2b110b0 | ||
|
|
80fb21e031 | ||
|
|
ebd06bdd24 | ||
|
|
6f86e21605 | ||
|
|
816154c7cb | ||
|
|
d9230c0103 | ||
|
|
5c7dfd1d69 | ||
|
|
7aacaf8c38 | ||
|
|
ee6a279596 | ||
|
|
a9af1dfdd2 | ||
|
|
fc46233baf | ||
|
|
2eec2e9128 | ||
|
|
8024b437e9 | ||
|
|
d1f3f15478 | ||
|
|
059cfcbad2 | ||
|
|
1a8f396e77 | ||
|
|
5640365fbd | ||
|
|
4b7de86a92 | ||
|
|
24ec642181 | ||
|
|
8dce66d76f | ||
|
|
22d75f3ecb | ||
|
|
7972678fe6 | ||
|
|
ffc1d9a212 | ||
|
|
7f018b90db | ||
|
|
8a774dc90d | ||
|
|
368c6c0ccb | ||
|
|
5c4874b90f | ||
|
|
3420faab08 | ||
|
|
a548b34811 | ||
|
|
56cbf3736b | ||
|
|
ad30eb809c | ||
|
|
ee368452ae | ||
|
|
a9095ca2ad | ||
|
|
013522c376 | ||
|
|
947242d913 | ||
|
|
8a896114c1 | ||
|
|
47f58040d1 | ||
|
|
35a118a2a7 | ||
|
|
582032f372 | ||
|
|
311d392386 | ||
|
|
404c13d4c1 | ||
|
|
5c68c8ece8 | ||
|
|
4d7a6fb6de | ||
|
|
630558ed4f | ||
|
|
69942003f7 | ||
|
|
af9c2bd59d | ||
|
|
81c4b822e0 | ||
|
|
81fb44c45c | ||
|
|
d66997c2ed | ||
|
|
d7a654fc27 | ||
|
|
229422bfa9 | ||
|
|
baabba1dea | ||
|
|
8f5d564f84 | ||
|
|
dcb332e08d | ||
|
|
51e72d1a05 | ||
|
|
8f37015dbb | ||
|
|
74df7fcd66 | ||
|
|
bfaf074f4e | ||
|
|
3281ed2ef1 | ||
|
|
b2c2570a85 | ||
|
|
abf185c691 | ||
|
|
f4fe5fcb16 | ||
|
|
37275e8fe3 | ||
|
|
f1dab11f1f | ||
|
|
6d1c61407d | ||
|
|
8b400b48f7 | ||
|
|
b845645b80 | ||
|
|
cacce6d2d0 | ||
|
|
373ee53143 | ||
|
|
344c33d9a1 | ||
|
|
c5b970cca3 | ||
|
|
15947161e6 | ||
|
|
394eb92e71 | ||
|
|
d62cdc659f | ||
|
|
a6cc13845a | ||
|
|
55a995c4cd | ||
|
|
ca26fcb0eb | ||
|
|
4eddd2c3d1 | ||
|
|
c53143ef4f | ||
|
|
e772244440 | ||
|
|
ae369ec9ba | ||
|
|
e8669d4ab5 | ||
|
|
cd14096dbe | ||
|
|
d9ff114e1a | ||
|
|
a1c6f0073e | ||
|
|
f1de353b74 | ||
|
|
5da8d5fc73 | ||
|
|
3ba04f179f | ||
|
|
3890d0abdb | ||
|
|
8b209df253 | ||
|
|
25a43b57b2 | ||
|
|
b7a44560f5 | ||
|
|
0e8cc72b13 | ||
|
|
33e20766c9 | ||
|
|
9f993e0c49 | ||
|
|
6ea85e6380 | ||
|
|
4d58026d06 | ||
|
|
7b9b9218dc | ||
|
|
dff1adb1ad | ||
|
|
35eeccd45a | ||
|
|
429f2536af | ||
|
|
7b41acb781 | ||
|
|
cc7a8fb1a6 | ||
|
|
c1e78cf55b | ||
|
|
4536e8b55b | ||
|
|
70e3c9805a | ||
|
|
8187a3bc04 | ||
|
|
4443c908cb | ||
|
|
c03eac1dc9 | ||
|
|
61c1da144e | ||
|
|
3692858a3d | ||
|
|
9c51fc3ade | ||
|
|
1cf746f721 | ||
|
|
4979f84e41 | ||
|
|
a19073ec01 | ||
|
|
1b39b5376f | ||
|
|
6559416bd8 | ||
|
|
fa25ecf521 | ||
|
|
6fb0256997 | ||
|
|
8c26403e91 | ||
|
|
90a89f8ca5 | ||
|
|
0bba1d95de | ||
|
|
b3f99645a3 | ||
|
|
76ced59b62 | ||
|
|
bc3731265e | ||
|
|
189c92affa | ||
|
|
4ec9cbe379 | ||
|
|
9648525ac1 | ||
|
|
b125780991 | ||
|
|
99104fc11d | ||
|
|
7cb137ae8d | ||
|
|
e55e79bcca | ||
|
|
0b644fd794 | ||
|
|
d5599ebfa3 | ||
|
|
f7d8781bac | ||
|
|
6f7298b9db | ||
|
|
d0b6d95f1b | ||
|
|
93b913e14d | ||
|
|
b96c8a0c2f | ||
|
|
a392a06cc0 | ||
|
|
d9af788514 | ||
|
|
a4724fec4a | ||
|
|
0e5580390f | ||
|
|
acc34cb618 | ||
|
|
d033a6e40d | ||
|
|
4fd8294b09 | ||
|
|
8d26d9da46 | ||
|
|
d81607c9d5 | ||
|
|
5ac71e0579 | ||
|
|
d04ecbcb0a | ||
|
|
e4987d9a59 | ||
|
|
155c6e94a3 | ||
|
|
4e285a4e70 | ||
|
|
9c00e681bb | ||
|
|
81369d7e04 | ||
|
|
160891592b | ||
|
|
70b20f90cd | ||
|
|
47a2adca96 | ||
|
|
a1f1acfbf9 | ||
|
|
00b9c082a3 | ||
|
|
45d2492bcb | ||
|
|
085d1e0d38 | ||
|
|
1404581e9b | ||
|
|
d5985be94a | ||
|
|
4ee1cd5826 | ||
|
|
dc7fce86a5 | ||
|
|
f22417e7e7 | ||
|
|
10c9661369 | ||
|
|
ad97b3d995 | ||
|
|
04e8e03d8f | ||
|
|
bd19013771 | ||
|
|
3901ffca17 | ||
|
|
cbd3308da6 | ||
|
|
0ad6b3b88e | ||
|
|
4e87f5aabc | ||
|
|
2019af831a | ||
|
|
1e076ea63d | ||
|
|
4863084fa2 | ||
|
|
7ba79171c7 | ||
|
|
e3c2aea3cc | ||
|
|
21c9530e8b | ||
|
|
036196a487 | ||
|
|
73855cacb7 | ||
|
|
8dad6d7e1c | ||
|
|
e5ffa2aa09 | ||
|
|
8445c381c5 | ||
|
|
fa46b7bf85 | ||
|
|
7ce2250d85 | ||
|
|
ef20d9b91a | ||
|
|
fbee310261 | ||
|
|
7d6bf4b0ca | ||
|
|
210834fbe9 | ||
|
|
24cf19710f | ||
|
|
a59660f421 | ||
|
|
be5af0b777 | ||
|
|
75e5fe7d27 | ||
|
|
2985258074 | ||
|
|
911ac65d1e | ||
|
|
d2967f514b | ||
|
|
a68c6a2cfc | ||
|
|
733f6aae85 | ||
|
|
1daece3bee | ||
|
|
adddd48c1d | ||
|
|
8c870cd3ca | ||
|
|
bd5eda92a7 | ||
|
|
cf09cef6d8 | ||
|
|
b3f9f8275d | ||
|
|
9597d474d0 | ||
|
|
e6f2e9791c | ||
|
|
31b1370270 | ||
|
|
064a4ce798 | ||
|
|
ac5843edb0 | ||
|
|
a1f64e4774 | ||
|
|
21d2ae709f | ||
|
|
c5e509f069 | ||
|
|
761c0ff9ac | ||
|
|
ce8289e753 | ||
|
|
2dd4f8b04a | ||
|
|
b4615f7655 | ||
|
|
fcaa787060 | ||
|
|
23c1fc3544 | ||
|
|
a4037a8268 | ||
|
|
61ee1c61df | ||
|
|
69f95f4148 | ||
|
|
212a413e93 | ||
|
|
de4b5a8f0f | ||
|
|
1228ce277f | ||
|
|
bd6fdd625a | ||
|
|
7de17ad949 | ||
|
|
7ab11a8379 | ||
|
|
70e0085596 | ||
|
|
f9ccc19df5 | ||
|
|
5c69568c7f | ||
|
|
1d69bd48be | ||
|
|
5b435c586e | ||
|
|
71e46d1eca | ||
|
|
238aff7c31 | ||
|
|
a1435bd566 | ||
|
|
59d8c570b7 | ||
|
|
8f34f69397 | ||
|
|
47af21d248 | ||
|
|
c2a3c1cb8f | ||
|
|
1e2d76a686 | ||
|
|
34468c16ad | ||
|
|
b84c6b4b32 | ||
|
|
8395cf8d5a | ||
|
|
c2bf7f09ce | ||
|
|
c2762d3b5e | ||
|
|
01d996a5c0 | ||
|
|
50739277c4 | ||
|
|
0fef4e6e2e | ||
|
|
218012558a | ||
|
|
e40e86500b | ||
|
|
6f0942ac6e | ||
|
|
a67927c29c | ||
|
|
7e50eed95e | ||
|
|
173b6c3f00 | ||
|
|
7646c683b5 | ||
|
|
047fe21c14 | ||
|
|
b59a601b52 | ||
|
|
ecb8ef6bb1 | ||
|
|
cd2eab6ba2 | ||
|
|
6a4d8329c3 | ||
|
|
b8dbb3f073 | ||
|
|
9a5decdb28 | ||
|
|
31e762d921 | ||
|
|
bb495f567c | ||
|
|
aa1db617d5 | ||
|
|
9b3e43ffc1 | ||
|
|
d5a0f8f23c | ||
|
|
ec5cfe0019 | ||
|
|
fd5626e9e2 | ||
|
|
53bf3420e7 | ||
|
|
127a27315e | ||
|
|
671441bdf8 | ||
|
|
5c6e2ed071 | ||
|
|
254b276a54 | ||
|
|
31df4e42d7 | ||
|
|
2b8eb7ed66 | ||
|
|
29fc0eff38 | ||
|
|
4917da2d2e | ||
|
|
8ea98b64aa | ||
|
|
4904b48f5c | ||
|
|
05d5ef602c | ||
|
|
a311519314 | ||
|
|
1dc146322c | ||
|
|
0f551baf37 | ||
|
|
b9190eddfe | ||
|
|
44dada9e60 | ||
|
|
1b8c517e3e | ||
|
|
20602889be | ||
|
|
4b06536582 | ||
|
|
621b38c98b | ||
|
|
321cf8bf7d | ||
|
|
762cdc812c | ||
|
|
dae5aa38a8 | ||
|
|
7d42e50f5b | ||
|
|
a4c083e7f9 | ||
|
|
e4f202834c | ||
|
|
6e0c380409 | ||
|
|
4cdf6eda2c | ||
|
|
652d50173e | ||
|
|
fa58a81852 | ||
|
|
f2fc2cc24a | ||
|
|
0a2fc08706 | ||
|
|
6e81f2430b | ||
|
|
509036f162 | ||
|
|
4040cb36bb | ||
|
|
53a659c0cf | ||
|
|
0cf412efb3 | ||
|
|
2c7977d3e8 | ||
|
|
85b5cb55de | ||
|
|
6ed69d8ed8 | ||
|
|
0a92ac97d4 | ||
|
|
955748f00f | ||
|
|
bc53bc7cfd | ||
|
|
5ce5f84f4a | ||
|
|
ffa7efedd6 | ||
|
|
5e6752db14 | ||
|
|
248ca5ee12 | ||
|
|
7fb2973431 | ||
|
|
3a419126f3 | ||
|
|
ef5c71374b | ||
|
|
5ccf2d7bcc | ||
|
|
c85936bb11 | ||
|
|
3fb5073feb | ||
|
|
75df1fa3ac | ||
|
|
931906c9f3 | ||
|
|
b6368b1296 | ||
|
|
cb1fa8b5ae | ||
|
|
601bc96734 | ||
|
|
8441aff066 | ||
|
|
9818f179c4 | ||
|
|
91e1d35a10 | ||
|
|
74c9a3dc50 | ||
|
|
55fc3fc177 | ||
|
|
724eac9168 | ||
|
|
a528cee5f4 | ||
|
|
e16917f63a | ||
|
|
984d19a9a5 | ||
|
|
229481c89c | ||
|
|
f9af698521 | ||
|
|
db96d5246f | ||
|
|
2e771cd65a | ||
|
|
638f227b51 | ||
|
|
0e73eb568e | ||
|
|
3261855b8f | ||
|
|
3bbabb8416 | ||
|
|
629b685f5a | ||
|
|
6b1a6d264b | ||
|
|
79540a8b9c | ||
|
|
99d62381b9 | ||
|
|
860d28e16c | ||
|
|
ac00c8f6ae | ||
|
|
b5fa93eda0 | ||
|
|
a8573f268b | ||
|
|
fa141e394b | ||
|
|
e72bb87cc1 | ||
|
|
32b294f1f3 | ||
|
|
70d4369d81 | ||
|
|
75f601c154 | ||
|
|
335cc234c8 | ||
|
|
5343781e14 | ||
|
|
a00bc95acc | ||
|
|
d289dc8a53 | ||
|
|
93deaa5687 | ||
|
|
102c05e927 | ||
|
|
cf598dc3cb | ||
|
|
1ecb0ca081 | ||
|
|
5459a55406 | ||
|
|
fa1c11f5f9 | ||
|
|
2623f0e360 | ||
|
|
b81eb35f3d | ||
|
|
66fffce87c | ||
|
|
6e8c9f92cb | ||
|
|
fc61aae20a | ||
|
|
3d9d25df52 | ||
|
|
5f3db017af | ||
|
|
69646e5b5d | ||
|
|
4e459b3383 | ||
|
|
8c5e8bdf78 | ||
|
|
6bc750cab7 | ||
|
|
70d9a77e9b | ||
|
|
6d2b5d976d | ||
|
|
57231382a6 | ||
|
|
1f6fc0630d | ||
|
|
e5ee405971 | ||
|
|
5522a7a2e5 | ||
|
|
cbc718437b | ||
|
|
8ca701b882 | ||
|
|
f5e253456c | ||
|
|
8cc29241e2 | ||
|
|
4ea0d05c17 | ||
|
|
7898c33819 | ||
|
|
d0ba87f7ee | ||
|
|
5ee961d3eb | ||
|
|
ac10e15c15 | ||
|
|
71159cf0c8 | ||
|
|
b2f22ac584 | ||
|
|
8c662c9d7b | ||
|
|
8cd3083e52 | ||
|
|
06227d4514 | ||
|
|
f5ce228059 | ||
|
|
53f8415e9b | ||
|
|
710964b47d | ||
|
|
4dafe424cf | ||
|
|
bc4a0a575c | ||
|
|
cf213affa2 | ||
|
|
e29aaaf162 | ||
|
|
979a320347 | ||
|
|
20bddd8e47 | ||
|
|
86f335b01f | ||
|
|
102204e293 | ||
|
|
67651354d5 | ||
|
|
cefb52471f | ||
|
|
ee5e0e13b7 | ||
|
|
30a8f25d52 | ||
|
|
d348c2099e | ||
|
|
6a400dda7b | ||
|
|
080c4ba680 | ||
|
|
37aca3f1c7 | ||
|
|
0158f1363b | ||
|
|
f47f2d13fa | ||
|
|
6fe6f4b3e0 | ||
|
|
00e4631b3b | ||
|
|
2e7503ff78 | ||
|
|
02fa5aa0fa | ||
|
|
9b4a67276a | ||
|
|
b607a09125 | ||
|
|
af89f05011 | ||
|
|
fed5161fc6 | ||
|
|
b8b97fa6d4 | ||
|
|
71f141f3f8 | ||
|
|
81fef1be19 | ||
|
|
0f175de599 | ||
|
|
1602befc51 | ||
|
|
162a838afc | ||
|
|
05a5e4372a | ||
|
|
e588abd4e7 | ||
|
|
f85b206bdf | ||
|
|
5b3bbfce10 | ||
|
|
2a9733fbaf | ||
|
|
96185faca6 | ||
|
|
af20b2ce0d | ||
|
|
919b92a0b5 | ||
|
|
3b0153ca7a | ||
|
|
5f16e4ef87 | ||
|
|
483dc06ecb | ||
|
|
b8e389c6e8 | ||
|
|
cde4ee91f8 | ||
|
|
ab45efceab | ||
|
|
cbdcf5905f | ||
|
|
62c0e6605c | ||
|
|
f1c6988552 | ||
|
|
e1197f7253 | ||
|
|
146062d921 | ||
|
|
96c4201929 | ||
|
|
a0bbcd2fee | ||
|
|
627b4c8b14 | ||
|
|
fd6c352881 | ||
|
|
3f7ba2e3d1 | ||
|
|
7c180727b9 | ||
|
|
19d4e2224b | ||
|
|
678edb1846 | ||
|
|
ae2ba5771f | ||
|
|
cd9dd2e679 | ||
|
|
47f9ed08e9 | ||
|
|
ee3c06394d | ||
|
|
ffba1d5037 | ||
|
|
ccc3d38c45 | ||
|
|
37517c7dd1 | ||
|
|
a4dee77728 | ||
|
|
a95318a4f9 | ||
|
|
46fad32837 | ||
|
|
5be40f62f3 | ||
|
|
fb75519ff8 | ||
|
|
5fea12d8eb | ||
|
|
8291098b6d | ||
|
|
1a000fecd5 | ||
|
|
a0dc66abe7 | ||
|
|
3d47d73ba9 | ||
|
|
443ebc46d6 | ||
|
|
99379ede8a | ||
|
|
4871095a3e | ||
|
|
21dc988e45 | ||
|
|
01e0dd50ad | ||
|
|
d3bc184971 | ||
|
|
c42f29446d | ||
|
|
1030e09fc1 | ||
|
|
96b930cd07 | ||
|
|
de08edb831 | ||
|
|
ee477b25e5 | ||
|
|
277f21d5b2 | ||
|
|
a7d5d9a1d6 | ||
|
|
fd0d76e866 | ||
|
|
646d8f431c | ||
|
|
ef0d562702 | ||
|
|
962fe9c36d | ||
|
|
50e2385e82 | ||
|
|
1cd3ef5dba | ||
|
|
80157fc1be | ||
|
|
c5fc37150d | ||
|
|
8932adbf88 | ||
|
|
d27d36b76a | ||
|
|
ba804c7d4a | ||
|
|
3db37166b4 | ||
|
|
bf02a569ee | ||
|
|
015982bed4 | ||
|
|
a1c5c94753 | ||
|
|
7a356412d5 | ||
|
|
1ea716a31f | ||
|
|
bb27bf9d34 | ||
|
|
a489f40b76 | ||
|
|
8ed87e7fbb | ||
|
|
cc96ac173c | ||
|
|
79f8270c35 | ||
|
|
336f9f3813 | ||
|
|
835c5e9d43 | ||
|
|
4789cf6c31 | ||
|
|
5f1f52b6ce | ||
|
|
62abfa96b8 | ||
|
|
a8a96b7631 | ||
|
|
af80d96b9e | ||
|
|
3c23fb0b13 | ||
|
|
bba0ea1255 | ||
|
|
750490cd2f | ||
|
|
ff8e44e4f3 | ||
|
|
579b8611be | ||
|
|
da12b92d75 | ||
|
|
a3f99bd781 | ||
|
|
7f846429cf | ||
|
|
2acaefdb2a | ||
|
|
9c2cdd2513 | ||
|
|
01683aa816 | ||
|
|
85f701b94e | ||
|
|
ff7cfe4715 | ||
|
|
d3cd3d62b4 | ||
|
|
91c67b085b | ||
|
|
cd8c7ec3c0 | ||
|
|
2c51a7970d | ||
|
|
fb362022f7 | ||
|
|
2814ae6d3c | ||
|
|
ed2967ec7d | ||
|
|
616fb47983 | ||
|
|
7225199deb | ||
|
|
c08a4e851b | ||
|
|
9f8e8c0856 | ||
|
|
9397ff8dd0 | ||
|
|
906ee75278 | ||
|
|
4049abf2c0 | ||
|
|
47798febed | ||
|
|
67b2503062 | ||
|
|
3a9cdb28ab | ||
|
|
79060f0bfe | ||
|
|
d5cfcb28fc | ||
|
|
40ea51e622 | ||
|
|
0397a3120f | ||
|
|
cc34734131 | ||
|
|
6dcde96f85 | ||
|
|
ccbc3af964 | ||
|
|
cd95ec4e12 | ||
|
|
fcd2d63df4 | ||
|
|
e68d49e7df | ||
|
|
5134080f87 | ||
|
|
3e44856d01 | ||
|
|
bd1c0033eb | ||
|
|
5514616372 | ||
|
|
01f3ed0e5e | ||
|
|
19fd7bc37e | ||
|
|
779d3dce6f | ||
|
|
3ade2bb6ec | ||
|
|
fd1155928e | ||
|
|
a8fe2d7e83 | ||
|
|
8ce996e065 | ||
|
|
892a1df280 | ||
|
|
44fa98497f | ||
|
|
cfd5d7ae35 | ||
|
|
7b4e5dd107 | ||
|
|
1289b1a283 | ||
|
|
2934841152 | ||
|
|
5ae72d1ed2 | ||
|
|
bc68836c8d | ||
|
|
f0112a2de2 | ||
|
|
94219b78e7 | ||
|
|
0f4b6d7d9f | ||
|
|
58418bcf46 | ||
|
|
e4cd52060c | ||
|
|
4f8552835e | ||
|
|
707f2835a8 | ||
|
|
1130aba7ca | ||
|
|
34ab93c9bd | ||
|
|
2d2b96420f | ||
|
|
77aaa15082 | ||
|
|
80bf47493e | ||
|
|
7d4c7718aa | ||
|
|
793ff1a728 | ||
|
|
4f7cdcce55 | ||
|
|
64a7978c7f | ||
|
|
7c6140b331 | ||
|
|
16d4a034e2 | ||
|
|
55c51ad49d | ||
|
|
cea14c9d0d | ||
|
|
fb0473da39 | ||
|
|
9d249904bd | ||
|
|
111dc4963d | ||
|
|
5a6d0455ec | ||
|
|
a5b9fe4c35 | ||
|
|
c95aec9da6 | ||
|
|
e0c674bc9e | ||
|
|
da9bd1d420 |
5
.github/CONTRIBUTING.md
vendored
5
.github/CONTRIBUTING.md
vendored
@@ -1,3 +1,5 @@
|
||||
### Please do **not** open pull requests for *new features* now, as we are planning to rewrite large chunks of the code. Only bugfix PRs will be accepted. More details will be announced soon!
|
||||
|
||||
NewPipe contribution guidelines
|
||||
===============================
|
||||
|
||||
@@ -22,6 +24,7 @@ You'll see *exactly* what is sent, be able to add **your comments**, and then se
|
||||
|
||||
* NewPipe is translated via [Weblate](https://hosted.weblate.org/projects/newpipe/strings/). Log in there with your GitHub account, or register.
|
||||
* Add the language you want to translate if it is not there already: see [How to add a new language](https://github.com/TeamNewPipe/NewPipe/wiki/How-to-add-a-new-language-to-NewPipe) in the wiki.
|
||||
* NewPipe uses the [PrettyTime](https://github.com/ocpsoft/prettytime) library to display localized versions of dates and times. It needs to be translated, too. Read [these instructions to add a new language](https://www.ocpsoft.org/prettytime/#section-14) and [this issue](https://github.com/TeamNewPipe/NewPipe/issues/9134) for more info.
|
||||
|
||||
## Code contribution
|
||||
|
||||
@@ -68,7 +71,7 @@ The [checkStyle](https://github.com/checkstyle/checkstyle) plugin verifies that
|
||||
- Go to `File -> Settings -> Plugins`, search for `checkstyle` and install `CheckStyle-IDEA`.
|
||||
- Go to `File -> Settings -> Tools -> Checkstyle`.
|
||||
- Add NewPipe's configuration file by clicking the `+` in the right toolbar of the "Configuration File" list.
|
||||
- Under the "Use a local Checkstyle file" bullet, click on `Browse` and pick the file named `checkstyle.xml` in the project's root folder.
|
||||
- Under the "Use a local Checkstyle file" bullet, click on `Browse` and, enter `checkstyle` folder under the project's root path and pick the file named `checkstyle.xml`.
|
||||
- Enable "Store relative to project location" so that moving the directory around does not create issues.
|
||||
- Insert a description in the top bar, then click `Next` and then `Finish`.
|
||||
- Activate the configuration file you just added by enabling the checkbox on the left.
|
||||
|
||||
@@ -5,7 +5,7 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this issue! :hugs:
|
||||
Thanks for taking the time to fill out this form! :hugs:
|
||||
|
||||
Note that you can also ask questions on our [IRC channel](https://web.libera.chat/#newpipe).
|
||||
|
||||
@@ -14,7 +14,9 @@ body:
|
||||
attributes:
|
||||
label: "Checklist"
|
||||
options:
|
||||
- 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."
|
||||
- label: "I made sure that there are *no existing issues or discussions* - [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
|
||||
- label: "I have read the [FAQ](https://newpipe.net/FAQ/) and my question isn't listed."
|
||||
required: true
|
||||
- label: "I have taken the time to fill in all the required details. I understand that the question will be dismissed otherwise."
|
||||
required: true
|
||||
@@ -27,7 +29,7 @@ body:
|
||||
label: What is/are your question(s)?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
- type: textarea
|
||||
id: additional-information
|
||||
attributes:
|
||||
10
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
10
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,6 +1,6 @@
|
||||
name: Bug report
|
||||
description: Create a bug report to help us improve
|
||||
labels: [bug]
|
||||
labels: [bug, needs triage]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@@ -18,6 +18,8 @@ body:
|
||||
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
|
||||
- label: "I have read the [FAQ](https://newpipe.net/FAQ/) and my problem isn't listed."
|
||||
required: true
|
||||
- label: "I have taken the time to fill in all the required details. I understand that the bug report will be dismissed otherwise."
|
||||
required: true
|
||||
- label: "This issue contains only one bug."
|
||||
@@ -40,7 +42,7 @@ body:
|
||||
label: Steps to reproduce the bug
|
||||
description: |
|
||||
What did you do for the bug to show up?
|
||||
|
||||
|
||||
If you can't cause the bug to show up again reliably (and hence don't have a proper set of steps to give us), please still try to give as many details as possible on how you think you encountered the bug.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
@@ -69,11 +71,11 @@ body:
|
||||
label: Screenshots/Screen recordings
|
||||
description: |
|
||||
A picture or video is worth a thousand words.
|
||||
|
||||
|
||||
If applicable, add screenshots or a screen recording to help explain your problem.
|
||||
GitHub supports uploading them directly in the text box.
|
||||
If your file is too big for Github to accept, try to compress it (ZIP-file) or feel free to paste a link to an image/video hoster here instead.
|
||||
|
||||
|
||||
:heavy_exclamation_mark: DON'T POST SCREENSHOTS OF THE ERROR PAGE.
|
||||
Instead, follow the instructions in the "Logs" section below.
|
||||
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/config.yml
vendored
3
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,5 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: ❓ Question
|
||||
url: https://github.com/TeamNewPipe/NewPipe/discussions/new?category=questions
|
||||
about: Ask about anything NewPipe-related
|
||||
- name: 💬 IRC
|
||||
url: https://web.libera.chat/#newpipe
|
||||
about: Chat with us via IRC for quick Q/A
|
||||
|
||||
7
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
7
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,6 +1,6 @@
|
||||
name: Feature request
|
||||
description: Suggest an idea for this project
|
||||
labels: [enhancement]
|
||||
labels: [feature request, needs triage]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@@ -8,7 +8,6 @@ body:
|
||||
Thank you for helping to make NewPipe better by suggesting a feature. :hugs:
|
||||
|
||||
Your ideas are highly welcome! The app is made for you, the users, after all.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
@@ -16,6 +15,8 @@ body:
|
||||
options:
|
||||
- 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
|
||||
- label: "I have read the [FAQ](https://newpipe.net/FAQ/) and my problem isn't listed."
|
||||
required: true
|
||||
- label: "I'm aware that this is a request for NewPipe itself and that requests for adding a new service need to be made at [NewPipeExtractor](https://github.com/TeamNewPipe/NewPipeExtractor/issues)."
|
||||
required: true
|
||||
- label: "I have taken the time to fill in all the required details. I understand that the feature request will be dismissed otherwise."
|
||||
@@ -43,7 +44,7 @@ body:
|
||||
Describe any problem or limitation you come across while using the app which would be solved by this feature.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
- type: textarea
|
||||
id: additional-information
|
||||
attributes:
|
||||
|
||||
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -25,10 +25,10 @@
|
||||
<!-- Delete this if it doesn't apply to your PR. -->
|
||||
-
|
||||
|
||||
#### APK testing
|
||||
#### APK testing
|
||||
<!-- Use a new, meaningfully named branch. The name is used as a suffix for the app ID to allow installing and testing multiple versions of NewPipe, e.g. "commentfix", if your PR implements a bugfix for comments. (No names like "patch-0" and "feature-1".) -->
|
||||
<!-- Remove the following line if you directly link the APK created by the CI pipeline. Directly linking is preferred if you need to let users test.-->
|
||||
The APK can be found by going to the "Checks" tab below the title. On the left pane, click on "CI", scroll down to "artifacts" and click "app" to download the zip file which contains the debug APK of this PR.
|
||||
The APK can be found by going to the "Checks" tab below the title. On the left pane, click on "CI", scroll down to "artifacts" and click "app" to download the zip file which contains the debug APK of this PR. You can find more info and a video demonstration [on this wiki page](https://github.com/TeamNewPipe/NewPipe/wiki/Download-APK-for-PR).
|
||||
|
||||
#### Due diligence
|
||||
- [ ] I read the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md).
|
||||
|
||||
66
.github/workflows/ci.yml
vendored
66
.github/workflows/ci.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
- release**
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- 'doc/**'
|
||||
@@ -30,19 +31,25 @@ on:
|
||||
jobs:
|
||||
build-and-test-jvm:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- uses: gradle/wrapper-validation-action@v1
|
||||
|
||||
- name: create and checkout branch
|
||||
# push events already checked out the branch
|
||||
if: github.event_name == 'pull_request'
|
||||
run: git checkout -B ${{ github.head_ref }}
|
||||
env:
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
run: git checkout -B "$BRANCH"
|
||||
|
||||
- name: set up JDK 11
|
||||
uses: actions/setup-java@v2
|
||||
- name: set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 11
|
||||
java-version: 17
|
||||
distribution: "temurin"
|
||||
cache: 'gradle'
|
||||
|
||||
@@ -50,7 +57,7 @@ jobs:
|
||||
run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: app
|
||||
path: app/build/outputs/apk/debug/*.apk
|
||||
@@ -61,15 +68,24 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
# api-level 19 is min sdk, but throws errors related to desugaring
|
||||
api-level: [ 21, 29 ]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
include:
|
||||
- api-level: 21
|
||||
target: default
|
||||
arch: x86
|
||||
- api-level: 33
|
||||
target: google_apis # emulator API 33 only exists with Google APIs
|
||||
arch: x86_64
|
||||
|
||||
- name: set up JDK 11
|
||||
uses: actions/setup-java@v2
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 11
|
||||
java-version: 17
|
||||
distribution: "temurin"
|
||||
cache: 'gradle'
|
||||
|
||||
@@ -77,12 +93,12 @@ jobs:
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: ${{ matrix.api-level }}
|
||||
# workaround to emulator bug: https://github.com/ReactiveCircus/android-emulator-runner/issues/160
|
||||
emulator-build: 7425822
|
||||
target: ${{ matrix.target }}
|
||||
arch: ${{ matrix.arch }}
|
||||
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@v2
|
||||
uses: actions/upload-artifact@v3
|
||||
if: failure()
|
||||
with:
|
||||
name: android-test-report-api${{ matrix.api-level }}
|
||||
@@ -90,20 +106,24 @@ jobs:
|
||||
|
||||
sonar:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: Set up JDK 11
|
||||
uses: actions/setup-java@v2
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 11 # Sonar requires JDK 11
|
||||
java-version: 17
|
||||
distribution: "temurin"
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Cache SonarCloud packages
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.sonar/cache
|
||||
key: ${{ runner.os }}-sonar
|
||||
@@ -113,4 +133,4 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
run: ./gradlew build sonarqube --info
|
||||
run: ./gradlew build sonar --info
|
||||
|
||||
102
.github/workflows/image-minimizer.js
vendored
102
.github/workflows/image-minimizer.js
vendored
@@ -4,7 +4,12 @@
|
||||
module.exports = async ({github, context}) => {
|
||||
const IGNORE_KEY = '<!-- IGNORE IMAGE MINIFY -->';
|
||||
const IGNORE_ALT_NAME_END = 'ignoreImageMinify';
|
||||
// Targeted maximum height
|
||||
const IMG_MAX_HEIGHT_PX = 600;
|
||||
// maximum width of GitHub issues/comments
|
||||
const IMG_MAX_WIDTH_PX = 800;
|
||||
// all images that have a lower aspect ratio (-> have a smaller width) than this will be minimized
|
||||
const MIN_ASPECT_RATIO = IMG_MAX_WIDTH_PX / IMG_MAX_HEIGHT_PX
|
||||
|
||||
// Get the body of the image
|
||||
let initialBody = null;
|
||||
@@ -25,10 +30,12 @@ module.exports = async ({github, context}) => {
|
||||
}
|
||||
|
||||
// Regex for finding images (simple variant) 
|
||||
const REGEX_IMAGE_LOOKUP = /\!\[(.*)\]\((https:\/\/[-a-z0-9]+\.githubusercontent\.com\/\d+\/[-0-9a-f]{32,512}\.(jpg|gif|png))\)/gm;
|
||||
const REGEX_USER_CONTENT_IMAGE_LOOKUP = /\!\[(.*)\]\((https:\/\/[-a-z0-9]+\.githubusercontent\.com\/\d+\/[-0-9a-f]{32,512}\.(jpg|gif|png))\)/gm;
|
||||
const REGEX_ASSETS_IMAGE_LOCKUP = /\!\[(.*)\]\((https:\/\/github\.com\/[-\w\d]+\/[-\w\d]+\/assets\/\d+\/[\-0-9a-f]{32,512})\)/gm;
|
||||
|
||||
// Check if we found something
|
||||
let foundSimpleImages = REGEX_IMAGE_LOOKUP.test(initialBody);
|
||||
let foundSimpleImages = REGEX_USER_CONTENT_IMAGE_LOOKUP.test(initialBody)
|
||||
|| REGEX_ASSETS_IMAGE_LOCKUP.test(initialBody);
|
||||
if (!foundSimpleImages) {
|
||||
console.log('Found no simple images to process');
|
||||
return;
|
||||
@@ -38,42 +45,17 @@ module.exports = async ({github, context}) => {
|
||||
|
||||
// Require the probe lib for getting the image dimensions
|
||||
const probe = require('probe-image-size');
|
||||
|
||||
var wasMatchModified = false;
|
||||
|
||||
// Try to find and replace the images with minimized ones
|
||||
let newBody = await replaceAsync(initialBody, REGEX_IMAGE_LOOKUP, async (match, g1, g2) => {
|
||||
console.log(`Found match '${match}'`);
|
||||
|
||||
if (g1.endsWith(IGNORE_ALT_NAME_END)) {
|
||||
console.log(`Ignoring match '${match}': IGNORE_ALT_NAME_END`);
|
||||
return match;
|
||||
}
|
||||
|
||||
let shouldModifiy = false;
|
||||
try {
|
||||
console.log(`Probing ${g2}`);
|
||||
let probeResult = await probe(g2);
|
||||
if (probeResult == null) {
|
||||
throw 'No probeResult';
|
||||
}
|
||||
if (probeResult.hUnits != 'px') {
|
||||
throw `Unexpected probeResult.hUnits (expected px but got ${probeResult.hUnits})`;
|
||||
}
|
||||
|
||||
shouldModifiy = probeResult.height > IMG_MAX_HEIGHT_PX;
|
||||
} catch(e) {
|
||||
console.log('Probing failed:', e);
|
||||
// Immediately abort
|
||||
return match;
|
||||
}
|
||||
|
||||
if (shouldModifiy) {
|
||||
console.log(`Modifying match '${match}'`);
|
||||
return `<img alt="${g1}" src="${g2}" height=${IMG_MAX_HEIGHT_PX} />`;
|
||||
}
|
||||
|
||||
console.log(`Match '${match}' is ok/will not be modified`);
|
||||
return match;
|
||||
});
|
||||
let newBody = await replaceAsync(initialBody, REGEX_USER_CONTENT_IMAGE_LOOKUP, minimizeAsync);
|
||||
newBody = await replaceAsync(newBody, REGEX_ASSETS_IMAGE_LOCKUP, minimizeAsync);
|
||||
|
||||
if (!wasMatchModified) {
|
||||
console.log('Nothing was modified. Skipping update');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the corresponding element
|
||||
if (context.eventName == 'issue_comment') {
|
||||
@@ -104,4 +86,52 @@ module.exports = async ({github, context}) => {
|
||||
const data = await Promise.all(promises);
|
||||
return str.replace(regex, () => data.shift());
|
||||
}
|
||||
|
||||
async function minimizeAsync(match, g1, g2) {
|
||||
console.log(`Found match '${match}'`);
|
||||
|
||||
if (g1.endsWith(IGNORE_ALT_NAME_END)) {
|
||||
console.log(`Ignoring match '${match}': IGNORE_ALT_NAME_END`);
|
||||
return match;
|
||||
}
|
||||
|
||||
let probeAspectRatio = 0;
|
||||
let shouldModify = false;
|
||||
try {
|
||||
console.log(`Probing ${g2}`);
|
||||
let probeResult = await probe(g2);
|
||||
if (probeResult == null) {
|
||||
throw 'No probeResult';
|
||||
}
|
||||
if (probeResult.hUnits != 'px') {
|
||||
throw `Unexpected probeResult.hUnits (expected px but got ${probeResult.hUnits})`;
|
||||
}
|
||||
if (probeResult.height <= 0) {
|
||||
throw `Unexpected probeResult.height (height is invalid: ${probeResult.height})`;
|
||||
}
|
||||
if (probeResult.wUnits != 'px') {
|
||||
throw `Unexpected probeResult.wUnits (expected px but got ${probeResult.wUnits})`;
|
||||
}
|
||||
if (probeResult.width <= 0) {
|
||||
throw `Unexpected probeResult.width (width is invalid: ${probeResult.width})`;
|
||||
}
|
||||
console.log(`Probing resulted in ${probeResult.width}x${probeResult.height}px`);
|
||||
|
||||
probeAspectRatio = probeResult.width / probeResult.height;
|
||||
shouldModify = probeResult.height > IMG_MAX_HEIGHT_PX && probeAspectRatio < MIN_ASPECT_RATIO;
|
||||
} catch(e) {
|
||||
console.log('Probing failed:', e);
|
||||
// Immediately abort
|
||||
return match;
|
||||
}
|
||||
|
||||
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))} />`;
|
||||
}
|
||||
|
||||
console.log(`Match '${match}' is ok/will not be modified`);
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
10
.github/workflows/image-minimizer.yml
vendored
10
.github/workflows/image-minimizer.yml
vendored
@@ -6,14 +6,18 @@ on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
try-minimize:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
@@ -21,7 +25,7 @@ jobs:
|
||||
run: npm i probe-image-size@7.2.3 --ignore-scripts
|
||||
|
||||
- name: Minimize simple images
|
||||
uses: actions/github-script@v5
|
||||
uses: actions/github-script@v6
|
||||
timeout-minutes: 3
|
||||
with:
|
||||
script: |
|
||||
|
||||
6
.github/workflows/no-response.yml
vendored
6
.github/workflows/no-response.yml
vendored
@@ -9,6 +9,10 @@ on:
|
||||
# Run daily at midnight.
|
||||
- cron: '0 0 * * *'
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
noResponse:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -17,4 +21,4 @@ jobs:
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
daysUntilClose: 14
|
||||
responseRequiredLabel: waiting-for-author
|
||||
responseRequiredLabel: waiting for author
|
||||
|
||||
8
LICENSE
8
LICENSE
@@ -1,7 +1,7 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
@@ -645,7 +645,7 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
@@ -664,11 +664,11 @@ might be different; for a GUI interface, you would use an "about box".
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
157
README.md
157
README.md
@@ -1,6 +1,9 @@
|
||||
<h3 align="center">We are planning to <i>rewrite</i> large chunks of the codebase, to bring about <a href="https://github.com/TeamNewPipe/NewPipe/discussions/10118">a new, modern and stable NewPipe</a>!</h3>
|
||||
<h4 align="center">Please do <b>not</b> open pull requests for <i>new features</i> now, only bugfix PRs will be accepted.</h4>
|
||||
|
||||
<p align="center"><a href="https://newpipe.net"><img src="assets/new_pipe_icon_5.png" width="150"></a></p>
|
||||
<h2 align="center"><b>NewPipe</b></h2>
|
||||
<h4 align="center">A libre lightweight streaming frontend for Android.</h4>
|
||||
<h4 align="center">A libre lightweight streaming front-end for Android.</h4>
|
||||
|
||||
<p align="center"><a href="https://f-droid.org/packages/org.schabi.newpipe/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on-en.svg" alt="Get it on F-Droid" height=80/></a></p>
|
||||
|
||||
@@ -13,87 +16,91 @@
|
||||
<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>
|
||||
</p>
|
||||
<hr>
|
||||
<p align="center"><a href="#screenshots">Screenshots</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="#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.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 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).*
|
||||
|
||||
<b>WARNING: THIS IS A BETA VERSION, THEREFORE YOU MAY ENCOUNTER BUGS. IF YOU DO, OPEN AN ISSUE VIA OUR GITHUB REPOSITORY.</b>
|
||||
<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>
|
||||
<b>PUTTING NEWPIPE, OR ANY FORK OF IT, INTO THE GOOGLE PLAY STORE VIOLATES THEIR TERMS AND CONDITIONS.</b>
|
||||
|
||||
## Screenshots
|
||||
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot_01.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_01.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot_02.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_02.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot_03.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_03.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot_04.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_04.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot_05.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_05.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot_06.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_06.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot_07.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_07.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot_08.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_08.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot_09.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_09.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot_10.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_10.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/tenInchScreenshots/shot_11.png" width=405>](fastlane/metadata/android/en-US/images/tenInchScreenshots/shot_11.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/tenInchScreenshots/shot_12.png" width=405>](fastlane/metadata/android/en-US/images/tenInchScreenshots/shot_12.png)
|
||||
|
||||
## Description
|
||||
|
||||
NewPipe does not use any Google framework libraries, nor the YouTube API. Websites are only parsed to fetch required info, so this app can be used on devices without Google services installed. Also, you don't need a YouTube account to use NewPipe, which is copylefted libre software.
|
||||
|
||||
### Features
|
||||
|
||||
* Search videos
|
||||
* No Login Required
|
||||
* Display general info about videos
|
||||
* Watch YouTube videos
|
||||
* Listen to YouTube videos
|
||||
* Popup mode (floating player)
|
||||
* Select streaming player to watch video with
|
||||
* Download videos
|
||||
* Download audio only
|
||||
* Open a video in Kodi
|
||||
* Show next/related videos
|
||||
* Search YouTube in a specific language
|
||||
* Watch/Block age restricted material
|
||||
* Display general info about channels
|
||||
* Search channels
|
||||
* Watch videos from a channel
|
||||
* Orbot/Tor support (not yet directly)
|
||||
* 1080p/2K/4K support
|
||||
* View history
|
||||
* Subscribe to channels
|
||||
* Search history
|
||||
* Search/watch playlists
|
||||
* Watch as enqueued playlists
|
||||
* Enqueue videos
|
||||
* Local playlists
|
||||
* Subtitles
|
||||
* Livestream support
|
||||
* Show comments
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/00.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/00.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/01.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/02.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/03.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/04.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/05.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/06.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/07.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/07.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/08.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/08.png)
|
||||
<br/><br/>
|
||||
[<img src="fastlane/metadata/android/en-US/images/tenInchScreenshots/09.png" width=405>](fastlane/metadata/android/en-US/images/tenInchScreenshots/09.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/tenInchScreenshots/10.png" width=405>](fastlane/metadata/android/en-US/images/tenInchScreenshots/10.png)
|
||||
|
||||
### Supported Services
|
||||
|
||||
NewPipe supports multiple services. Our [docs](https://teamnewpipe.github.io/documentation/) provide more info on how a new service can be added to the app and the extractor. Please get in touch with us if you intend to add a new one. Currently supported services are:
|
||||
NewPipe currently supports these services:
|
||||
|
||||
* YouTube
|
||||
* SoundCloud \[beta\]
|
||||
* media.ccc.de \[beta\]
|
||||
* PeerTube instances \[beta\]
|
||||
* Bandcamp \[beta\]
|
||||
<!-- We link to the service websites separately to avoid people accidentally opening a website they didn't want to. -->
|
||||
* YouTube ([website](https://www.youtube.com/)) and YouTube Music ([website](https://music.youtube.com/)) ([wiki](https://en.wikipedia.org/wiki/YouTube))
|
||||
* PeerTube ([website](https://joinpeertube.org/)) and all its instances (open the website to know what that means!) ([wiki](https://en.wikipedia.org/wiki/PeerTube))
|
||||
* Bandcamp ([website](https://bandcamp.com/)) ([wiki](https://en.wikipedia.org/wiki/Bandcamp))
|
||||
* SoundCloud ([website](https://soundcloud.com/)) ([wiki](https://en.wikipedia.org/wiki/SoundCloud))
|
||||
* media.ccc.de ([website](https://media.ccc.de/)) ([wiki](https://en.wikipedia.org/wiki/Chaos_Computer_Club))
|
||||
|
||||
<!-- Hidden span to keep old links compatible. -->
|
||||
As you can see, NewPipe supports multiple video and audio services. Though it started off with YouTube, other people have added more services over the years, making NewPipe more and more versatile!
|
||||
|
||||
Partially due to circumstance, and partially due to its popularity, YouTube is the best supported out of these services. If you use or are familiar with any of these other services, please help us improve support for them! We're looking for maintainers for SoundCloud and PeerTube.
|
||||
|
||||
If you intend to add a new service, please get in touch with us first! Our [docs](https://teamnewpipe.github.io/documentation/) provide more information on how a new service can be added to the app and to the [NewPipe Extractor](https://github.com/TeamNewPipe/NewPipeExtractor).
|
||||
|
||||
## Description
|
||||
|
||||
NewPipe works by fetching the required data from the official API (e.g. PeerTube) of the service you're using. If the official API is restricted (e.g. YouTube) for our purposes, or is proprietary, the app parses the website or uses an internal API instead. This means that you don't need an account on any service to use NewPipe.
|
||||
|
||||
Also, since they are free and open source software, neither the app nor the Extractor use any proprietary libraries or frameworks, such as Google Play Services. This means you can use NewPipe on devices or custom ROMs that do not have Google apps installed.
|
||||
|
||||
### Features
|
||||
|
||||
* Watch videos at resolutions up to 4K
|
||||
* Listen to audio in the background, only loading the audio stream to save data
|
||||
* Popup mode (floating player, aka Picture-in-Picture)
|
||||
* Watch live streams
|
||||
* Show/hide subtitles/closed captions
|
||||
* Search videos and audios (on YouTube, you can specify the content language as well)
|
||||
* Enqueue videos (and optionally save them as local playlists)
|
||||
* Show/hide general information about videos (such as description and tags)
|
||||
* Show/hide next/related videos
|
||||
* Show/hide comments
|
||||
* Search videos, audios, channels, playlists and albums
|
||||
* Browse videos and audios within a channel
|
||||
* Subscribe to channels (yes, without logging into any account!)
|
||||
* Get notifications about new videos from channels you're subscribed to
|
||||
* Create and edit channel groups (for easier browsing and management)
|
||||
* Browse video feeds generated from your channel groups
|
||||
* View and search your watch history
|
||||
* Search and watch playlists (these are remote playlists, which means they're fetched from the service you're browsing)
|
||||
* Create and edit local playlists (these are created and saved within the app, and have nothing to do with any service)
|
||||
* Download videos/audios/subtitles (closed captions)
|
||||
* Open in Kodi
|
||||
* Watch/Block age-restricted material
|
||||
|
||||
<!-- Hidden span to keep old links compatible. You should remove this span if you're translating the README into another language.-->
|
||||
<span id="updates"></span>
|
||||
|
||||
## Installation and updates
|
||||
You can install NewPipe using one of the following methods:
|
||||
1. Add our custom repo to F-Droid and install it from there. The instructions are here: https://newpipe.net/FAQ/tutorials/install-add-fdroid-repo/
|
||||
2. Download the APK from [GitHub Releases](https://github.com/TeamNewPipe/NewPipe/releases) and install it.
|
||||
3. Update via F-Droid. This is the slowest method of getting updates, as F-Droid must recognize changes, build the APK itself, sign it, then push the update to users.
|
||||
3. Update via F-Droid. This is the slowest method of getting updates, as F-Droid must recognize changes, build the APK itself, sign it, and then push the update to users.
|
||||
4. Build a debug APK yourself. This is the fastest way to get new features on your device, but is much more complicated, so we recommend using one of the other methods.
|
||||
5. If you're interested in a specific feature or bugfix provided in a Pull Request in this repo, you can also download its APK from within the PR. Read the PR description for instructions. The great thing about PR-specific APKs is that they're installed side-by-side the official app, so you don't have to worry about losing your data or messing anything up.
|
||||
|
||||
We recommend method 1 for most users. APKs installed using method 1 or 2 are compatible with each other, but not with those installed using method 3. This is due to the same signing key (ours) being used for 1 and 2, but a different signing key (F-Droid's) being used for 3. Building a debug APK using method 4 excludes a key entirely. Signing keys help ensure that a user isn't tricked into installing a malicious update to an app.
|
||||
We recommend method 1 for most users. APKs installed using method 1 or 2 are compatible with each other (meaning that if you installed NewPipe using either method 1 or 2, you can also update NewPipe using the other), but not with those installed using method 3. This is due to the same signing key (ours) being used for 1 and 2, but a different signing key (F-Droid's) being used for 3. Building a debug APK using method 4 excludes a key entirely. Signing keys help ensure that a user isn't tricked into installing a malicious update to an app. When using method 5, each APK is signed with a different random key supplied by GitHub Actions, so you cannot even update it. You will have to backup and restore the app data each time you wish to use a new APK.
|
||||
|
||||
In the meanwhile, if you want to switch sources for some reason (e.g. NewPipe's core functionality breaks and F-Droid doesn't have the latest update yet), we recommend following this procedure:
|
||||
1. Back up your data via Settings > Content > Export Database so you keep your history, subscriptions, and playlists
|
||||
@@ -101,30 +108,29 @@ In the meanwhile, if you want to switch sources for some reason (e.g. NewPipe's
|
||||
3. Download the APK from the new source and install it
|
||||
4. Import the data from step 1 via Settings > Content > Import Database
|
||||
|
||||
## Contribution
|
||||
Whether you have ideas, translations, design changes, code cleaning, or real heavy code changes, help is always welcome.
|
||||
The more is done the better it gets!
|
||||
<b>Note: when you're importing a database into the official app, always make sure that it is the one you exported _from_ the official app. If you import a database exported from an APK other than the official app, it may break things. Such an action is unsupported, and you should only do so when you're absolutely certain you know what you're doing.</b>
|
||||
|
||||
If you'd like to get involved, check our [contribution notes](.github/CONTRIBUTING.md).
|
||||
## Contribution
|
||||
Whether you have ideas, translations, design changes, code cleaning, or even major code changes, help is always welcome. The app gets better and better with each contribution, no matter how big or small! If you'd like to get involved, check our [contribution notes](.github/CONTRIBUTING.md).
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/newpipe/">
|
||||
<img src="https://hosted.weblate.org/widgets/newpipe/-/287x66-grey.png" alt="Translation status" />
|
||||
</a>
|
||||
|
||||
## Donate
|
||||
If you like NewPipe we'd be happy about a donation. You can either send bitcoin or donate via Bountysource or Liberapay. For further info on donating to NewPipe, please visit our [website](https://newpipe.net/donate).
|
||||
If you like NewPipe, you're welcome to send a donation. We prefer Liberapay, as it is both open-source and non-profit. For further info on donating to NewPipe, please visit our [website](https://newpipe.net/donate).
|
||||
|
||||
<table>
|
||||
<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://liberapay.com/TeamNewPipe/"><img src="https://upload.wikimedia.org/wikipedia/commons/2/27/Liberapay_logo_v2_white-on-yellow.svg" alt="Liberapay" width="80px" ></a></td>
|
||||
<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>
|
||||
@@ -134,14 +140,9 @@ If you like NewPipe we'd be happy about a donation. You can either send bitcoin
|
||||
|
||||
## Privacy Policy
|
||||
|
||||
The NewPipe project aims to provide a private, anonymous experience for using media web services.
|
||||
Therefore, the app does not collect any data without your consent. NewPipe's privacy policy explains in detail what data is sent and stored when you send a crash report, or comment in our blog. You can find the document [here](https://newpipe.net/legal/privacy/).
|
||||
The NewPipe project aims to provide a private, anonymous experience for using web-based media services. Therefore, the app does not collect any data without your consent. NewPipe's privacy policy explains in detail what data is sent and stored when you send a crash report, or leave a comment in our blog. You can find the document [here](https://newpipe.net/legal/privacy/).
|
||||
|
||||
## License
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
|
||||
NewPipe is Free Software: You can use, study, share, and improve it at
|
||||
will. Specifically you can redistribute and/or modify it under the terms of the
|
||||
[GNU General Public License](https://www.gnu.org/licenses/gpl.html) as
|
||||
published by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
NewPipe is Free Software: You can use, study, share, and improve it at will. Specifically you can redistribute and/or modify it under the terms of the [GNU General Public License](https://www.gnu.org/licenses/gpl.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
140
app/build.gradle
140
app/build.gradle
@@ -1,28 +1,29 @@
|
||||
import com.android.tools.profgen.ArtProfileKt
|
||||
import com.android.tools.profgen.ArtProfileSerializer
|
||||
import com.android.tools.profgen.DexFile
|
||||
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "kotlin-kapt"
|
||||
id "kotlin-parcelize"
|
||||
id "checkstyle"
|
||||
id "org.sonarqube" version "3.3"
|
||||
id "org.sonarqube" version "4.0.0.2929"
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 30
|
||||
buildToolsVersion '30.0.3'
|
||||
compileSdk 33
|
||||
namespace 'org.schabi.newpipe'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "org.schabi.newpipe"
|
||||
resValue "string", "app_name", "NewPipe"
|
||||
minSdk 19
|
||||
targetSdk 29
|
||||
versionCode 983
|
||||
versionName "0.22.0"
|
||||
|
||||
multiDexEnabled true
|
||||
minSdk 21
|
||||
targetSdk 33
|
||||
versionCode 993
|
||||
versionName "0.25.1"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
javaCompileOptions {
|
||||
annotationProcessorOptions {
|
||||
@@ -79,13 +80,13 @@ android {
|
||||
// Flag to enable support for the new language APIs
|
||||
coreLibraryDesugaringEnabled true
|
||||
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
encoding 'utf-8'
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11
|
||||
jvmTarget = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
@@ -95,24 +96,32 @@ android {
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
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']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ext {
|
||||
checkstyleVersion = '9.2.1'
|
||||
checkstyleVersion = '10.12.1'
|
||||
|
||||
androidxLifecycleVersion = '2.3.1'
|
||||
androidxRoomVersion = '2.3.0'
|
||||
androidxLifecycleVersion = '2.5.1'
|
||||
androidxRoomVersion = '2.4.3'
|
||||
androidxWorkVersion = '2.7.1'
|
||||
|
||||
icepickVersion = '3.2.0'
|
||||
exoPlayerVersion = '2.14.2'
|
||||
exoPlayerVersion = '2.18.7'
|
||||
googleAutoServiceVersion = '1.0.1'
|
||||
groupieVersion = '2.10.0'
|
||||
groupieVersion = '2.10.1'
|
||||
markwonVersion = '4.6.2'
|
||||
|
||||
leakCanaryVersion = '2.5'
|
||||
leakCanaryVersion = '2.9.1'
|
||||
stethoVersion = '1.6.0'
|
||||
mockitoVersion = '4.0.0'
|
||||
assertJVersion = '3.22.0'
|
||||
}
|
||||
|
||||
configurations {
|
||||
@@ -121,7 +130,7 @@ configurations {
|
||||
}
|
||||
|
||||
checkstyle {
|
||||
getConfigDirectory().set(rootProject.file("."))
|
||||
getConfigDirectory().set(rootProject.file("checkstyle"))
|
||||
ignoreFailures false
|
||||
showViolations true
|
||||
toolVersion = checkstyleVersion
|
||||
@@ -154,6 +163,7 @@ task runKtlint(type: JavaExec) {
|
||||
getMainClass().set("com.pinterest.ktlint.Main")
|
||||
classpath = configurations.ktlint
|
||||
args "src/**/*.kt"
|
||||
jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED")
|
||||
}
|
||||
|
||||
task formatKtlint(type: JavaExec) {
|
||||
@@ -162,6 +172,7 @@ task formatKtlint(type: JavaExec) {
|
||||
getMainClass().set("com.pinterest.ktlint.Main")
|
||||
classpath = configurations.ktlint
|
||||
args "-F", "src/**/*.kt"
|
||||
jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED")
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
@@ -171,7 +182,7 @@ afterEvaluate {
|
||||
preDebugBuild.dependsOn runCheckstyle, runKtlint
|
||||
}
|
||||
|
||||
sonarqube {
|
||||
sonar {
|
||||
properties {
|
||||
property "sonar.projectKey", "TeamNewPipe_NewPipe"
|
||||
property "sonar.organization", "teamnewpipe"
|
||||
@@ -181,7 +192,7 @@ sonarqube {
|
||||
|
||||
dependencies {
|
||||
/** Desugaring **/
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
|
||||
|
||||
/** NewPipe libraries **/
|
||||
// You can use a local version by uncommenting a few lines in settings.gradle
|
||||
@@ -189,28 +200,28 @@ 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:v0.21.14'
|
||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:8495ad619e'
|
||||
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
|
||||
|
||||
/** Checkstyle **/
|
||||
checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}"
|
||||
ktlint 'com.pinterest:ktlint:0.43.2'
|
||||
ktlint 'com.pinterest:ktlint:0.45.2'
|
||||
|
||||
/** Kotlin **/
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin_version}"
|
||||
|
||||
/** AndroidX **/
|
||||
implementation 'androidx.appcompat:appcompat:1.3.1'
|
||||
implementation 'androidx.appcompat:appcompat:1.5.1'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
|
||||
implementation 'androidx.core:core-ktx:1.6.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.core:core-ktx:1.10.0'
|
||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.3.6'
|
||||
implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}"
|
||||
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0'
|
||||
implementation 'androidx.media:media:1.4.3'
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
implementation 'androidx.preference:preference:1.1.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.4.1'
|
||||
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.room:room-runtime:${androidxRoomVersion}"
|
||||
implementation "androidx.room:room-rxjava3:${androidxRoomVersion}"
|
||||
@@ -219,8 +230,9 @@ dependencies {
|
||||
// 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.webkit:webkit:1.4.0'
|
||||
implementation 'com.google.android.material:material:1.4.0'
|
||||
implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}"
|
||||
implementation "androidx.work:work-rxjava3:${androidxWorkVersion}"
|
||||
implementation 'com.google.android.material:material:1.6.1'
|
||||
|
||||
/** Third-party libraries **/
|
||||
// Instance state boilerplate elimination
|
||||
@@ -228,14 +240,19 @@ dependencies {
|
||||
kapt "frankiesardo:icepick-processor:${icepickVersion}"
|
||||
|
||||
// HTML parser
|
||||
implementation "org.jsoup:jsoup:1.14.3"
|
||||
implementation "org.jsoup:jsoup:1.16.1"
|
||||
|
||||
// HTTP client
|
||||
//noinspection GradleDependency --> do not update okhttp to keep supporting Android 4.4 users
|
||||
implementation "com.squareup.okhttp3:okhttp:3.12.13"
|
||||
implementation "com.squareup.okhttp3:okhttp:4.11.0"
|
||||
|
||||
// Media player
|
||||
implementation "com.google.android.exoplayer:exoplayer:${exoPlayerVersion}"
|
||||
implementation "com.google.android.exoplayer:exoplayer-core:${exoPlayerVersion}"
|
||||
implementation "com.google.android.exoplayer:exoplayer-dash:${exoPlayerVersion}"
|
||||
implementation "com.google.android.exoplayer:exoplayer-database:${exoPlayerVersion}"
|
||||
implementation "com.google.android.exoplayer:exoplayer-datasource:${exoPlayerVersion}"
|
||||
implementation "com.google.android.exoplayer:exoplayer-hls:${exoPlayerVersion}"
|
||||
implementation "com.google.android.exoplayer:exoplayer-smoothstreaming:${exoPlayerVersion}"
|
||||
implementation "com.google.android.exoplayer:exoplayer-ui:${exoPlayerVersion}"
|
||||
implementation "com.google.android.exoplayer:extension-mediasession:${exoPlayerVersion}"
|
||||
|
||||
// Metadata generator for service descriptors
|
||||
@@ -246,8 +263,6 @@ dependencies {
|
||||
implementation "com.github.lisawray.groupie:groupie:${groupieVersion}"
|
||||
implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}"
|
||||
|
||||
// Circular ImageView
|
||||
implementation "de.hdodenhof:circleimageview:3.1.0"
|
||||
// Image loading
|
||||
//noinspection GradleDependency --> 2.8 is the last version, not 2.71828!
|
||||
implementation "com.squareup.picasso:picasso:2.8"
|
||||
@@ -256,23 +271,20 @@ dependencies {
|
||||
implementation "io.noties.markwon:core:${markwonVersion}"
|
||||
implementation "io.noties.markwon:linkify:${markwonVersion}"
|
||||
|
||||
// File picker
|
||||
implementation "com.nononsenseapps:filepicker:4.2.1"
|
||||
|
||||
// Crash reporting
|
||||
implementation "ch.acra:acra-core:5.7.0"
|
||||
implementation "ch.acra:acra-core:5.10.1"
|
||||
|
||||
// Properly restarting
|
||||
implementation 'com.jakewharton:process-phoenix:2.1.2'
|
||||
|
||||
// Reactive extensions for Java VM
|
||||
implementation "io.reactivex.rxjava3:rxjava:3.0.13"
|
||||
implementation "io.reactivex.rxjava3:rxandroid:3.0.0"
|
||||
implementation "io.reactivex.rxjava3:rxjava:3.1.6"
|
||||
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.2.Final"
|
||||
implementation "org.ocpsoft.prettytime:prettytime:5.0.6.Final"
|
||||
|
||||
/** Debugging **/
|
||||
// Memory leak detection
|
||||
@@ -288,10 +300,10 @@ dependencies {
|
||||
testImplementation "org.mockito:mockito-core:${mockitoVersion}"
|
||||
testImplementation "org.mockito:mockito-inline:${mockitoVersion}"
|
||||
|
||||
androidTestImplementation "androidx.test.ext:junit:1.1.3"
|
||||
androidTestImplementation "androidx.test:runner:1.4.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:${assertJVersion}"
|
||||
androidTestImplementation "org.assertj:assertj-core:3.23.1"
|
||||
}
|
||||
|
||||
static String getGitWorkingBranch() {
|
||||
@@ -309,3 +321,25 @@ static String getGitWorkingBranch() {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// fix reproducible builds
|
||||
project.afterEvaluate {
|
||||
tasks.compileReleaseArtProfile.doLast {
|
||||
outputs.files.each { file ->
|
||||
if (file.toString().endsWith(".profm")) {
|
||||
println("Sorting ${file} ...")
|
||||
def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
|
||||
def profile = ArtProfileKt.ArtProfile(file)
|
||||
def keys = new ArrayList(profile.profileData.keySet())
|
||||
def sortedData = new LinkedHashMap()
|
||||
Collections.sort keys, new DexFile.Companion()
|
||||
keys.each { key -> sortedData[key] = profile.profileData[key] }
|
||||
new FileOutputStream(file).with {
|
||||
write(version.magicBytes$profgen)
|
||||
write(version.versionBytes$profgen)
|
||||
version.write$profgen(it, sortedData, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
44
app/proguard-rules.pro
vendored
44
app/proguard-rules.pro
vendored
@@ -1,36 +1,18 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# By default, the flags in this file are appended to flags specified
|
||||
# in /home/the-scrabi/bin/Android/Sdk/tools/proguard/proguard-android.txt
|
||||
# You can edit the include path and order by changing the proguardFiles
|
||||
# directive in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# Add any project specific keep options here:
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
# https://developer.android.com/build/shrink-code
|
||||
|
||||
## Helps debug release versions
|
||||
-dontobfuscate
|
||||
|
||||
## Rules for NewPipeExtractor
|
||||
-keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; }
|
||||
-keep class org.ocpsoft.prettytime.i18n.** { *; }
|
||||
|
||||
-keep class org.mozilla.javascript.** { *; }
|
||||
|
||||
-keep class org.mozilla.classfile.ClassFileWriter
|
||||
-dontwarn org.mozilla.javascript.tools.**
|
||||
|
||||
## Rules for ExoPlayer
|
||||
-keep class com.google.android.exoplayer2.** { *; }
|
||||
|
||||
-dontwarn org.mozilla.javascript.tools.**
|
||||
-dontwarn android.arch.util.paging.CountedDataSource
|
||||
-dontwarn android.arch.persistence.room.paging.LimitOffsetDataSource
|
||||
|
||||
|
||||
# Rules for icepick. Copy paste from https://github.com/frankiesardo/icepick
|
||||
## Rules for Icepick. Copy pasted from https://github.com/frankiesardo/icepick
|
||||
-dontwarn icepick.**
|
||||
-keep class icepick.** { *; }
|
||||
-keep class **$$Icepick { *; }
|
||||
@@ -39,15 +21,17 @@
|
||||
}
|
||||
-keepnames class * { @icepick.State *;}
|
||||
|
||||
# Rules for OkHttp. Copy paste from https://github.com/square/okhttp
|
||||
## Rules for OkHttp. Copy pasted from https://github.com/square/okhttp
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-dontwarn javax.annotation.**
|
||||
# A resource is loaded with a relative path so the package of this class must be preserved.
|
||||
-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
|
||||
|
||||
## See https://github.com/TeamNewPipe/NewPipe/pull/1441
|
||||
-keepclassmembers class * implements java.io.Serializable {
|
||||
static final long serialVersionUID;
|
||||
!static !transient <fields>;
|
||||
private void writeObject(java.io.ObjectOutputStream);
|
||||
private void readObject(java.io.ObjectInputStream);
|
||||
}
|
||||
|
||||
## For some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml)
|
||||
-keep class org.schabi.newpipe.settings.notifications.** { *; }
|
||||
|
||||
19
app/sampledata/channels.json
Normal file
19
app/sampledata/channels.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"name": "BBC",
|
||||
"additional": "12K subscribers•233 videos",
|
||||
"description": "The BBC is the world’s leading public service broadcaster. We’re impartial and independent, and every day we create distinctive, world-class programmes and content which inform, educate and entertain millions of people in the UK and around the world. SUBSCRIBE to our YouTube channel to get the best of BBC entertainment and comedy programmes, stories from science and nature documentaries, and much more! https://bit.ly/2IXqEIn Get ALL your fresh TV, and sofa-hugging box sets on iPlayer https://bbc.in/2J18jYJ"
|
||||
},
|
||||
{
|
||||
"name": "Linus Tech Tips",
|
||||
"additional": "1M subscribers•233 videos",
|
||||
"description": "Looking for a Tech YouTuber?\n\nLinus Tech Tips is a passionate team of \"professionally curious\" experts in consumer technology and video production which aims to inform and educate people of all ages through our entertaining videos. We create product reviews, step-by-step computer build guides, and a variety of other tech-focused content.\n\nSchedule:\nNew videos every Saturday to Thursday @ 10:00am Pacific\nLive WAN Show podcasts every Friday @ ~5:00pm Pacific"
|
||||
},
|
||||
{
|
||||
"name": "Marques Brownlee",
|
||||
"additional": "13 subscribers•12K videos",
|
||||
"description": "MKBHD: Quality Tech Videos | YouTuber | Geek | Consumer Electronics | Tech Head | Internet Personality!\n\nbusiness@MKBHD.com\n\nNYC"
|
||||
}
|
||||
]
|
||||
}
|
||||
719
app/schemas/org.schabi.newpipe.database.AppDatabase/5.json
Normal file
719
app/schemas/org.schabi.newpipe.database.AppDatabase/5.json
Normal file
@@ -0,0 +1,719 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 5,
|
||||
"identityHash": "096731b513bb71dd44517639f4a2c1e3",
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_subscriptions_service_id_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"service_id",
|
||||
"url"
|
||||
],
|
||||
"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}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "creationDate",
|
||||
"columnName": "creation_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "service_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "search",
|
||||
"columnName": "search",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_search_history_search",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"search"
|
||||
],
|
||||
"createSql": "CREATE INDEX 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": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_streams_service_id_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"service_id",
|
||||
"url"
|
||||
],
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"stream_id",
|
||||
"access_date"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_stream_history_stream_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"stream_id"
|
||||
],
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"stream_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "streams",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"stream_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "playlists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnail_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_playlists_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"createSql": "CREATE INDEX 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": {
|
||||
"columnNames": [
|
||||
"playlist_id",
|
||||
"join_index"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_playlist_stream_join_playlist_id_join_index",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"playlist_id",
|
||||
"join_index"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX 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"
|
||||
],
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_remote_playlists_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"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"
|
||||
],
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"stream_id",
|
||||
"subscription_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_feed_subscription_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"subscription_id"
|
||||
],
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_feed_group_sort_order",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sort_order"
|
||||
],
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"group_id",
|
||||
"subscription_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_feed_group_subscription_join_subscription_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"subscription_id"
|
||||
],
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"subscription_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "subscriptions",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"subscription_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '096731b513bb71dd44517639f4a2c1e3')"
|
||||
]
|
||||
}
|
||||
}
|
||||
737
app/schemas/org.schabi.newpipe.database.AppDatabase/6.json
Normal file
737
app/schemas/org.schabi.newpipe.database.AppDatabase/6.json
Normal file
@@ -0,0 +1,737 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 6,
|
||||
"identityHash": "4084aa342aef315dc7b558770a7755a9",
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"stream_id",
|
||||
"access_date"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"stream_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "streams",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"stream_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "playlists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnail_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isThumbnailPermanent",
|
||||
"columnName": "is_thumbnail_permanent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"playlist_id",
|
||||
"join_index"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"stream_id",
|
||||
"subscription_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"group_id",
|
||||
"subscription_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"subscription_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "subscriptions",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"subscription_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4084aa342aef315dc7b558770a7755a9')"
|
||||
]
|
||||
}
|
||||
}
|
||||
737
app/schemas/org.schabi.newpipe.database.AppDatabase/7.json
Normal file
737
app/schemas/org.schabi.newpipe.database.AppDatabase/7.json
Normal file
@@ -0,0 +1,737 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 7,
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"stream_id",
|
||||
"access_date"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"stream_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "streams",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"stream_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "playlists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `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": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"playlist_id",
|
||||
"join_index"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"stream_id",
|
||||
"subscription_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"group_id",
|
||||
"subscription_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"subscription_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "subscriptions",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"subscription_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '012fc8e7ad3333f1597347f34e76a513')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package org.schabi.newpipe.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.room.Room
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DatabaseMigrationTest {
|
||||
companion object {
|
||||
private const val DEFAULT_SERVICE_ID = 0
|
||||
private const val DEFAULT_URL = "https://www.youtube.com/watch?v=cDphUib5iG4"
|
||||
private const val DEFAULT_TITLE = "Test Title"
|
||||
private val DEFAULT_TYPE = StreamType.VIDEO_STREAM
|
||||
private const val DEFAULT_DURATION = 480L
|
||||
private const val DEFAULT_UPLOADER_NAME = "Uploader Test"
|
||||
private const val DEFAULT_THUMBNAIL = "https://example.com/example.jpg"
|
||||
|
||||
private const val DEFAULT_SECOND_SERVICE_ID = 0
|
||||
private const val DEFAULT_SECOND_URL = "https://www.youtube.com/watch?v=ncQU6iBn5Fc"
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val testHelper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AppDatabase::class.java.canonicalName,
|
||||
FrameworkSQLiteOpenHelperFactory()
|
||||
)
|
||||
|
||||
@Test
|
||||
fun migrateDatabaseFrom2to3() {
|
||||
val databaseInV2 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_2)
|
||||
|
||||
databaseInV2.run {
|
||||
insert(
|
||||
"streams",
|
||||
SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", DEFAULT_SERVICE_ID)
|
||||
put("url", DEFAULT_URL)
|
||||
put("title", DEFAULT_TITLE)
|
||||
put("stream_type", DEFAULT_TYPE.name)
|
||||
put("duration", DEFAULT_DURATION)
|
||||
put("uploader", DEFAULT_UPLOADER_NAME)
|
||||
put("thumbnail_url", DEFAULT_THUMBNAIL)
|
||||
}
|
||||
)
|
||||
insert(
|
||||
"streams",
|
||||
SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", DEFAULT_SECOND_SERVICE_ID)
|
||||
put("url", DEFAULT_SECOND_URL)
|
||||
}
|
||||
)
|
||||
insert(
|
||||
"streams",
|
||||
SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", DEFAULT_SERVICE_ID)
|
||||
}
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME,
|
||||
Migrations.DB_VER_3,
|
||||
true,
|
||||
Migrations.MIGRATION_2_3
|
||||
)
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME,
|
||||
Migrations.DB_VER_4,
|
||||
true,
|
||||
Migrations.MIGRATION_3_4
|
||||
)
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME,
|
||||
Migrations.DB_VER_5,
|
||||
true,
|
||||
Migrations.MIGRATION_4_5
|
||||
)
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME,
|
||||
Migrations.DB_VER_6,
|
||||
true,
|
||||
Migrations.MIGRATION_5_6
|
||||
)
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME,
|
||||
Migrations.DB_VER_7,
|
||||
true,
|
||||
Migrations.MIGRATION_6_7
|
||||
)
|
||||
|
||||
val migratedDatabaseV3 = getMigratedDatabase()
|
||||
val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst()
|
||||
|
||||
// Only expect 2, the one with the null url will be ignored
|
||||
assertEquals(2, listFromDB.size)
|
||||
|
||||
val streamFromMigratedDatabase = listFromDB[0]
|
||||
assertEquals(DEFAULT_SERVICE_ID, streamFromMigratedDatabase.serviceId)
|
||||
assertEquals(DEFAULT_URL, streamFromMigratedDatabase.url)
|
||||
assertEquals(DEFAULT_TITLE, streamFromMigratedDatabase.title)
|
||||
assertEquals(DEFAULT_TYPE, streamFromMigratedDatabase.streamType)
|
||||
assertEquals(DEFAULT_DURATION, streamFromMigratedDatabase.duration)
|
||||
assertEquals(DEFAULT_UPLOADER_NAME, streamFromMigratedDatabase.uploader)
|
||||
assertEquals(DEFAULT_THUMBNAIL, streamFromMigratedDatabase.thumbnailUrl)
|
||||
assertNull(streamFromMigratedDatabase.viewCount)
|
||||
assertNull(streamFromMigratedDatabase.textualUploadDate)
|
||||
assertNull(streamFromMigratedDatabase.uploadDate)
|
||||
assertNull(streamFromMigratedDatabase.isUploadDateApproximation)
|
||||
|
||||
val secondStreamFromMigratedDatabase = listFromDB[1]
|
||||
assertEquals(DEFAULT_SECOND_SERVICE_ID, secondStreamFromMigratedDatabase.serviceId)
|
||||
assertEquals(DEFAULT_SECOND_URL, secondStreamFromMigratedDatabase.url)
|
||||
assertEquals("", secondStreamFromMigratedDatabase.title)
|
||||
// Should fallback to VIDEO_STREAM
|
||||
assertEquals(StreamType.VIDEO_STREAM, secondStreamFromMigratedDatabase.streamType)
|
||||
assertEquals(0, secondStreamFromMigratedDatabase.duration)
|
||||
assertEquals("", secondStreamFromMigratedDatabase.uploader)
|
||||
assertEquals("", secondStreamFromMigratedDatabase.thumbnailUrl)
|
||||
assertNull(secondStreamFromMigratedDatabase.viewCount)
|
||||
assertNull(secondStreamFromMigratedDatabase.textualUploadDate)
|
||||
assertNull(secondStreamFromMigratedDatabase.uploadDate)
|
||||
assertNull(secondStreamFromMigratedDatabase.isUploadDateApproximation)
|
||||
}
|
||||
|
||||
private fun getMigratedDatabase(): AppDatabase {
|
||||
val database: AppDatabase = Room.databaseBuilder(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
AppDatabase::class.java,
|
||||
AppDatabase.DATABASE_NAME
|
||||
)
|
||||
.build()
|
||||
testHelper.closeWhenFinished(database)
|
||||
return database
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package org.schabi.newpipe.util
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.view.View.GONE
|
||||
import android.view.View.INVISIBLE
|
||||
import android.view.View.VISIBLE
|
||||
import android.widget.Spinner
|
||||
import androidx.collection.SparseArrayCompat
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
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.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.stream.AudioStream
|
||||
import org.schabi.newpipe.extractor.stream.Stream
|
||||
import org.schabi.newpipe.extractor.stream.SubtitlesStream
|
||||
import org.schabi.newpipe.extractor.stream.VideoStream
|
||||
|
||||
@MediumTest
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class StreamItemAdapterTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var spinner: Spinner
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
UiThreadStatement.runOnUiThread {
|
||||
spinner = Spinner(context)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoStreams_noSecondaryStream() {
|
||||
val adapter = StreamItemAdapter<VideoStream, AudioStream>(
|
||||
getVideoStreams(true, true, true, true)
|
||||
)
|
||||
|
||||
spinner.adapter = adapter
|
||||
assertIconVisibility(spinner, 0, VISIBLE, VISIBLE)
|
||||
assertIconVisibility(spinner, 1, VISIBLE, VISIBLE)
|
||||
assertIconVisibility(spinner, 2, VISIBLE, VISIBLE)
|
||||
assertIconVisibility(spinner, 3, VISIBLE, VISIBLE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoStreams_hasSecondaryStream() {
|
||||
val adapter = StreamItemAdapter(
|
||||
getVideoStreams(false, true, false, true),
|
||||
getAudioStreams(false, true, false, true)
|
||||
)
|
||||
|
||||
spinner.adapter = adapter
|
||||
assertIconVisibility(spinner, 0, GONE, GONE)
|
||||
assertIconVisibility(spinner, 1, GONE, GONE)
|
||||
assertIconVisibility(spinner, 2, GONE, GONE)
|
||||
assertIconVisibility(spinner, 3, GONE, GONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoStreams_Mixed() {
|
||||
val adapter = StreamItemAdapter(
|
||||
getVideoStreams(true, true, true, true, true, false, true, true),
|
||||
getAudioStreams(false, true, false, false, false, true, true, true)
|
||||
)
|
||||
|
||||
spinner.adapter = adapter
|
||||
assertIconVisibility(spinner, 0, VISIBLE, VISIBLE)
|
||||
assertIconVisibility(spinner, 1, GONE, INVISIBLE)
|
||||
assertIconVisibility(spinner, 2, VISIBLE, VISIBLE)
|
||||
assertIconVisibility(spinner, 3, VISIBLE, VISIBLE)
|
||||
assertIconVisibility(spinner, 4, VISIBLE, VISIBLE)
|
||||
assertIconVisibility(spinner, 5, GONE, INVISIBLE)
|
||||
assertIconVisibility(spinner, 6, GONE, INVISIBLE)
|
||||
assertIconVisibility(spinner, 7, GONE, INVISIBLE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subtitleStreams_noIcon() {
|
||||
val adapter = StreamItemAdapter<SubtitlesStream, Stream>(
|
||||
StreamItemAdapter.StreamSizeWrapper(
|
||||
(0 until 5).map {
|
||||
SubtitlesStream.Builder()
|
||||
.setContent("https://example.com", true)
|
||||
.setMediaFormat(MediaFormat.SRT)
|
||||
.setLanguageCode("pt-BR")
|
||||
.setAutoGenerated(false)
|
||||
.build()
|
||||
},
|
||||
context
|
||||
)
|
||||
)
|
||||
spinner.adapter = adapter
|
||||
for (i in 0 until spinner.count) {
|
||||
assertIconVisibility(spinner, i, GONE, GONE)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioStreams_noIcon() {
|
||||
val adapter = StreamItemAdapter<AudioStream, Stream>(
|
||||
StreamItemAdapter.StreamSizeWrapper(
|
||||
(0 until 5).map {
|
||||
AudioStream.Builder()
|
||||
.setId(Stream.ID_UNKNOWN)
|
||||
.setContent("https://example.com/$it", true)
|
||||
.setMediaFormat(MediaFormat.OPUS)
|
||||
.setAverageBitrate(192)
|
||||
.build()
|
||||
},
|
||||
context
|
||||
)
|
||||
)
|
||||
spinner.adapter = adapter
|
||||
for (i in 0 until spinner.count) {
|
||||
assertIconVisibility(spinner, i, GONE, GONE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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(
|
||||
videoOnly.map {
|
||||
VideoStream.Builder()
|
||||
.setId(Stream.ID_UNKNOWN)
|
||||
.setContent("https://example.com", true)
|
||||
.setMediaFormat(MediaFormat.MPEG_4)
|
||||
.setResolution("720p")
|
||||
.setIsVideoOnly(it)
|
||||
.build()
|
||||
},
|
||||
context
|
||||
)
|
||||
|
||||
/**
|
||||
* @return a list of audio streams, containing valid and null elements mirroring the provided
|
||||
* [shouldBeValid] vararg.
|
||||
*/
|
||||
private fun getAudioStreams(vararg shouldBeValid: Boolean) =
|
||||
getSecondaryStreamsFromList(
|
||||
shouldBeValid.map {
|
||||
if (it) {
|
||||
AudioStream.Builder()
|
||||
.setId(Stream.ID_UNKNOWN)
|
||||
.setContent("https://example.com", true)
|
||||
.setMediaFormat(MediaFormat.OPUS)
|
||||
.setAverageBitrate(192)
|
||||
.build()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
private fun assertIconVisibility(
|
||||
spinner: Spinner,
|
||||
position: Int,
|
||||
normalVisibility: Int,
|
||||
dropDownVisibility: Int
|
||||
) {
|
||||
spinner.setSelection(position)
|
||||
spinner.adapter.getView(position, null, spinner).run {
|
||||
Assert.assertEquals(
|
||||
"normal visibility (pos=[$position]) is not correct",
|
||||
findViewById<View>(R.id.wo_sound_icon).visibility,
|
||||
normalVisibility,
|
||||
)
|
||||
}
|
||||
spinner.adapter.getDropDownView(position, null, spinner).run {
|
||||
Assert.assertEquals(
|
||||
"drop down visibility (pos=[$position]) is not correct",
|
||||
findViewById<View>(R.id.wo_sound_icon).visibility,
|
||||
dropDownVisibility
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function that builds a secondary stream list.
|
||||
*/
|
||||
private fun <T : Stream> getSecondaryStreamsFromList(streams: List<T?>) =
|
||||
SparseArrayCompat<SecondaryStreamHelper<T>?>(streams.size).apply {
|
||||
streams.forEachIndexed { index, stream ->
|
||||
val secondaryStreamHelper: SecondaryStreamHelper<T>? = stream?.let {
|
||||
SecondaryStreamHelper(
|
||||
StreamItemAdapter.StreamSizeWrapper(streams, context),
|
||||
it
|
||||
)
|
||||
}
|
||||
put(index, secondaryStreamHelper)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="org.schabi.newpipe">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application
|
||||
android:name=".DebugApp"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="org.schabi.newpipe"
|
||||
android:installLocation="auto">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
@@ -10,10 +9,22 @@
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<!-- We need to be able to open links in the browser on API 30+ -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="http" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
@@ -22,11 +33,12 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:logo="@mipmap/ic_launcher"
|
||||
android:theme="@style/OpeningTheme"
|
||||
android:resizeableActivity="true"
|
||||
android:theme="@style/OpeningTheme"
|
||||
tools:ignore="AllowBackup">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
@@ -37,15 +49,17 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver">
|
||||
<receiver
|
||||
android:name="androidx.media.session.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name=".player.MainPlayer"
|
||||
android:exported="false"
|
||||
android:name=".player.PlayerService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
@@ -54,15 +68,18 @@
|
||||
|
||||
<activity
|
||||
android:name=".player.PlayQueueActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/title_activity_play_queue"
|
||||
android:launchMode="singleTask" />
|
||||
|
||||
<activity
|
||||
android:name=".settings.SettingsActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/settings" />
|
||||
|
||||
<activity
|
||||
android:name=".about.AboutActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/title_activity_about" />
|
||||
|
||||
<service android:name=".local.subscription.services.SubscriptionsImportService" />
|
||||
@@ -71,6 +88,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".PanicResponderActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleInstance"
|
||||
android:noHistory="true"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
@@ -83,13 +101,18 @@
|
||||
|
||||
<activity
|
||||
android:name=".ExitActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/general_error"
|
||||
android:theme="@android:style/Theme.NoDisplay" />
|
||||
<activity android:name=".error.ErrorActivity" />
|
||||
|
||||
<activity
|
||||
android:name=".error.ErrorActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- giga get related -->
|
||||
<activity
|
||||
android:name=".download.DownloadActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/app_name"
|
||||
android:launchMode="singleTask" />
|
||||
|
||||
@@ -97,6 +120,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".util.FilePickerActivityHelper"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/FilePickerThemeDark">
|
||||
<intent-filter>
|
||||
@@ -107,6 +131,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".error.ReCaptchaActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/recaptcha" />
|
||||
|
||||
<provider
|
||||
@@ -122,6 +147,7 @@
|
||||
<activity
|
||||
android:name=".RouterActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:label="@string/preferred_open_action_share_menu_title"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/RouterActivityThemeDark">
|
||||
@@ -147,10 +173,12 @@
|
||||
<data android:pathPrefix="/watch" />
|
||||
<data android:pathPrefix="/attribution_link" />
|
||||
<data android:pathPrefix="/shorts/" />
|
||||
<data android:pathPrefix="/live/" />
|
||||
<!-- channel prefix -->
|
||||
<data android:pathPrefix="/channel/" />
|
||||
<data android:pathPrefix="/user/" />
|
||||
<data android:pathPrefix="/c/" />
|
||||
<data android:pathPrefix="/@" />
|
||||
<!-- playlist prefix -->
|
||||
<data android:pathPrefix="/playlist" />
|
||||
</intent-filter>
|
||||
@@ -329,16 +357,16 @@
|
||||
<data android:host="eduvid.org" />
|
||||
<data android:host="framatube.org" />
|
||||
<data android:host="media.assassinate-you.net" />
|
||||
<data android:host="media.fsfe.org" />
|
||||
<data android:host="peertube.co.uk" />
|
||||
<data android:host="peertube.cpy.re" />
|
||||
<data android:host="peertube.mastodon.host" />
|
||||
<data android:host="peertube.fr" />
|
||||
<data android:host="tilvids.com" />
|
||||
<data android:host="tube.privacytools.io" />
|
||||
<data android:host="video.ploud.fr" />
|
||||
<data android:host="video.lqdn.fr" />
|
||||
<data android:host="peertube.mastodon.host" />
|
||||
<data android:host="peertube.stream" />
|
||||
<data android:host="skeptikon.fr" />
|
||||
<data android:host="media.fsfe.org" />
|
||||
<data android:host="tilvids.com" />
|
||||
<data android:host="video.lqdn.fr" />
|
||||
<data android:host="video.ploud.fr" />
|
||||
|
||||
<data android:pathPrefix="/videos/" /> <!-- it contains playlists -->
|
||||
<data android:pathPrefix="/w/" /> <!-- short video URLs -->
|
||||
@@ -351,46 +379,49 @@
|
||||
|
||||
<!-- Bandcamp filter for tracks, albums and playlists -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH"/>
|
||||
<action android:name="android.nfc.action.NDEF_DISCOVERED"/>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
|
||||
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="http"/>
|
||||
<data android:scheme="https"/>
|
||||
<data android:host="*.bandcamp.com"/>
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="*.bandcamp.com" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Bandcamp filter for radio -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH"/>
|
||||
<action android:name="android.nfc.action.NDEF_DISCOVERED"/>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
|
||||
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="http"/>
|
||||
<data android:scheme="https"/>
|
||||
<data android:sspPattern="bandcamp.com/?show=*"/>
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:sspPattern="bandcamp.com/?show=*" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
<service
|
||||
android:name=".RouterActivity$FetcherService"
|
||||
android:exported="false" />
|
||||
<service
|
||||
android:name=".CheckForNewAppVersion"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- opting out of sending metrics to Google in Android System WebView -->
|
||||
<meta-data android:name="android.webkit.WebView.MetricsOptOut" android:value="true" />
|
||||
<meta-data
|
||||
android:name="android.webkit.WebView.MetricsOptOut"
|
||||
android:value="true" />
|
||||
<!-- see https://github.com/TeamNewPipe/NewPipe/issues/3947 -->
|
||||
<!-- Version < 3.0. DeX Mode and Screen Mirroring support -->
|
||||
<meta-data android:name="com.samsung.android.keepalive.density" android:value="true"/>
|
||||
<meta-data
|
||||
android:name="com.samsung.android.keepalive.density"
|
||||
android:value="true" />
|
||||
<!-- Version >= 3.0. DeX Dual Mode support -->
|
||||
<meta-data android:name="com.samsung.android.multidisplay.keep_process_alive" android:value="true"/>
|
||||
<meta-data
|
||||
android:name="com.samsung.android.multidisplay.keep_process_alive"
|
||||
android:value="true" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -282,11 +282,9 @@ public abstract class FragmentStatePagerAdapterMenuWorkaround extends PagerAdapt
|
||||
@Nullable
|
||||
public Parcelable saveState() {
|
||||
Bundle state = null;
|
||||
if (mSavedState.size() > 0) {
|
||||
if (!mSavedState.isEmpty()) {
|
||||
state = new Bundle();
|
||||
final Fragment.SavedState[] fss = new Fragment.SavedState[mSavedState.size()];
|
||||
mSavedState.toArray(fss);
|
||||
state.putParcelableArray("states", fss);
|
||||
state.putParcelableArray("states", mSavedState.toArray(new Fragment.SavedState[0]));
|
||||
}
|
||||
for (int i = 0; i < mFragments.size(); i++) {
|
||||
final Fragment f = mFragments.get(i);
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout;
|
||||
import org.schabi.newpipe.R;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
// See https://stackoverflow.com/questions/56849221#57997489
|
||||
@@ -27,7 +26,7 @@ public final class FlingBehavior extends AppBarLayout.Behavior {
|
||||
|
||||
private boolean allowScroll = true;
|
||||
private final Rect globalRect = new Rect();
|
||||
private final List<Integer> skipInterceptionOfElements = Arrays.asList(
|
||||
private final List<Integer> skipInterceptionOfElements = List.of(
|
||||
R.id.itemsListPanel, R.id.playbackSeekBar,
|
||||
R.id.playPauseButton, R.id.playPreviousButton, R.id.playNextButton);
|
||||
|
||||
@@ -67,7 +66,7 @@ public final class FlingBehavior extends AppBarLayout.Behavior {
|
||||
public boolean onInterceptTouchEvent(@NonNull final CoordinatorLayout parent,
|
||||
@NonNull final AppBarLayout child,
|
||||
@NonNull final MotionEvent ev) {
|
||||
for (final Integer element : skipInterceptionOfElements) {
|
||||
for (final int element : skipInterceptionOfElements) {
|
||||
final View view = child.findViewById(element);
|
||||
if (view != null) {
|
||||
final boolean visible = view.getGlobalVisibleRect(globalRect);
|
||||
@@ -132,8 +131,8 @@ public final class FlingBehavior extends AppBarLayout.Behavior {
|
||||
try {
|
||||
final Class<?> headerBehaviorType = this.getClass().getSuperclass().getSuperclass();
|
||||
if (headerBehaviorType != null) {
|
||||
final Field field
|
||||
= headerBehaviorType.getDeclaredField("lastNestedScrollingChildRef");
|
||||
final Field field =
|
||||
headerBehaviorType.getDeclaredField("lastNestedScrollingChildRef");
|
||||
field.setAccessible(true);
|
||||
return field;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.schabi.newpipe;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
@@ -7,19 +8,13 @@ import android.util.Log;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.app.NotificationChannelCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.multidex.MultiDexApplication;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import com.jakewharton.processphoenix.ProcessPhoenix;
|
||||
|
||||
import org.acra.ACRA;
|
||||
import org.acra.config.ACRAConfigurationException;
|
||||
import org.acra.config.CoreConfiguration;
|
||||
import org.acra.config.CoreConfigurationBuilder;
|
||||
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.NewPipe;
|
||||
import org.schabi.newpipe.extractor.downloader.Downloader;
|
||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
||||
@@ -32,9 +27,8 @@ import org.schabi.newpipe.util.StateSaver;
|
||||
import java.io.IOException;
|
||||
import java.io.InterruptedIOException;
|
||||
import java.net.SocketException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import io.reactivex.rxjava3.exceptions.CompositeException;
|
||||
import io.reactivex.rxjava3.exceptions.MissingBackpressureException;
|
||||
@@ -61,7 +55,7 @@ import io.reactivex.rxjava3.plugins.RxJavaPlugins;
|
||||
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
public class App extends MultiDexApplication {
|
||||
public class App extends Application {
|
||||
public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID;
|
||||
private static final String TAG = App.class.toString();
|
||||
private static App app;
|
||||
@@ -145,7 +139,7 @@ public class App extends MultiDexApplication {
|
||||
if (throwable instanceof UndeliverableException) {
|
||||
// As UndeliverableException is a wrapper,
|
||||
// get the cause of it to get the "real" exception
|
||||
actualThrowable = throwable.getCause();
|
||||
actualThrowable = Objects.requireNonNull(throwable.getCause());
|
||||
} else {
|
||||
actualThrowable = throwable;
|
||||
}
|
||||
@@ -154,7 +148,7 @@ public class App extends MultiDexApplication {
|
||||
if (actualThrowable instanceof CompositeException) {
|
||||
errors = ((CompositeException) actualThrowable).getExceptions();
|
||||
} else {
|
||||
errors = Collections.singletonList(actualThrowable);
|
||||
errors = List.of(actualThrowable);
|
||||
}
|
||||
|
||||
for (final Throwable error : errors) {
|
||||
@@ -210,52 +204,48 @@ public class App extends MultiDexApplication {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final CoreConfiguration acraConfig = new CoreConfigurationBuilder(this)
|
||||
.setBuildConfigClass(BuildConfig.class)
|
||||
.build();
|
||||
ACRA.init(this, acraConfig);
|
||||
} catch (final ACRAConfigurationException exception) {
|
||||
exception.printStackTrace();
|
||||
ErrorUtil.openActivity(this, new ErrorInfo(exception,
|
||||
UserAction.SOMETHING_ELSE, "Could not initialize ACRA crash report"));
|
||||
}
|
||||
final CoreConfigurationBuilder acraConfig = new CoreConfigurationBuilder()
|
||||
.withBuildConfigClass(BuildConfig.class);
|
||||
ACRA.init(this, acraConfig);
|
||||
}
|
||||
|
||||
private void initNotificationChannels() {
|
||||
// Keep the importance below DEFAULT to avoid making noise on every notification update for
|
||||
// the main and update channels
|
||||
final NotificationChannelCompat mainChannel = new NotificationChannelCompat
|
||||
.Builder(getString(R.string.notification_channel_id),
|
||||
final List<NotificationChannelCompat> notificationChannelCompats = List.of(
|
||||
new NotificationChannelCompat.Builder(getString(R.string.notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.notification_channel_name))
|
||||
.setDescription(getString(R.string.notification_channel_description))
|
||||
.build();
|
||||
|
||||
final NotificationChannelCompat appUpdateChannel = new NotificationChannelCompat
|
||||
.Builder(getString(R.string.app_update_notification_channel_id),
|
||||
.setName(getString(R.string.notification_channel_name))
|
||||
.setDescription(getString(R.string.notification_channel_description))
|
||||
.build(),
|
||||
new NotificationChannelCompat
|
||||
.Builder(getString(R.string.app_update_notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.app_update_notification_channel_name))
|
||||
.setDescription(getString(R.string.app_update_notification_channel_description))
|
||||
.build();
|
||||
|
||||
final NotificationChannelCompat hashChannel = new NotificationChannelCompat
|
||||
.Builder(getString(R.string.hash_channel_id),
|
||||
.setName(getString(R.string.app_update_notification_channel_name))
|
||||
.setDescription(
|
||||
getString(R.string.app_update_notification_channel_description))
|
||||
.build(),
|
||||
new NotificationChannelCompat.Builder(getString(R.string.hash_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||
.setName(getString(R.string.hash_channel_name))
|
||||
.setDescription(getString(R.string.hash_channel_description))
|
||||
.build();
|
||||
|
||||
final NotificationChannelCompat errorReportChannel = new NotificationChannelCompat
|
||||
.Builder(getString(R.string.error_report_channel_id),
|
||||
.setName(getString(R.string.hash_channel_name))
|
||||
.setDescription(getString(R.string.hash_channel_description))
|
||||
.build(),
|
||||
new NotificationChannelCompat.Builder(getString(R.string.error_report_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.error_report_channel_name))
|
||||
.setDescription(getString(R.string.error_report_channel_description))
|
||||
.build();
|
||||
.setName(getString(R.string.error_report_channel_name))
|
||||
.setDescription(getString(R.string.error_report_channel_description))
|
||||
.build(),
|
||||
new NotificationChannelCompat
|
||||
.Builder(getString(R.string.streams_notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
||||
.setName(getString(R.string.streams_notification_channel_name))
|
||||
.setDescription(
|
||||
getString(R.string.streams_notification_channel_description))
|
||||
.build()
|
||||
);
|
||||
|
||||
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
|
||||
notificationManager.createNotificationChannelsCompat(Arrays.asList(mainChannel,
|
||||
appUpdateChannel, hashChannel, errorReportChannel));
|
||||
notificationManager.createNotificationChannelsCompat(notificationChannelCompats);
|
||||
}
|
||||
|
||||
protected boolean isDisposedRxExceptionsReported() {
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
package org.schabi.newpipe;
|
||||
|
||||
import android.app.Application;
|
||||
import android.app.IntentService;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.Signature;
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.core.content.pm.PackageInfoCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import com.grack.nanojson.JsonObject;
|
||||
import com.grack.nanojson.JsonParser;
|
||||
import com.grack.nanojson.JsonParserException;
|
||||
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.downloader.Response;
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.List;
|
||||
|
||||
public final class CheckForNewAppVersion extends IntentService {
|
||||
public CheckForNewAppVersion() {
|
||||
super("CheckForNewAppVersion");
|
||||
}
|
||||
|
||||
private static final boolean DEBUG = MainActivity.DEBUG;
|
||||
private static final String TAG = CheckForNewAppVersion.class.getSimpleName();
|
||||
|
||||
// Public key of the certificate that is used in NewPipe release versions
|
||||
private static final String RELEASE_CERT_PUBLIC_KEY_SHA1
|
||||
= "B0:2E:90:7C:1C:D6:FC:57:C3:35:F0:88:D0:8F:50:5F:94:E4:D2:15";
|
||||
private static final String NEWPIPE_API_URL = "https://newpipe.net/api/data.json";
|
||||
|
||||
/**
|
||||
* Method to get the APK's SHA1 key. See https://stackoverflow.com/questions/9293019/#22506133.
|
||||
*
|
||||
* @param application The application
|
||||
* @return String with the APK's SHA1 fingerprint in hexadecimal
|
||||
*/
|
||||
@NonNull
|
||||
private static String getCertificateSHA1Fingerprint(@NonNull final Application application) {
|
||||
final List<Signature> signatures;
|
||||
try {
|
||||
signatures = PackageInfoCompat.getSignatures(application.getPackageManager(),
|
||||
application.getPackageName());
|
||||
} catch (final PackageManager.NameNotFoundException e) {
|
||||
ErrorUtil.createNotification(application, new ErrorInfo(e,
|
||||
UserAction.CHECK_FOR_NEW_APP_VERSION, "Could not find package info"));
|
||||
return "";
|
||||
}
|
||||
if (signatures.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
final X509Certificate c;
|
||||
try {
|
||||
final byte[] cert = signatures.get(0).toByteArray();
|
||||
final InputStream input = new ByteArrayInputStream(cert);
|
||||
final CertificateFactory cf = CertificateFactory.getInstance("X509");
|
||||
c = (X509Certificate) cf.generateCertificate(input);
|
||||
} catch (final CertificateException e) {
|
||||
ErrorUtil.createNotification(application, new ErrorInfo(e,
|
||||
UserAction.CHECK_FOR_NEW_APP_VERSION, "Certificate error"));
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
final MessageDigest md = MessageDigest.getInstance("SHA1");
|
||||
final byte[] publicKey = md.digest(c.getEncoded());
|
||||
return byte2HexFormatted(publicKey);
|
||||
} catch (NoSuchAlgorithmException | CertificateEncodingException e) {
|
||||
ErrorUtil.createNotification(application, new ErrorInfo(e,
|
||||
UserAction.CHECK_FOR_NEW_APP_VERSION, "Could not retrieve SHA1 key"));
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static String byte2HexFormatted(final byte[] arr) {
|
||||
final StringBuilder str = new StringBuilder(arr.length * 2);
|
||||
|
||||
for (int i = 0; i < arr.length; i++) {
|
||||
String h = Integer.toHexString(arr[i]);
|
||||
final int l = h.length();
|
||||
if (l == 1) {
|
||||
h = "0" + h;
|
||||
}
|
||||
if (l > 2) {
|
||||
h = h.substring(l - 2, l);
|
||||
}
|
||||
str.append(h.toUpperCase());
|
||||
if (i < (arr.length - 1)) {
|
||||
str.append(':');
|
||||
}
|
||||
}
|
||||
return str.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to compare the current and latest available app version.
|
||||
* If a newer version is available, we show the update notification.
|
||||
*
|
||||
* @param application The application
|
||||
* @param versionName Name of new version
|
||||
* @param apkLocationUrl Url with the new apk
|
||||
* @param versionCode Code of new version
|
||||
*/
|
||||
private static void compareAppVersionAndShowNotification(@NonNull final Application application,
|
||||
final String versionName,
|
||||
final String apkLocationUrl,
|
||||
final int versionCode) {
|
||||
if (BuildConfig.VERSION_CODE >= versionCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A pending intent to open the apk location url in the browser.
|
||||
final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(apkLocationUrl));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
final PendingIntent pendingIntent
|
||||
= PendingIntent.getActivity(application, 0, intent, 0);
|
||||
|
||||
final String channelId = application
|
||||
.getString(R.string.app_update_notification_channel_id);
|
||||
final NotificationCompat.Builder notificationBuilder
|
||||
= new NotificationCompat.Builder(application, channelId)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_update)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setContentTitle(application
|
||||
.getString(R.string.app_update_notification_content_title))
|
||||
.setContentText(application
|
||||
.getString(R.string.app_update_notification_content_text)
|
||||
+ " " + versionName);
|
||||
|
||||
final NotificationManagerCompat notificationManager
|
||||
= NotificationManagerCompat.from(application);
|
||||
notificationManager.notify(2000, notificationBuilder.build());
|
||||
}
|
||||
|
||||
public static boolean isReleaseApk(@NonNull final App app) {
|
||||
return getCertificateSHA1Fingerprint(app).equals(RELEASE_CERT_PUBLIC_KEY_SHA1);
|
||||
}
|
||||
|
||||
private void checkNewVersion() throws IOException, ReCaptchaException {
|
||||
final App app = App.getApp();
|
||||
|
||||
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
|
||||
final NewVersionManager manager = new NewVersionManager();
|
||||
|
||||
// Check if the current apk is a github one or not.
|
||||
if (!isReleaseApk(app)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the last request has happened a certain time ago
|
||||
// to reduce the number of API requests.
|
||||
final long expiry = prefs.getLong(app.getString(R.string.update_expiry_key), 0);
|
||||
if (!manager.isExpired(expiry)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make a network request to get latest NewPipe data.
|
||||
final Response response = DownloaderImpl.getInstance().get(NEWPIPE_API_URL);
|
||||
handleResponse(response, manager, prefs, app);
|
||||
}
|
||||
|
||||
private void handleResponse(@NonNull final Response response,
|
||||
@NonNull final NewVersionManager manager,
|
||||
@NonNull final SharedPreferences prefs,
|
||||
@NonNull final App app) {
|
||||
try {
|
||||
// Store a timestamp which needs to be exceeded,
|
||||
// before a new request to the API is made.
|
||||
final long newExpiry = manager
|
||||
.coerceExpiry(response.getHeader("expires"));
|
||||
prefs.edit()
|
||||
.putLong(app.getString(R.string.update_expiry_key), newExpiry)
|
||||
.apply();
|
||||
} catch (final Exception e) {
|
||||
if (DEBUG) {
|
||||
Log.w(TAG, "Could not extract and save new expiry date", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the json from the response.
|
||||
try {
|
||||
|
||||
final JsonObject githubStableObject = JsonParser.object()
|
||||
.from(response.responseBody()).getObject("flavors")
|
||||
.getObject("github").getObject("stable");
|
||||
|
||||
final String versionName = githubStableObject
|
||||
.getString("version");
|
||||
final int versionCode = githubStableObject
|
||||
.getInt("version_code");
|
||||
final String apkLocationUrl = githubStableObject
|
||||
.getString("apk");
|
||||
|
||||
compareAppVersionAndShowNotification(app, versionName,
|
||||
apkLocationUrl, versionCode);
|
||||
} catch (final JsonParserException e) {
|
||||
// Most likely something is wrong in data received from NEWPIPE_API_URL.
|
||||
// Do not alarm user and fail silently.
|
||||
if (DEBUG) {
|
||||
Log.w(TAG, "Could not get NewPipe API: invalid json", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new service which
|
||||
* checks if all conditions for performing a version check are met,
|
||||
* fetches the API endpoint {@link #NEWPIPE_API_URL} containing info
|
||||
* about the latest NewPipe version
|
||||
* and displays a notification about ana available update.
|
||||
* <br>
|
||||
* Following conditions need to be met, before data is request from the server:
|
||||
* <ul>
|
||||
* <li> The app is signed with the correct signing key (by TeamNewPipe / schabi).
|
||||
* If the signing key differs from the one used upstream, the update cannot be installed.</li>
|
||||
* <li>The user enabled searching for and notifying about updates in the settings.</li>
|
||||
* <li>The app did not recently check for updates.
|
||||
* We do not want to make unnecessary connections and DOS our servers.</li>
|
||||
* </ul>
|
||||
* <b>Must not be executed</b> when the app is in background.
|
||||
*/
|
||||
public static void startNewVersionCheckService() {
|
||||
final Intent intent = new Intent(App.getApp().getApplicationContext(),
|
||||
CheckForNewAppVersion.class);
|
||||
App.getApp().startService(intent);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onHandleIntent(@Nullable final Intent intent) {
|
||||
try {
|
||||
checkNewVersion();
|
||||
} catch (final IOException e) {
|
||||
Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e);
|
||||
} catch (final ReCaptchaException e) {
|
||||
Log.e(TAG, "ReCaptchaException should never happen here.", e);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.schabi.newpipe;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -12,40 +11,27 @@ import org.schabi.newpipe.extractor.downloader.Downloader;
|
||||
import org.schabi.newpipe.extractor.downloader.Request;
|
||||
import org.schabi.newpipe.extractor.downloader.Response;
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
|
||||
import org.schabi.newpipe.util.CookieUtils;
|
||||
import org.schabi.newpipe.util.InfoCache;
|
||||
import org.schabi.newpipe.util.TLSSocketFactoryCompat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
import okhttp3.CipherSuite;
|
||||
import okhttp3.ConnectionSpec;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||
|
||||
public final class DownloaderImpl extends Downloader {
|
||||
public static final String USER_AGENT
|
||||
= "Mozilla/5.0 (Windows NT 10.0; rv:78.0) Gecko/20100101 Firefox/78.0";
|
||||
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY
|
||||
= "youtube_restricted_mode_key";
|
||||
public static final String USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0";
|
||||
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY =
|
||||
"youtube_restricted_mode_key";
|
||||
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000";
|
||||
public static final String YOUTUBE_DOMAIN = "youtube.com";
|
||||
|
||||
@@ -54,9 +40,6 @@ public final class DownloaderImpl extends Downloader {
|
||||
private final OkHttpClient client;
|
||||
|
||||
private DownloaderImpl(final OkHttpClient.Builder builder) {
|
||||
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) {
|
||||
enableModernTLS(builder);
|
||||
}
|
||||
this.client = builder
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
// .cache(new Cache(new File(context.getExternalCacheDir(), "okhttp"),
|
||||
@@ -81,69 +64,16 @@ public final class DownloaderImpl extends Downloader {
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable TLS 1.2 and 1.1 on Android Kitkat. This function is mostly taken
|
||||
* from the documentation of OkHttpClient.Builder.sslSocketFactory(_,_).
|
||||
* <p>
|
||||
* If there is an error, the function will safely fall back to doing nothing
|
||||
* and printing the error to the console.
|
||||
* </p>
|
||||
*
|
||||
* @param builder The HTTPClient Builder on which TLS is enabled on (will be modified in-place)
|
||||
*/
|
||||
private static void enableModernTLS(final OkHttpClient.Builder builder) {
|
||||
try {
|
||||
// get the default TrustManager
|
||||
final TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(
|
||||
TrustManagerFactory.getDefaultAlgorithm());
|
||||
trustManagerFactory.init((KeyStore) null);
|
||||
final TrustManager[] trustManagers = trustManagerFactory.getTrustManagers();
|
||||
if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) {
|
||||
throw new IllegalStateException("Unexpected default trust managers:"
|
||||
+ Arrays.toString(trustManagers));
|
||||
}
|
||||
final X509TrustManager trustManager = (X509TrustManager) trustManagers[0];
|
||||
|
||||
// insert our own TLSSocketFactory
|
||||
final SSLSocketFactory sslSocketFactory = TLSSocketFactoryCompat.getInstance();
|
||||
|
||||
builder.sslSocketFactory(sslSocketFactory, trustManager);
|
||||
|
||||
// This will try to enable all modern CipherSuites(+2 more)
|
||||
// that are supported on the device.
|
||||
// Necessary because some servers (e.g. Framatube.org)
|
||||
// don't support the old cipher suites.
|
||||
// https://github.com/square/okhttp/issues/4053#issuecomment-402579554
|
||||
final List<CipherSuite> cipherSuites =
|
||||
new ArrayList<>(ConnectionSpec.MODERN_TLS.cipherSuites());
|
||||
cipherSuites.add(CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA);
|
||||
cipherSuites.add(CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA);
|
||||
final ConnectionSpec legacyTLS = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
|
||||
.cipherSuites(cipherSuites.toArray(new CipherSuite[0]))
|
||||
.build();
|
||||
|
||||
builder.connectionSpecs(Arrays.asList(legacyTLS, ConnectionSpec.CLEARTEXT));
|
||||
} catch (final KeyManagementException | NoSuchAlgorithmException | KeyStoreException e) {
|
||||
if (DEBUG) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String getCookies(final String url) {
|
||||
final List<String> resultCookies = new ArrayList<>();
|
||||
if (url.contains(YOUTUBE_DOMAIN)) {
|
||||
final String youtubeCookie = getCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY);
|
||||
if (youtubeCookie != null) {
|
||||
resultCookies.add(youtubeCookie);
|
||||
}
|
||||
}
|
||||
final String youtubeCookie = url.contains(YOUTUBE_DOMAIN)
|
||||
? getCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY) : null;
|
||||
|
||||
// Recaptcha cookie is always added TODO: not sure if this is necessary
|
||||
final String recaptchaCookie = getCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY);
|
||||
if (recaptchaCookie != null) {
|
||||
resultCookies.add(recaptchaCookie);
|
||||
}
|
||||
return CookieUtils.concatCookies(resultCookies);
|
||||
return Stream.of(youtubeCookie, getCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY))
|
||||
.filter(Objects::nonNull)
|
||||
.flatMap(cookies -> Arrays.stream(cookies.split("; *")))
|
||||
.distinct()
|
||||
.collect(Collectors.joining("; "));
|
||||
}
|
||||
|
||||
public String getCookie(final String key) {
|
||||
@@ -203,7 +133,7 @@ public final class DownloaderImpl extends Downloader {
|
||||
|
||||
RequestBody requestBody = null;
|
||||
if (dataToSend != null) {
|
||||
requestBody = RequestBody.create(null, dataToSend);
|
||||
requestBody = RequestBody.create(dataToSend);
|
||||
}
|
||||
|
||||
final okhttp3.Request.Builder requestBuilder = new okhttp3.Request.Builder()
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.schabi.newpipe;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
@@ -44,11 +43,7 @@ public class ExitActivity extends Activity {
|
||||
protected void onCreate(final Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
finishAndRemoveTask();
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
finishAndRemoveTask();
|
||||
|
||||
NavigationHelper.restartApp(this);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
|
||||
package org.schabi.newpipe;
|
||||
|
||||
import static org.schabi.newpipe.CheckForNewAppVersion.startNewVersionCheckService;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
@@ -29,7 +28,6 @@ import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
@@ -65,6 +63,7 @@ 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.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance;
|
||||
@@ -72,6 +71,7 @@ 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.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;
|
||||
@@ -86,7 +86,6 @@ import org.schabi.newpipe.util.PermissionHelper;
|
||||
import org.schabi.newpipe.util.SerializedCache;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
import org.schabi.newpipe.util.StateSaver;
|
||||
import org.schabi.newpipe.util.TLSSocketFactoryCompat;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.views.FocusOverlayView;
|
||||
|
||||
@@ -131,11 +130,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
+ "savedInstanceState = [" + savedInstanceState + "]");
|
||||
}
|
||||
|
||||
// enable TLS1.1/1.2 for kitkat devices, to fix download and play for media.ccc.de sources
|
||||
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) {
|
||||
TLSSocketFactoryCompat.setAsDefault();
|
||||
}
|
||||
|
||||
ThemeHelper.setDayNightMode(this);
|
||||
ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this));
|
||||
|
||||
@@ -159,11 +153,17 @@ public class MainActivity extends AppCompatActivity {
|
||||
} catch (final Exception e) {
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Setting up drawer", e);
|
||||
}
|
||||
|
||||
if (DeviceUtils.isTv(this)) {
|
||||
FocusOverlayView.setupFocusObserver(this);
|
||||
}
|
||||
openMiniPlayerUponPlayerStarted();
|
||||
|
||||
if (PermissionHelper.checkPostNotificationsPermission(this,
|
||||
PermissionHelper.POST_NOTIFICATIONS_REQUEST_CODE)) {
|
||||
// Schedule worker for checking for new streams and creating corresponding notifications
|
||||
// if this is enabled by the user.
|
||||
NotificationWorker.initialize(this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -174,10 +174,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
|
||||
|
||||
if (prefs.getBoolean(app.getString(R.string.update_app_key), true)) {
|
||||
// Start the service which is checking all conditions
|
||||
// Start the worker which is checking all conditions
|
||||
// and eventually searching for a new version.
|
||||
// The service searching for a new NewPipe version must not be started in background.
|
||||
startNewVersionCheckService();
|
||||
NewVersionWorker.enqueueNewVersionCheckingWork(app, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +226,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
drawerLayoutBinding.navigation.getMenu()
|
||||
.add(R.id.menu_tabs_group, kioskId, 0, KioskTranslator
|
||||
.getTranslatedKioskName(ks, this))
|
||||
.setIcon(KioskTranslator.getKioskIcon(ks, this));
|
||||
.setIcon(KioskTranslator.getKioskIcon(ks));
|
||||
kioskId++;
|
||||
}
|
||||
|
||||
@@ -237,7 +236,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
.setIcon(R.drawable.ic_tv);
|
||||
drawerLayoutBinding.navigation.getMenu()
|
||||
.add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title)
|
||||
.setIcon(R.drawable.ic_rss_feed);
|
||||
.setIcon(R.drawable.ic_subscriptions);
|
||||
drawerLayoutBinding.navigation.getMenu()
|
||||
.add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks)
|
||||
.setIcon(R.drawable.ic_bookmark);
|
||||
@@ -260,8 +259,15 @@ public class MainActivity extends AppCompatActivity {
|
||||
private boolean drawerItemSelected(final MenuItem item) {
|
||||
switch (item.getGroupId()) {
|
||||
case R.id.menu_services_group:
|
||||
changeService(item);
|
||||
break;
|
||||
if (item.getItemId() == ServiceList.PeerTube.getServiceId()
|
||||
&& DeviceUtils.isTv(getApplicationContext())
|
||||
&& !item.isActionViewExpanded()) {
|
||||
((Spinner) item.getActionView()).performClick();
|
||||
return true;
|
||||
} else {
|
||||
changeService(item);
|
||||
break;
|
||||
}
|
||||
case R.id.menu_tabs_group:
|
||||
try {
|
||||
tabSelected(item);
|
||||
@@ -379,16 +385,15 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private void showServices() {
|
||||
for (final StreamingService s : NewPipe.getServices()) {
|
||||
final String title = s.getServiceInfo().getName()
|
||||
+ (ServiceHelper.isBeta(s) ? " (beta)" : "");
|
||||
final String title = s.getServiceInfo().getName();
|
||||
|
||||
final MenuItem menuItem = drawerLayoutBinding.navigation.getMenu()
|
||||
.add(R.id.menu_services_group, s.getServiceId(), ORDER, title)
|
||||
.setIcon(ServiceHelper.getIcon(s.getServiceId()));
|
||||
|
||||
// peertube specifics
|
||||
if (s.getServiceId() == 3) {
|
||||
enhancePeertubeMenu(s, menuItem);
|
||||
// PeerTube specifics
|
||||
if (s == ServiceList.PeerTube) {
|
||||
enhancePeertubeMenu(menuItem);
|
||||
}
|
||||
}
|
||||
drawerLayoutBinding.navigation.getMenu()
|
||||
@@ -396,9 +401,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
.setChecked(true);
|
||||
}
|
||||
|
||||
private void enhancePeertubeMenu(final StreamingService s, final MenuItem menuItem) {
|
||||
private void enhancePeertubeMenu(final MenuItem menuItem) {
|
||||
final PeertubeInstance currentInstance = PeertubeHelper.getCurrentInstance();
|
||||
menuItem.setTitle(currentInstance.getName() + (ServiceHelper.isBeta(s) ? " (beta)" : ""));
|
||||
menuItem.setTitle(currentInstance.getName());
|
||||
final Spinner spinner = InstanceSpinnerLayoutBinding.inflate(LayoutInflater.from(this))
|
||||
.getRoot();
|
||||
final List<PeertubeInstance> instances = PeertubeHelper.getInstanceList(this);
|
||||
@@ -478,8 +483,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Setting up service toggle", e);
|
||||
}
|
||||
|
||||
final SharedPreferences sharedPreferences
|
||||
= PreferenceManager.getDefaultSharedPreferences(this);
|
||||
final SharedPreferences sharedPreferences =
|
||||
PreferenceManager.getDefaultSharedPreferences(this);
|
||||
if (sharedPreferences.getBoolean(Constants.KEY_THEME_CHANGE, false)) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Theme has changed, recreating activity...");
|
||||
@@ -605,6 +610,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
((VideoDetailFragment) fragment).openDownloadDialog();
|
||||
}
|
||||
break;
|
||||
case PermissionHelper.POST_NOTIFICATIONS_REQUEST_CODE:
|
||||
NotificationWorker.initialize(this);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,8 +659,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
super.onCreateOptionsMenu(menu);
|
||||
|
||||
final Fragment fragment
|
||||
= getSupportFragmentManager().findFragmentById(R.id.fragment_holder);
|
||||
final Fragment fragment =
|
||||
getSupportFragmentManager().findFragmentById(R.id.fragment_holder);
|
||||
if (!(fragment instanceof SearchFragment)) {
|
||||
toolbarLayoutBinding.toolbarSearchContainer.getRoot().setVisibility(View.GONE);
|
||||
}
|
||||
@@ -719,7 +727,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (toggle != null) {
|
||||
toggle.syncState();
|
||||
toolbarLayoutBinding.toolbar.setNavigationOnClickListener(v -> mainBinding.getRoot()
|
||||
.openDrawer(GravityCompat.START));
|
||||
.open());
|
||||
mainBinding.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
package org.schabi.newpipe;
|
||||
|
||||
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
|
||||
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 android.content.Context;
|
||||
import android.database.Cursor;
|
||||
|
||||
@@ -8,11 +16,6 @@ import androidx.room.Room;
|
||||
|
||||
import org.schabi.newpipe.database.AppDatabase;
|
||||
|
||||
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
|
||||
|
||||
public final class NewPipeDatabase {
|
||||
private static volatile AppDatabase databaseInstance;
|
||||
|
||||
@@ -23,7 +26,8 @@ public final class NewPipeDatabase {
|
||||
private static AppDatabase getDatabase(final Context context) {
|
||||
return Room
|
||||
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
|
||||
MIGRATION_5_6, MIGRATION_6_7)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package org.schabi.newpipe
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class NewVersionManager {
|
||||
|
||||
fun isExpired(expiry: Long): Boolean {
|
||||
return Instant.ofEpochSecond(expiry).isBefore(Instant.now())
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce expiry date time in between 6 hours and 72 hours from now
|
||||
*
|
||||
* @return Epoch second of expiry date time
|
||||
*/
|
||||
fun coerceExpiry(expiryString: String?): Long {
|
||||
val now = ZonedDateTime.now()
|
||||
return expiryString?.let {
|
||||
|
||||
var expiry = ZonedDateTime.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(expiryString))
|
||||
expiry = maxOf(expiry, now.plusHours(6))
|
||||
expiry = minOf(expiry, now.plusHours(72))
|
||||
expiry.toEpochSecond()
|
||||
} ?: now.plusHours(6).toEpochSecond()
|
||||
}
|
||||
}
|
||||
180
app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt
Normal file
180
app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt
Normal file
@@ -0,0 +1,180 @@
|
||||
package org.schabi.newpipe
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
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 java.io.IOException
|
||||
|
||||
class NewVersionWorker(
|
||||
context: Context,
|
||||
workerParams: WorkerParameters
|
||||
) : Worker(context, workerParams) {
|
||||
|
||||
/**
|
||||
* Method to compare the current and latest available app version.
|
||||
* If a newer version is available, we show the update notification.
|
||||
*
|
||||
* @param versionName Name of new version
|
||||
* @param apkLocationUrl Url with the new apk
|
||||
* @param versionCode Code of new version
|
||||
*/
|
||||
private fun compareAppVersionAndShowNotification(
|
||||
versionName: String,
|
||||
apkLocationUrl: String?,
|
||||
versionCode: Int
|
||||
) {
|
||||
if (BuildConfig.VERSION_CODE >= versionCode) {
|
||||
if (inputData.getBoolean(IS_MANUAL, false)) {
|
||||
// Show toast stating that the app is up-to-date if the update check was manual.
|
||||
ContextCompat.getMainExecutor(applicationContext).execute {
|
||||
Toast.makeText(
|
||||
applicationContext, R.string.app_update_unavailable_toast,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// A pending intent to open the apk location url in the browser.
|
||||
val intent = Intent(Intent.ACTION_VIEW, apkLocationUrl?.toUri())
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val pendingIntent = PendingIntentCompat.getActivity(
|
||||
applicationContext, 0, intent, 0, false
|
||||
)
|
||||
val channelId = applicationContext.getString(R.string.app_update_notification_channel_id)
|
||||
val notificationBuilder = NotificationCompat.Builder(applicationContext, channelId)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_update)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setContentTitle(
|
||||
applicationContext.getString(R.string.app_update_available_notification_title)
|
||||
)
|
||||
.setContentText(
|
||||
applicationContext.getString(
|
||||
R.string.app_update_available_notification_text, versionName
|
||||
)
|
||||
)
|
||||
|
||||
val notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
notificationManager.notify(2000, notificationBuilder.build())
|
||||
}
|
||||
|
||||
@Throws(IOException::class, ReCaptchaException::class)
|
||||
private fun checkNewVersion() {
|
||||
// Check if the current apk is a github one or not.
|
||||
if (!isReleaseApk()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!inputData.getBoolean(IS_MANUAL, false)) {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||
// 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)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Make a network request to get latest NewPipe data.
|
||||
val response = DownloaderImpl.getInstance().get(NEWPIPE_API_URL)
|
||||
handleResponse(response)
|
||||
}
|
||||
|
||||
private fun handleResponse(response: Response) {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||
try {
|
||||
// Store a timestamp which needs to be exceeded,
|
||||
// before a new request to the API is made.
|
||||
val newExpiry = coerceUpdateCheckExpiry(response.getHeader("expires"))
|
||||
prefs.edit {
|
||||
putLong(applicationContext.getString(R.string.update_expiry_key), newExpiry)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (DEBUG) {
|
||||
Log.w(TAG, "Could not extract and save new expiry date", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the json from the response.
|
||||
try {
|
||||
val githubStableObject = JsonParser.`object`()
|
||||
.from(response.responseBody()).getObject("flavors")
|
||||
.getObject("github").getObject("stable")
|
||||
|
||||
val versionName = githubStableObject.getString("version")
|
||||
val versionCode = githubStableObject.getInt("version_code")
|
||||
val apkLocationUrl = githubStableObject.getString("apk")
|
||||
compareAppVersionAndShowNotification(versionName, apkLocationUrl, versionCode)
|
||||
} catch (e: JsonParserException) {
|
||||
// Most likely something is wrong in data received from NEWPIPE_API_URL.
|
||||
// Do not alarm user and fail silently.
|
||||
if (DEBUG) {
|
||||
Log.w(TAG, "Could not get NewPipe API: invalid json", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun doWork(): Result {
|
||||
return try {
|
||||
checkNewVersion()
|
||||
Result.success()
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e)
|
||||
Result.failure()
|
||||
} catch (e: ReCaptchaException) {
|
||||
Log.e(TAG, "ReCaptchaException should never happen here.", e)
|
||||
Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEBUG = MainActivity.DEBUG
|
||||
private val TAG = NewVersionWorker::class.java.simpleName
|
||||
private const val NEWPIPE_API_URL = "https://newpipe.net/api/data.json"
|
||||
private const val IS_MANUAL = "isManual"
|
||||
|
||||
/**
|
||||
* Start a new worker which checks if all conditions for performing a version check are met,
|
||||
* fetches the API endpoint [.NEWPIPE_API_URL] containing info about the latest NewPipe
|
||||
* version and displays a notification about an available update if one is available.
|
||||
* <br></br>
|
||||
* Following conditions need to be met, before data is requested from the server:
|
||||
*
|
||||
* * The app is signed with the correct signing key (by TeamNewPipe / schabi).
|
||||
* If the signing key differs from the one used upstream, the update cannot be installed.
|
||||
* * The user enabled searching for and notifying about updates in the settings.
|
||||
* * The app did not recently check for updates.
|
||||
* We do not want to make unnecessary connections and DOS our servers.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun enqueueNewVersionCheckingWork(context: Context, isManual: Boolean) {
|
||||
val workRequest = OneTimeWorkRequestBuilder<NewVersionWorker>()
|
||||
.setInputData(workDataOf(IS_MANUAL to isManual))
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueue(workRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package org.schabi.newpipe;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
|
||||
/*
|
||||
@@ -40,10 +39,6 @@ public class PanicResponderActivity extends Activity {
|
||||
ExitActivity.exitAndRemoveFromRecentApps(this);
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
finishAndRemoveTask();
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
finishAndRemoveTask();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.schabi.newpipe;
|
||||
|
||||
import static org.schabi.newpipe.util.SparseItemUtil.fetchStreamInfoAndSaveToDatabase;
|
||||
import static org.schabi.newpipe.util.external_communication.ShareUtils.shareText;
|
||||
|
||||
import android.content.Context;
|
||||
@@ -10,13 +11,14 @@ import android.widget.PopupMenu;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.download.DownloadDialog;
|
||||
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.SaveUploaderUrlHelper;
|
||||
import org.schabi.newpipe.util.SparseItemUtil;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public final class QueueItemMenuUtil {
|
||||
private QueueItemMenuUtil() {
|
||||
@@ -53,7 +55,7 @@ public final class QueueItemMenuUtil {
|
||||
case R.id.menu_item_append_playlist:
|
||||
PlaylistDialog.createCorrespondingDialog(
|
||||
context,
|
||||
Collections.singletonList(new StreamEntity(item)),
|
||||
List.of(new StreamEntity(item)),
|
||||
dialog -> dialog.show(
|
||||
fragmentManager,
|
||||
"QueueItemMenuUtil@append_playlist"
|
||||
@@ -62,7 +64,8 @@ public final class QueueItemMenuUtil {
|
||||
|
||||
return true;
|
||||
case R.id.menu_item_channel_details:
|
||||
SaveUploaderUrlHelper.saveUploaderUrlIfNeeded(context, item,
|
||||
SparseItemUtil.fetchUploaderUrlIfSparse(context, item.getServiceId(),
|
||||
item.getUrl(), item.getUploaderUrl(),
|
||||
// An intent must be used here.
|
||||
// Opening with FragmentManager transactions is not working,
|
||||
// as PlayQueueActivity doesn't use fragments.
|
||||
@@ -74,6 +77,14 @@ public final class QueueItemMenuUtil {
|
||||
shareText(context, item.getTitle(), item.getUrl(),
|
||||
item.getThumbnailUrl());
|
||||
return true;
|
||||
case R.id.menu_item_download:
|
||||
fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(),
|
||||
info -> {
|
||||
final DownloadDialog downloadDialog = new DownloadDialog(context,
|
||||
info);
|
||||
downloadDialog.show(fragmentManager, "downloadDialog");
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
@@ -10,12 +10,14 @@ import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.ContextThemeWrapper;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.Button;
|
||||
import android.widget.RadioButton;
|
||||
import android.widget.RadioGroup;
|
||||
@@ -24,13 +26,19 @@ import android.widget.Toast;
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.ServiceCompat;
|
||||
import androidx.core.widget.TextViewCompat;
|
||||
import androidx.core.math.MathUtils;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.lifecycle.DefaultLifecycleObserver;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
@@ -58,10 +66,9 @@ import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException;
|
||||
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException;
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.extractor.stream.VideoStream;
|
||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
||||
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||
import org.schabi.newpipe.player.MainPlayer;
|
||||
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;
|
||||
@@ -71,7 +78,7 @@ import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
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.ThemeHelper;
|
||||
@@ -80,10 +87,13 @@ import org.schabi.newpipe.util.urlfinder.UrlFinder;
|
||||
import org.schabi.newpipe.views.FocusOverlayView;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.lang.ref.Reference;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import icepick.Icepick;
|
||||
import icepick.State;
|
||||
@@ -92,7 +102,6 @@ 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.Consumer;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
/**
|
||||
@@ -112,12 +121,57 @@ public class RouterActivity extends AppCompatActivity {
|
||||
private boolean selectionIsDownload = false;
|
||||
private boolean selectionIsAddToPlaylist = false;
|
||||
private AlertDialog alertDialogChoice = null;
|
||||
private FragmentManager.FragmentLifecycleCallbacks dismissListener = null;
|
||||
|
||||
@Override
|
||||
protected void onCreate(final Bundle savedInstanceState) {
|
||||
ThemeHelper.setDayNightMode(this);
|
||||
setTheme(ThemeHelper.isLightThemeSelected(this)
|
||||
? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark);
|
||||
Localization.assureCorrectAppLanguage(this);
|
||||
|
||||
// Pass-through touch events to background activities
|
||||
// so that our transparent window won't lock UI in the mean time
|
||||
// network request is underway before showing PlaylistDialog or DownloadDialog
|
||||
// (ref: https://stackoverflow.com/a/10606141)
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
|
||||
| WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
|
||||
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE);
|
||||
|
||||
// Android never fails to impress us with a list of new restrictions per API.
|
||||
// Starting with S (Android 12) one of the prerequisite conditions has to be met
|
||||
// before the FLAG_NOT_TOUCHABLE flag is allowed to kick in:
|
||||
// @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE
|
||||
// For our present purpose it seems we can just set LayoutParams.alpha to 0
|
||||
// on the strength of "4. Fully transparent windows" without affecting the scrim of dialogs
|
||||
final WindowManager.LayoutParams params = getWindow().getAttributes();
|
||||
params.alpha = 0f;
|
||||
getWindow().setAttributes(params);
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
Icepick.restoreInstanceState(this, savedInstanceState);
|
||||
|
||||
// FragmentManager will take care to recreate (Playlist|Download)Dialog when screen rotates
|
||||
// We used to .setOnDismissListener(dialog -> finish()); when creating these DialogFragments
|
||||
// but those callbacks won't survive a config change
|
||||
// Try an alternate approach to hook into FragmentManager instead, to that effect
|
||||
// (ref: https://stackoverflow.com/a/44028453)
|
||||
final FragmentManager fm = getSupportFragmentManager();
|
||||
if (dismissListener == null) {
|
||||
dismissListener = new FragmentManager.FragmentLifecycleCallbacks() {
|
||||
@Override
|
||||
public void onFragmentDestroyed(@NonNull final FragmentManager fm,
|
||||
@NonNull final Fragment f) {
|
||||
super.onFragmentDestroyed(fm, f);
|
||||
if (f instanceof DialogFragment && fm.getFragments().isEmpty()) {
|
||||
// No more DialogFragments, we're done
|
||||
finish();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
fm.registerFragmentLifecycleCallbacks(dismissListener, false);
|
||||
|
||||
if (TextUtils.isEmpty(currentUrl)) {
|
||||
currentUrl = getUrl(getIntent());
|
||||
|
||||
@@ -126,9 +180,6 @@ public class RouterActivity extends AppCompatActivity {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
setTheme(ThemeHelper.isLightThemeSelected(this)
|
||||
? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -150,16 +201,34 @@ public class RouterActivity extends AppCompatActivity {
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
|
||||
handleUrl(currentUrl);
|
||||
// Don't overlap the DialogFragment after rotating the screen
|
||||
// If there's no DialogFragment, we're either starting afresh
|
||||
// or we didn't make it to PlaylistDialog or DownloadDialog before the orientation change
|
||||
if (getSupportFragmentManager().getFragments().isEmpty()) {
|
||||
// Start over from scratch
|
||||
handleUrl(currentUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
|
||||
if (dismissListener != null) {
|
||||
getSupportFragmentManager().unregisterFragmentLifecycleCallbacks(dismissListener);
|
||||
}
|
||||
|
||||
disposables.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void finish() {
|
||||
// allow the activity to recreate in case orientation changes
|
||||
if (!isChangingConfigurations()) {
|
||||
super.finish();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleUrl(final String url) {
|
||||
disposables.add(Observable
|
||||
.fromCallable(() -> {
|
||||
@@ -239,7 +308,7 @@ public class RouterActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private void showUnsupportedUrlDialog(final String url) {
|
||||
protected void showUnsupportedUrlDialog(final String url) {
|
||||
final Context context = getThemeWrapperContext();
|
||||
new AlertDialog.Builder(context)
|
||||
.setTitle(R.string.unsupported_url)
|
||||
@@ -257,80 +326,122 @@ public class RouterActivity extends AppCompatActivity {
|
||||
protected void onSuccess() {
|
||||
final SharedPreferences preferences = PreferenceManager
|
||||
.getDefaultSharedPreferences(this);
|
||||
final String selectedChoiceKey = preferences
|
||||
.getString(getString(R.string.preferred_open_action_key),
|
||||
getString(R.string.preferred_open_action_default));
|
||||
|
||||
final String showInfoKey = getString(R.string.show_info_key);
|
||||
final String videoPlayerKey = getString(R.string.video_player_key);
|
||||
final String backgroundPlayerKey = getString(R.string.background_player_key);
|
||||
final String popupPlayerKey = getString(R.string.popup_player_key);
|
||||
final String downloadKey = getString(R.string.download_key);
|
||||
final String alwaysAskKey = getString(R.string.always_ask_open_action_key);
|
||||
final ChoiceAvailabilityChecker choiceChecker = new ChoiceAvailabilityChecker(
|
||||
getChoicesForService(currentService, currentLinkType),
|
||||
preferences.getString(getString(R.string.preferred_open_action_key),
|
||||
getString(R.string.preferred_open_action_default)));
|
||||
|
||||
if (selectedChoiceKey.equals(alwaysAskKey)) {
|
||||
final List<AdapterChoiceItem> choices
|
||||
= getChoicesForService(currentService, currentLinkType);
|
||||
// Check for non-player related choices
|
||||
if (choiceChecker.isAvailableAndSelected(
|
||||
R.string.show_info_key,
|
||||
R.string.download_key,
|
||||
R.string.add_to_playlist_key)) {
|
||||
handleChoice(choiceChecker.getSelectedChoiceKey());
|
||||
return;
|
||||
}
|
||||
// Check if the choice is player related
|
||||
if (choiceChecker.isAvailableAndSelected(
|
||||
R.string.video_player_key,
|
||||
R.string.background_player_key,
|
||||
R.string.popup_player_key)) {
|
||||
|
||||
final String selectedChoice = choiceChecker.getSelectedChoiceKey();
|
||||
|
||||
switch (choices.size()) {
|
||||
case 1:
|
||||
handleChoice(choices.get(0).key);
|
||||
break;
|
||||
case 0:
|
||||
handleChoice(showInfoKey);
|
||||
break;
|
||||
default:
|
||||
showDialog(choices);
|
||||
break;
|
||||
}
|
||||
} else if (selectedChoiceKey.equals(showInfoKey)) {
|
||||
handleChoice(showInfoKey);
|
||||
} else if (selectedChoiceKey.equals(downloadKey)) {
|
||||
handleChoice(downloadKey);
|
||||
} else {
|
||||
final boolean isExtVideoEnabled = preferences.getBoolean(
|
||||
getString(R.string.use_external_video_player_key), false);
|
||||
final boolean isExtAudioEnabled = preferences.getBoolean(
|
||||
getString(R.string.use_external_audio_player_key), false);
|
||||
final boolean isVideoPlayerSelected = selectedChoiceKey.equals(videoPlayerKey)
|
||||
|| selectedChoiceKey.equals(popupPlayerKey);
|
||||
final boolean isAudioPlayerSelected = selectedChoiceKey.equals(backgroundPlayerKey);
|
||||
final boolean isVideoPlayerSelected =
|
||||
selectedChoice.equals(getString(R.string.video_player_key))
|
||||
|| selectedChoice.equals(getString(R.string.popup_player_key));
|
||||
final boolean isAudioPlayerSelected =
|
||||
selectedChoice.equals(getString(R.string.background_player_key));
|
||||
|
||||
if (currentLinkType != LinkType.STREAM) {
|
||||
if (isExtAudioEnabled && isAudioPlayerSelected
|
||||
|| isExtVideoEnabled && isVideoPlayerSelected) {
|
||||
Toast.makeText(this, R.string.external_player_unsupported_link_type,
|
||||
Toast.LENGTH_LONG).show();
|
||||
handleChoice(showInfoKey);
|
||||
return;
|
||||
}
|
||||
if (currentLinkType != LinkType.STREAM
|
||||
&& ((isExtAudioEnabled && isAudioPlayerSelected)
|
||||
|| (isExtVideoEnabled && isVideoPlayerSelected))
|
||||
) {
|
||||
Toast.makeText(this, R.string.external_player_unsupported_link_type,
|
||||
Toast.LENGTH_LONG).show();
|
||||
handleChoice(getString(R.string.show_info_key));
|
||||
return;
|
||||
}
|
||||
|
||||
final List<StreamingService.ServiceInfo.MediaCapability> capabilities
|
||||
= currentService.getServiceInfo().getMediaCapabilities();
|
||||
final List<StreamingService.ServiceInfo.MediaCapability> capabilities =
|
||||
currentService.getServiceInfo().getMediaCapabilities();
|
||||
|
||||
boolean serviceSupportsChoice = false;
|
||||
if (isVideoPlayerSelected) {
|
||||
serviceSupportsChoice = capabilities.contains(VIDEO);
|
||||
} else if (selectedChoiceKey.equals(backgroundPlayerKey)) {
|
||||
serviceSupportsChoice = capabilities.contains(AUDIO);
|
||||
}
|
||||
|
||||
if (serviceSupportsChoice) {
|
||||
handleChoice(selectedChoiceKey);
|
||||
// Check if the service supports the choice
|
||||
if ((isVideoPlayerSelected && capabilities.contains(VIDEO))
|
||||
|| (isAudioPlayerSelected && capabilities.contains(AUDIO))) {
|
||||
handleChoice(selectedChoice);
|
||||
} else {
|
||||
handleChoice(showInfoKey);
|
||||
handleChoice(getString(R.string.show_info_key));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Default / Ask always
|
||||
final List<AdapterChoiceItem> availableChoices = choiceChecker.getAvailableChoices();
|
||||
switch (availableChoices.size()) {
|
||||
case 1:
|
||||
handleChoice(availableChoices.get(0).key);
|
||||
break;
|
||||
case 0:
|
||||
handleChoice(getString(R.string.show_info_key));
|
||||
break;
|
||||
default:
|
||||
showDialog(availableChoices);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a helper class for checking if the choices are available and/or selected.
|
||||
*/
|
||||
class ChoiceAvailabilityChecker {
|
||||
private final List<AdapterChoiceItem> availableChoices;
|
||||
private final String selectedChoiceKey;
|
||||
|
||||
ChoiceAvailabilityChecker(
|
||||
@NonNull final List<AdapterChoiceItem> availableChoices,
|
||||
@NonNull final String selectedChoiceKey) {
|
||||
this.availableChoices = availableChoices;
|
||||
this.selectedChoiceKey = selectedChoiceKey;
|
||||
}
|
||||
|
||||
public List<AdapterChoiceItem> getAvailableChoices() {
|
||||
return availableChoices;
|
||||
}
|
||||
|
||||
public String getSelectedChoiceKey() {
|
||||
return selectedChoiceKey;
|
||||
}
|
||||
|
||||
public boolean isAvailableAndSelected(@StringRes final int... wantedKeys) {
|
||||
return Arrays.stream(wantedKeys).anyMatch(this::isAvailableAndSelected);
|
||||
}
|
||||
|
||||
public boolean isAvailableAndSelected(@StringRes final int wantedKey) {
|
||||
final String wanted = getString(wantedKey);
|
||||
// Check if the wanted option is selected
|
||||
if (!selectedChoiceKey.equals(wanted)) {
|
||||
return false;
|
||||
}
|
||||
// Check if it's available
|
||||
return availableChoices.stream().anyMatch(item -> wanted.equals(item.key));
|
||||
}
|
||||
}
|
||||
|
||||
private void showDialog(final List<AdapterChoiceItem> choices) {
|
||||
final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this);
|
||||
final Context themeWrapperContext = getThemeWrapperContext();
|
||||
|
||||
final LayoutInflater inflater = LayoutInflater.from(themeWrapperContext);
|
||||
final RadioGroup radioGroup = SingleChoiceDialogViewBinding.inflate(getLayoutInflater())
|
||||
.list;
|
||||
final Context themeWrapperContext = getThemeWrapperContext();
|
||||
final LayoutInflater layoutInflater = LayoutInflater.from(themeWrapperContext);
|
||||
|
||||
final SingleChoiceDialogViewBinding binding =
|
||||
SingleChoiceDialogViewBinding.inflate(layoutInflater);
|
||||
final RadioGroup radioGroup = binding.list;
|
||||
|
||||
final DialogInterface.OnClickListener dialogButtonsClickListener = (dialog, which) -> {
|
||||
final int indexOfChild = radioGroup.indexOfChild(
|
||||
@@ -349,21 +460,19 @@ public class RouterActivity extends AppCompatActivity {
|
||||
|
||||
alertDialogChoice = new AlertDialog.Builder(themeWrapperContext)
|
||||
.setTitle(R.string.preferred_open_action_share_menu_title)
|
||||
.setView(radioGroup)
|
||||
.setView(binding.getRoot())
|
||||
.setCancelable(true)
|
||||
.setNegativeButton(R.string.just_once, dialogButtonsClickListener)
|
||||
.setPositiveButton(R.string.always, dialogButtonsClickListener)
|
||||
.setOnDismissListener((dialog) -> {
|
||||
.setOnDismissListener(dialog -> {
|
||||
if (!selectionIsDownload && !selectionIsAddToPlaylist) {
|
||||
finish();
|
||||
}
|
||||
})
|
||||
.create();
|
||||
|
||||
//noinspection CodeBlock2Expr
|
||||
alertDialogChoice.setOnShowListener(dialog -> {
|
||||
setDialogButtonsState(alertDialogChoice, radioGroup.getCheckedRadioButtonId() != -1);
|
||||
});
|
||||
alertDialogChoice.setOnShowListener(dialog -> setDialogButtonsState(
|
||||
alertDialogChoice, radioGroup.getCheckedRadioButtonId() != -1));
|
||||
|
||||
radioGroup.setOnCheckedChangeListener((group, checkedId) ->
|
||||
setDialogButtonsState(alertDialogChoice, true));
|
||||
@@ -383,9 +492,10 @@ public class RouterActivity extends AppCompatActivity {
|
||||
|
||||
int id = 12345;
|
||||
for (final AdapterChoiceItem item : choices) {
|
||||
final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater).getRoot();
|
||||
final RadioButton radioButton = ListRadioIconItemBinding.inflate(layoutInflater)
|
||||
.getRoot();
|
||||
radioButton.setText(item.description);
|
||||
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(radioButton,
|
||||
radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
AppCompatResources.getDrawable(themeWrapperContext, item.icon),
|
||||
null, null, null);
|
||||
radioButton.setChecked(false);
|
||||
@@ -410,7 +520,7 @@ public class RouterActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
|
||||
selectedRadioPosition = Math.min(Math.max(-1, selectedRadioPosition), choices.size() - 1);
|
||||
selectedRadioPosition = MathUtils.clamp(selectedRadioPosition, -1, choices.size() - 1);
|
||||
if (selectedRadioPosition != -1) {
|
||||
((RadioButton) radioGroup.getChildAt(selectedRadioPosition)).setChecked(true);
|
||||
}
|
||||
@@ -425,90 +535,67 @@ public class RouterActivity extends AppCompatActivity {
|
||||
|
||||
private List<AdapterChoiceItem> getChoicesForService(final StreamingService service,
|
||||
final LinkType linkType) {
|
||||
final Context context = getThemeWrapperContext();
|
||||
|
||||
final List<AdapterChoiceItem> returnList = new ArrayList<>();
|
||||
final List<StreamingService.ServiceInfo.MediaCapability> capabilities
|
||||
= service.getServiceInfo().getMediaCapabilities();
|
||||
|
||||
final SharedPreferences preferences = PreferenceManager
|
||||
.getDefaultSharedPreferences(this);
|
||||
final boolean isExtVideoEnabled = preferences.getBoolean(
|
||||
getString(R.string.use_external_video_player_key), false);
|
||||
final boolean isExtAudioEnabled = preferences.getBoolean(
|
||||
getString(R.string.use_external_audio_player_key), false);
|
||||
|
||||
final AdapterChoiceItem videoPlayer = new AdapterChoiceItem(
|
||||
getString(R.string.video_player_key), getString(R.string.video_player),
|
||||
R.drawable.ic_play_arrow);
|
||||
final AdapterChoiceItem showInfo = new AdapterChoiceItem(
|
||||
getString(R.string.show_info_key), getString(R.string.show_info),
|
||||
R.drawable.ic_info_outline);
|
||||
final AdapterChoiceItem popupPlayer = new AdapterChoiceItem(
|
||||
getString(R.string.popup_player_key), getString(R.string.popup_player),
|
||||
R.drawable.ic_picture_in_picture);
|
||||
final AdapterChoiceItem videoPlayer = new AdapterChoiceItem(
|
||||
getString(R.string.video_player_key), getString(R.string.video_player),
|
||||
R.drawable.ic_play_arrow);
|
||||
final AdapterChoiceItem backgroundPlayer = new AdapterChoiceItem(
|
||||
getString(R.string.background_player_key), getString(R.string.background_player),
|
||||
R.drawable.ic_headset);
|
||||
final AdapterChoiceItem addToPlaylist = new AdapterChoiceItem(
|
||||
getString(R.string.add_to_playlist_key), getString(R.string.add_to_playlist),
|
||||
R.drawable.ic_add);
|
||||
final AdapterChoiceItem popupPlayer = new AdapterChoiceItem(
|
||||
getString(R.string.popup_player_key), getString(R.string.popup_player),
|
||||
R.drawable.ic_picture_in_picture);
|
||||
|
||||
final List<AdapterChoiceItem> returnedItems = new ArrayList<>();
|
||||
returnedItems.add(showInfo); // Always present
|
||||
|
||||
final List<StreamingService.ServiceInfo.MediaCapability> capabilities =
|
||||
service.getServiceInfo().getMediaCapabilities();
|
||||
|
||||
if (linkType == LinkType.STREAM) {
|
||||
if (isExtVideoEnabled) {
|
||||
// show both "show info" and "video player", they are two different activities
|
||||
returnList.add(showInfo);
|
||||
returnList.add(videoPlayer);
|
||||
} else {
|
||||
final MainPlayer.PlayerType playerType = PlayerHolder.getInstance().getType();
|
||||
if (capabilities.contains(VIDEO)
|
||||
&& PlayerHelper.isAutoplayAllowedByUser(context)
|
||||
&& playerType == null || playerType == MainPlayer.PlayerType.VIDEO) {
|
||||
// show only "video player" since the details activity will be opened and the
|
||||
// video will be auto played there. Since "show info" would do the exact same
|
||||
// thing, use that as a key to let VideoDetailFragment load the stream instead
|
||||
// of using FetcherService (see comment in handleChoice())
|
||||
returnList.add(new AdapterChoiceItem(
|
||||
showInfo.key, videoPlayer.description, videoPlayer.icon));
|
||||
} else {
|
||||
// show only "show info" if video player is not applicable, auto play is
|
||||
// disabled or a video is playing in a player different than the main one
|
||||
returnList.add(showInfo);
|
||||
}
|
||||
}
|
||||
|
||||
if (capabilities.contains(VIDEO)) {
|
||||
returnList.add(popupPlayer);
|
||||
returnedItems.add(videoPlayer);
|
||||
returnedItems.add(popupPlayer);
|
||||
}
|
||||
if (capabilities.contains(AUDIO)) {
|
||||
returnList.add(backgroundPlayer);
|
||||
returnedItems.add(backgroundPlayer);
|
||||
}
|
||||
// download is redundant for linkType CHANNEL AND PLAYLIST (till playlist downloading is
|
||||
// not supported )
|
||||
returnList.add(new AdapterChoiceItem(getString(R.string.download_key),
|
||||
returnedItems.add(new AdapterChoiceItem(getString(R.string.download_key),
|
||||
getString(R.string.download),
|
||||
R.drawable.ic_file_download));
|
||||
|
||||
// Add to playlist is not necessary for CHANNEL and PLAYLIST linkType since those can
|
||||
// not be added to a playlist
|
||||
returnList.add(addToPlaylist);
|
||||
|
||||
returnedItems.add(new AdapterChoiceItem(getString(R.string.add_to_playlist_key),
|
||||
getString(R.string.add_to_playlist),
|
||||
R.drawable.ic_add));
|
||||
} else {
|
||||
returnList.add(showInfo);
|
||||
// LinkType.NONE is never present because it's filtered out before
|
||||
// channels and playlist can be played as they contain a list of videos
|
||||
final SharedPreferences preferences = PreferenceManager
|
||||
.getDefaultSharedPreferences(this);
|
||||
final boolean isExtVideoEnabled = preferences.getBoolean(
|
||||
getString(R.string.use_external_video_player_key), false);
|
||||
final boolean isExtAudioEnabled = preferences.getBoolean(
|
||||
getString(R.string.use_external_audio_player_key), false);
|
||||
|
||||
if (capabilities.contains(VIDEO) && !isExtVideoEnabled) {
|
||||
returnList.add(videoPlayer);
|
||||
returnList.add(popupPlayer);
|
||||
returnedItems.add(videoPlayer);
|
||||
returnedItems.add(popupPlayer);
|
||||
}
|
||||
if (capabilities.contains(AUDIO) && !isExtAudioEnabled) {
|
||||
returnList.add(backgroundPlayer);
|
||||
returnedItems.add(backgroundPlayer);
|
||||
}
|
||||
}
|
||||
|
||||
return returnList;
|
||||
return returnedItems;
|
||||
}
|
||||
|
||||
private Context getThemeWrapperContext() {
|
||||
protected Context getThemeWrapperContext() {
|
||||
return new ContextThemeWrapper(this, ThemeHelper.isLightThemeSelected(this)
|
||||
? R.style.LightTheme : R.style.DarkTheme);
|
||||
}
|
||||
@@ -544,8 +631,7 @@ public class RouterActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
if (selectedChoiceKey.equals(getString(R.string.popup_player_key))
|
||||
&& !PermissionHelper.isPopupEnabled(this)) {
|
||||
PermissionHelper.showPopupEnablementToast(this);
|
||||
&& !PermissionHelper.isPopupEnabledElseAsk(this)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
@@ -567,7 +653,8 @@ public class RouterActivity extends AppCompatActivity {
|
||||
|
||||
// stop and bypass FetcherService if InfoScreen was selected since
|
||||
// StreamDetailFragment can fetch data itself
|
||||
if (selectedChoiceKey.equals(getString(R.string.show_info_key))) {
|
||||
if (selectedChoiceKey.equals(getString(R.string.show_info_key))
|
||||
|| canHandleChoiceLikeShowInfo(selectedChoiceKey)) {
|
||||
disposables.add(Observable
|
||||
.fromCallable(() -> NavigationHelper.getIntentByLink(this, currentUrl))
|
||||
.subscribeOn(Schedulers.io())
|
||||
@@ -590,63 +677,203 @@ public class RouterActivity extends AppCompatActivity {
|
||||
finish();
|
||||
}
|
||||
|
||||
private void openAddToPlaylistDialog() {
|
||||
// Getting the stream info usually takes a moment
|
||||
// Notifying the user here to ensure that no confusion arises
|
||||
Toast.makeText(
|
||||
getApplicationContext(),
|
||||
getString(R.string.processing_may_take_a_moment),
|
||||
Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
private boolean canHandleChoiceLikeShowInfo(final String selectedChoiceKey) {
|
||||
if (!selectedChoiceKey.equals(getString(R.string.video_player_key))) {
|
||||
return false;
|
||||
}
|
||||
// "video player" can be handled like "show info" (because VideoDetailFragment can load
|
||||
// the stream instead of FetcherService) when...
|
||||
|
||||
disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, false)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
info -> PlaylistDialog.createCorrespondingDialog(
|
||||
getThemeWrapperContext(),
|
||||
Collections.singletonList(new StreamEntity(info)),
|
||||
playlistDialog -> {
|
||||
playlistDialog.setOnDismissListener(dialog -> finish());
|
||||
// ...Autoplay is enabled
|
||||
if (!PlayerHelper.isAutoplayAllowedByUser(getThemeWrapperContext())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
playlistDialog.show(
|
||||
this.getSupportFragmentManager(),
|
||||
"addToPlaylistDialog"
|
||||
);
|
||||
}
|
||||
),
|
||||
throwable -> handleError(this, new ErrorInfo(
|
||||
throwable,
|
||||
UserAction.REQUESTED_STREAM,
|
||||
"Tried to add " + currentUrl + " to a playlist",
|
||||
currentService.getServiceId())
|
||||
)
|
||||
)
|
||||
);
|
||||
final boolean isExtVideoEnabled = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
.getBoolean(getString(R.string.use_external_video_player_key), false);
|
||||
// ...it's not done via an external player
|
||||
if (isExtVideoEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ...the player is not running or in normal Video-mode/type
|
||||
final PlayerType playerType = PlayerHolder.getInstance().getType();
|
||||
return playerType == null || playerType == PlayerType.MAIN;
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
private void openDownloadDialog() {
|
||||
disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(result -> {
|
||||
final List<VideoStream> sortedVideoStreams = ListHelper
|
||||
.getSortedStreamVideosList(this, result.getVideoStreams(),
|
||||
result.getVideoOnlyStreams(), false);
|
||||
final int selectedVideoStreamIndex = ListHelper
|
||||
.getDefaultResolutionIndex(this, sortedVideoStreams);
|
||||
public static class PersistentFragment extends Fragment {
|
||||
private WeakReference<AppCompatActivity> weakContext;
|
||||
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||
private int running = 0;
|
||||
|
||||
final FragmentManager fm = getSupportFragmentManager();
|
||||
final DownloadDialog downloadDialog = DownloadDialog.newInstance(result);
|
||||
downloadDialog.setVideoStreams(sortedVideoStreams);
|
||||
downloadDialog.setAudioStreams(result.getAudioStreams());
|
||||
downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex);
|
||||
downloadDialog.setOnDismissListener(dialog -> finish());
|
||||
downloadDialog.show(fm, "downloadDialog");
|
||||
fm.executePendingTransactions();
|
||||
}, throwable ->
|
||||
showUnsupportedUrlDialog(currentUrl)));
|
||||
private synchronized void inFlight(final boolean started) {
|
||||
if (started) {
|
||||
running++;
|
||||
} else {
|
||||
running--;
|
||||
if (running <= 0) {
|
||||
getActivityContext().ifPresent(context -> context.getSupportFragmentManager()
|
||||
.beginTransaction().remove(this).commit());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(@NonNull final Context activityContext) {
|
||||
super.onAttach(activityContext);
|
||||
weakContext = new WeakReference<>((AppCompatActivity) activityContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDetach() {
|
||||
super.onDetach();
|
||||
weakContext = null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
@Override
|
||||
public void onCreate(final Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setRetainInstance(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
disposables.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the activity context, if there is one and the activity is not finishing
|
||||
*/
|
||||
private Optional<AppCompatActivity> getActivityContext() {
|
||||
return Optional.ofNullable(weakContext)
|
||||
.map(Reference::get)
|
||||
.filter(context -> !context.isFinishing());
|
||||
}
|
||||
|
||||
// guard against IllegalStateException in calling DialogFragment.show() whilst in background
|
||||
// (which could happen, say, when the user pressed the home button while waiting for
|
||||
// the network request to return) when it internally calls FragmentTransaction.commit()
|
||||
// after the FragmentManager has saved its states (isStateSaved() == true)
|
||||
// (ref: https://stackoverflow.com/a/39813506)
|
||||
private void runOnVisible(final Consumer<AppCompatActivity> runnable) {
|
||||
getActivityContext().ifPresentOrElse(context -> {
|
||||
if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) {
|
||||
context.runOnUiThread(() -> {
|
||||
runnable.accept(context);
|
||||
inFlight(false);
|
||||
});
|
||||
} else {
|
||||
getLifecycle().addObserver(new DefaultLifecycleObserver() {
|
||||
@Override
|
||||
public void onResume(@NonNull final LifecycleOwner owner) {
|
||||
getLifecycle().removeObserver(this);
|
||||
getActivityContext().ifPresentOrElse(context ->
|
||||
context.runOnUiThread(() -> {
|
||||
runnable.accept(context);
|
||||
inFlight(false);
|
||||
}),
|
||||
() -> inFlight(false)
|
||||
);
|
||||
}
|
||||
});
|
||||
// this trick doesn't seem to work on Android 10+ (API 29)
|
||||
// which places restrictions on starting activities from the background
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
|
||||
&& !context.isChangingConfigurations()) {
|
||||
// try to bring the activity back to front if minimised
|
||||
final Intent i = new Intent(context, RouterActivity.class);
|
||||
i.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
|
||||
startActivity(i);
|
||||
}
|
||||
}
|
||||
|
||||
}, () -> {
|
||||
// this branch is executed if there is no activity context
|
||||
inFlight(false);
|
||||
});
|
||||
}
|
||||
|
||||
<T> Single<T> pleaseWait(final Single<T> single) {
|
||||
// 'abuse' ambWith() here to cancel the toast for us when the wait is over
|
||||
return single.ambWith(Single.create(emitter -> getActivityContext().ifPresent(context ->
|
||||
context.runOnUiThread(() -> {
|
||||
// Getting the stream info usually takes a moment
|
||||
// Notifying the user here to ensure that no confusion arises
|
||||
final Toast toast = Toast.makeText(context,
|
||||
getString(R.string.processing_may_take_a_moment),
|
||||
Toast.LENGTH_LONG);
|
||||
toast.show();
|
||||
emitter.setCancellable(toast::cancel);
|
||||
}))));
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
private void openDownloadDialog(final int currentServiceId, final String currentUrl) {
|
||||
inFlight(true);
|
||||
disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.compose(this::pleaseWait)
|
||||
.subscribe(result ->
|
||||
runOnVisible(ctx -> {
|
||||
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))));
|
||||
}
|
||||
|
||||
private void openAddToPlaylistDialog(final int currentServiceId, final String currentUrl) {
|
||||
inFlight(true);
|
||||
disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, false)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.compose(this::pleaseWait)
|
||||
.subscribe(
|
||||
info -> getActivityContext().ifPresent(context ->
|
||||
PlaylistDialog.createCorrespondingDialog(context,
|
||||
List.of(new StreamEntity(info)),
|
||||
playlistDialog -> runOnVisible(ctx -> {
|
||||
// dismiss listener to be handled by FragmentManager
|
||||
final FragmentManager fm =
|
||||
ctx.getSupportFragmentManager();
|
||||
playlistDialog.show(fm, "addToPlaylistDialog");
|
||||
})
|
||||
)),
|
||||
throwable -> runOnVisible(ctx -> handleError(ctx, new ErrorInfo(
|
||||
throwable,
|
||||
UserAction.REQUESTED_STREAM,
|
||||
"Tried to add " + currentUrl + " to a playlist",
|
||||
((RouterActivity) ctx).currentService.getServiceId())
|
||||
))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void openAddToPlaylistDialog() {
|
||||
getPersistFragment().openAddToPlaylistDialog(currentServiceId, currentUrl);
|
||||
}
|
||||
|
||||
private void openDownloadDialog() {
|
||||
getPersistFragment().openDownloadDialog(currentServiceId, currentUrl);
|
||||
}
|
||||
|
||||
private PersistentFragment getPersistFragment() {
|
||||
final FragmentManager fm = getSupportFragmentManager();
|
||||
PersistentFragment persistFragment =
|
||||
(PersistentFragment) fm.findFragmentByTag("PERSIST_FRAGMENT");
|
||||
if (persistFragment == null) {
|
||||
persistFragment = new PersistentFragment();
|
||||
fm.beginTransaction()
|
||||
.add(persistFragment, "PERSIST_FRAGMENT")
|
||||
.commitNow();
|
||||
}
|
||||
return persistFragment;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -672,8 +899,8 @@ public class RouterActivity extends AppCompatActivity {
|
||||
final int icon;
|
||||
|
||||
AdapterChoiceItem(final String key, final String description, final int icon) {
|
||||
this.description = description;
|
||||
this.key = key;
|
||||
this.description = description;
|
||||
this.icon = icon;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.schabi.newpipe.BuildConfig
|
||||
import org.schabi.newpipe.R
|
||||
@@ -21,30 +21,28 @@ import org.schabi.newpipe.util.ThemeHelper
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
|
||||
class AboutActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Localization.assureCorrectAppLanguage(this)
|
||||
super.onCreate(savedInstanceState)
|
||||
ThemeHelper.setTheme(this)
|
||||
title = getString(R.string.title_activity_about)
|
||||
|
||||
val aboutBinding = ActivityAboutBinding.inflate(layoutInflater)
|
||||
setContentView(aboutBinding.root)
|
||||
setSupportActionBar(aboutBinding.aboutToolbar)
|
||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
// Create the adapter that will return a fragment for each of the three
|
||||
// primary sections of the activity.
|
||||
val mAboutStateAdapter = AboutStateAdapter(this)
|
||||
|
||||
// Set up the ViewPager with the sections adapter.
|
||||
aboutBinding.aboutViewPager2.adapter = mAboutStateAdapter
|
||||
TabLayoutMediator(
|
||||
aboutBinding.aboutTabLayout,
|
||||
aboutBinding.aboutViewPager2
|
||||
) { tab: TabLayout.Tab, position: Int ->
|
||||
when (position) {
|
||||
POS_ABOUT -> tab.setText(R.string.tab_about)
|
||||
POS_LICENSE -> tab.setText(R.string.tab_licenses)
|
||||
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
|
||||
}
|
||||
) { tab, position ->
|
||||
tab.setText(mAboutStateAdapter.getPageTitle(position))
|
||||
}.attach()
|
||||
}
|
||||
|
||||
@@ -60,13 +58,9 @@ class AboutActivity : AppCompatActivity() {
|
||||
* A placeholder fragment containing a simple view.
|
||||
*/
|
||||
class AboutFragment : Fragment() {
|
||||
private fun Button.openLink(url: Int) {
|
||||
private fun Button.openLink(@StringRes url: Int) {
|
||||
setOnClickListener {
|
||||
ShareUtils.openUrlInBrowser(
|
||||
context,
|
||||
requireContext().getString(url),
|
||||
false
|
||||
)
|
||||
ShareUtils.openUrlInApp(context, requireContext().getString(url))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,13 +69,15 @@ class AboutActivity : AppCompatActivity() {
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val aboutBinding = FragmentAboutBinding.inflate(inflater, container, false)
|
||||
aboutBinding.aboutAppVersion.text = BuildConfig.VERSION_NAME
|
||||
aboutBinding.aboutGithubLink.openLink(R.string.github_url)
|
||||
aboutBinding.aboutDonationLink.openLink(R.string.donation_url)
|
||||
aboutBinding.aboutWebsiteLink.openLink(R.string.website_url)
|
||||
aboutBinding.aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url)
|
||||
return aboutBinding.root
|
||||
FragmentAboutBinding.inflate(inflater, container, false).apply {
|
||||
aboutAppVersion.text = BuildConfig.VERSION_NAME
|
||||
aboutGithubLink.openLink(R.string.github_url)
|
||||
aboutDonationLink.openLink(R.string.donation_url)
|
||||
aboutWebsiteLink.openLink(R.string.website_url)
|
||||
aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url)
|
||||
faqLink.openLink(R.string.faq_url)
|
||||
return root
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,17 +86,29 @@ class AboutActivity : AppCompatActivity() {
|
||||
* one of the sections/tabs/pages.
|
||||
*/
|
||||
private class AboutStateAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
|
||||
private val posAbout = 0
|
||||
private val posLicense = 1
|
||||
private val totalCount = 2
|
||||
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
return when (position) {
|
||||
POS_ABOUT -> AboutFragment()
|
||||
POS_LICENSE -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS)
|
||||
posAbout -> AboutFragment()
|
||||
posLicense -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS)
|
||||
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
// Show 2 total pages.
|
||||
return TOTAL_COUNT
|
||||
return totalCount
|
||||
}
|
||||
|
||||
fun getPageTitle(position: Int): Int {
|
||||
return when (position) {
|
||||
posAbout -> R.string.tab_about
|
||||
posLicense -> R.string.tab_licenses
|
||||
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,10 +125,6 @@ class AboutActivity : AppCompatActivity() {
|
||||
"AndroidX", "2005 - 2011", "The Android Open Source Project",
|
||||
"https://developer.android.com/jetpack", StandardLicenses.APACHE2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"CircleImageView", "2014 - 2020", "Henning Dodenhof",
|
||||
"https://github.com/hdodenhof/CircleImageView", StandardLicenses.APACHE2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"ExoPlayer", "2014 - 2020", "Google, Inc.",
|
||||
"https://github.com/google/ExoPlayer", StandardLicenses.APACHE2
|
||||
@@ -191,8 +195,5 @@ class AboutActivity : AppCompatActivity() {
|
||||
"https://github.com/ByteHamster/SearchPreference", StandardLicenses.MIT
|
||||
),
|
||||
)
|
||||
private const val POS_ABOUT = 0
|
||||
private const val POS_LICENSE = 1
|
||||
private const val TOTAL_COUNT = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.about.LicenseFragmentHelper.showLicense
|
||||
import org.schabi.newpipe.databinding.FragmentLicensesBinding
|
||||
import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding
|
||||
|
||||
|
||||
@@ -12,136 +12,92 @@ 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.BufferedReader
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
object LicenseFragmentHelper {
|
||||
/**
|
||||
* @param context the context to use
|
||||
* @param license the license
|
||||
* @return String which contains a HTML formatted license page
|
||||
* styled according to the context's theme
|
||||
*/
|
||||
private fun getFormattedLicense(context: Context, license: License): String {
|
||||
val licenseContent = StringBuilder()
|
||||
val webViewData: String
|
||||
try {
|
||||
BufferedReader(
|
||||
InputStreamReader(
|
||||
context.assets.open(license.filename),
|
||||
StandardCharsets.UTF_8
|
||||
)
|
||||
).use { `in` ->
|
||||
var str: String?
|
||||
while (`in`.readLine().also { str = it } != null) {
|
||||
licenseContent.append(str)
|
||||
}
|
||||
/**
|
||||
* @param context the context to use
|
||||
* @param license the license
|
||||
* @return String which contains a HTML formatted license page
|
||||
* styled according to the context's theme
|
||||
*/
|
||||
private fun getFormattedLicense(context: Context, license: License): String {
|
||||
try {
|
||||
return context.assets.open(license.filename).bufferedReader().use { it.readText() }
|
||||
// split the HTML file and insert the stylesheet into the HEAD of the file
|
||||
.replace("</head>", "<style>${getLicenseStylesheet(context)}</style></head>")
|
||||
} catch (e: IOException) {
|
||||
throw IllegalArgumentException("Could not get license file: ${license.filename}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// split the HTML file and insert the stylesheet into the HEAD of the file
|
||||
webViewData = "$licenseContent".replace(
|
||||
"</head>",
|
||||
"<style>" + getLicenseStylesheet(context) + "</style></head>"
|
||||
)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw IllegalArgumentException(
|
||||
"Could not get license file: " + license.filename, e
|
||||
)
|
||||
/**
|
||||
* @param context the Android context
|
||||
* @return String which is a CSS stylesheet according to the context's theme
|
||||
*/
|
||||
private fun getLicenseStylesheet(context: Context): String {
|
||||
val isLightTheme = ThemeHelper.isLightThemeSelected(context)
|
||||
val licenseBackgroundColor = getHexRGBColor(
|
||||
context, if (isLightTheme) R.color.light_license_background_color else R.color.dark_license_background_color
|
||||
)
|
||||
val licenseTextColor = getHexRGBColor(
|
||||
context, if (isLightTheme) R.color.light_license_text_color else R.color.dark_license_text_color
|
||||
)
|
||||
val youtubePrimaryColor = getHexRGBColor(
|
||||
context, if (isLightTheme) R.color.light_youtube_primary_color else R.color.dark_youtube_primary_color
|
||||
)
|
||||
return "body{padding:12px 15px;margin:0;background:#$licenseBackgroundColor;color:#$licenseTextColor}" +
|
||||
"a[href]{color:#$youtubePrimaryColor}pre{white-space:pre-wrap}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast R.color to a hexadecimal color value.
|
||||
*
|
||||
* @param context the context to use
|
||||
* @param color the color number from R.color
|
||||
* @return a six characters long String with hexadecimal RGB values
|
||||
*/
|
||||
private 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()
|
||||
}
|
||||
return webViewData
|
||||
}
|
||||
|
||||
/**
|
||||
* @param context the Android context
|
||||
* @return String which is a CSS stylesheet according to the context's theme
|
||||
*/
|
||||
private fun getLicenseStylesheet(context: Context): String {
|
||||
val isLightTheme = ThemeHelper.isLightThemeSelected(context)
|
||||
return (
|
||||
"body{padding:12px 15px;margin:0;" + "background:#" + getHexRGBColor(
|
||||
context,
|
||||
if (isLightTheme) R.color.light_license_background_color
|
||||
else R.color.dark_license_background_color
|
||||
) + ";" + "color:#" + getHexRGBColor(
|
||||
context,
|
||||
if (isLightTheme) R.color.light_license_text_color
|
||||
else R.color.dark_license_text_color
|
||||
) + "}" + "a[href]{color:#" + getHexRGBColor(
|
||||
context,
|
||||
if (isLightTheme) R.color.light_youtube_primary_color
|
||||
else R.color.dark_youtube_primary_color
|
||||
) + "}" + "pre{white-space:pre-wrap}"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast R.color to a hexadecimal color value.
|
||||
*
|
||||
* @param context the context to use
|
||||
* @param color the color number from R.color
|
||||
* @return a six characters long String with hexadecimal RGB values
|
||||
*/
|
||||
private fun getHexRGBColor(context: Context, color: Int): String {
|
||||
return context.getString(color).substring(3)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun showLicense(context: Context?, license: License): Disposable {
|
||||
return if (context == null) {
|
||||
Disposable.empty()
|
||||
} else {
|
||||
Observable.fromCallable { getFormattedLicense(context, license) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { formattedLicense: String ->
|
||||
val webViewData = Base64.encodeToString(
|
||||
formattedLicense
|
||||
.toByteArray(StandardCharsets.UTF_8),
|
||||
Base64.NO_PADDING
|
||||
)
|
||||
val webView = WebView(context)
|
||||
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
|
||||
val alert = AlertDialog.Builder(context)
|
||||
alert.setTitle(license.name)
|
||||
alert.setView(webView)
|
||||
Localization.assureCorrectAppLanguage(context)
|
||||
alert.setNegativeButton(
|
||||
context.getString(R.string.ok)
|
||||
) { dialog, _ -> dialog.dismiss() }
|
||||
alert.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@JvmStatic
|
||||
fun showLicense(context: Context?, component: SoftwareComponent): Disposable {
|
||||
return if (context == null) {
|
||||
Disposable.empty()
|
||||
} else {
|
||||
Observable.fromCallable { getFormattedLicense(context, component.license) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { formattedLicense: String ->
|
||||
val webViewData = Base64.encodeToString(
|
||||
formattedLicense
|
||||
.toByteArray(StandardCharsets.UTF_8),
|
||||
Base64.NO_PADDING
|
||||
)
|
||||
val webView = WebView(context)
|
||||
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
|
||||
val alert = AlertDialog.Builder(context)
|
||||
alert.setTitle(component.license.name)
|
||||
alert.setView(webView)
|
||||
Localization.assureCorrectAppLanguage(context)
|
||||
alert.setPositiveButton(
|
||||
R.string.dismiss
|
||||
) { dialog, _ -> dialog.dismiss() }
|
||||
alert.setNeutralButton(R.string.open_website_license) { _, _ ->
|
||||
ShareUtils.openUrlInBrowser(context, component.link)
|
||||
}
|
||||
alert.show()
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.schabi.newpipe.database;
|
||||
|
||||
import static org.schabi.newpipe.database.Migrations.DB_VER_7;
|
||||
|
||||
import androidx.room.Database;
|
||||
import androidx.room.RoomDatabase;
|
||||
import androidx.room.TypeConverters;
|
||||
@@ -27,8 +29,6 @@ import org.schabi.newpipe.database.stream.model.StreamStateEntity;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionDAO;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
|
||||
import static org.schabi.newpipe.database.Migrations.DB_VER_4;
|
||||
|
||||
@TypeConverters({Converters.class})
|
||||
@Database(
|
||||
entities = {
|
||||
@@ -38,7 +38,7 @@ import static org.schabi.newpipe.database.Migrations.DB_VER_4;
|
||||
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
|
||||
FeedLastUpdatedEntity.class
|
||||
},
|
||||
version = DB_VER_4
|
||||
version = DB_VER_7
|
||||
)
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
public static final String DATABASE_NAME = "newpipe.db";
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.schabi.newpipe.database;
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Delete;
|
||||
import androidx.room.Insert;
|
||||
import androidx.room.OnConflictStrategy;
|
||||
import androidx.room.Update;
|
||||
|
||||
import java.util.Collection;
|
||||
@@ -14,13 +13,10 @@ import io.reactivex.rxjava3.core.Flowable;
|
||||
@Dao
|
||||
public interface BasicDAO<Entity> {
|
||||
/* Inserts */
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
@Insert
|
||||
long insert(Entity entity);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
List<Long> insertAll(Entity... entities);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
@Insert
|
||||
List<Long> insertAll(Collection<Entity> entities);
|
||||
|
||||
/* Searches */
|
||||
@@ -32,9 +28,6 @@ public interface BasicDAO<Entity> {
|
||||
@Delete
|
||||
void delete(Entity entity);
|
||||
|
||||
@Delete
|
||||
int delete(Collection<Entity> entities);
|
||||
|
||||
int deleteAll();
|
||||
|
||||
/* Updates */
|
||||
|
||||
@@ -22,6 +22,9 @@ public final class Migrations {
|
||||
public static final int DB_VER_2 = 2;
|
||||
public static final int DB_VER_3 = 3;
|
||||
public static final int DB_VER_4 = 4;
|
||||
public static final int DB_VER_5 = 5;
|
||||
public static final int DB_VER_6 = 6;
|
||||
public static final int DB_VER_7 = 7;
|
||||
|
||||
private static final String TAG = Migrations.class.getName();
|
||||
public static final boolean DEBUG = MainActivity.DEBUG;
|
||||
@@ -179,5 +182,59 @@ public final class Migrations {
|
||||
}
|
||||
};
|
||||
|
||||
private Migrations() { }
|
||||
public static final Migration MIGRATION_4_5 = new Migration(DB_VER_4, DB_VER_5) {
|
||||
@Override
|
||||
public void migrate(@NonNull final SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` "
|
||||
+ "INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_5_6 = new Migration(DB_VER_5, DB_VER_6) {
|
||||
@Override
|
||||
public void migrate(@NonNull final SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` "
|
||||
+ "INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_6_7 = new Migration(DB_VER_6, DB_VER_7) {
|
||||
@Override
|
||||
public void migrate(@NonNull final SupportSQLiteDatabase database) {
|
||||
// Create a new column thumbnail_stream_id
|
||||
database.execSQL("ALTER TABLE `playlists` ADD COLUMN `thumbnail_stream_id` "
|
||||
+ "INTEGER NOT NULL DEFAULT -1");
|
||||
|
||||
// Migrate the thumbnail_url to the thumbnail_stream_id
|
||||
database.execSQL("UPDATE playlists SET thumbnail_stream_id = ("
|
||||
+ " SELECT CASE WHEN COUNT(*) != 0 then stream_uid ELSE -1 END"
|
||||
+ " FROM ("
|
||||
+ " SELECT p.uid AS playlist_uid, s.uid AS stream_uid"
|
||||
+ " FROM playlists p"
|
||||
+ " LEFT JOIN playlist_stream_join ps ON p.uid = ps.playlist_id"
|
||||
+ " LEFT JOIN streams s ON s.uid = ps.stream_id"
|
||||
+ " WHERE s.thumbnail_url = p.thumbnail_url) AS temporary_table"
|
||||
+ " WHERE playlist_uid = playlists.uid)");
|
||||
|
||||
// Remove the thumbnail_url field in the playlist table
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS `playlists_new`"
|
||||
+ "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
|
||||
+ "name TEXT, "
|
||||
+ "is_thumbnail_permanent INTEGER NOT NULL, "
|
||||
+ "thumbnail_stream_id INTEGER NOT NULL)");
|
||||
|
||||
database.execSQL("INSERT INTO playlists_new"
|
||||
+ " SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id "
|
||||
+ " FROM playlists");
|
||||
|
||||
|
||||
database.execSQL("DROP TABLE playlists");
|
||||
database.execSQL("ALTER TABLE playlists_new RENAME TO playlists");
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS "
|
||||
+ "`index_playlists_name` ON `playlists` (`name`)");
|
||||
}
|
||||
};
|
||||
|
||||
private Migrations() {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ import androidx.room.Update
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
import org.schabi.newpipe.database.feed.model.FeedEntity
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
|
||||
import org.schabi.newpipe.database.stream.StreamWithState
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@@ -20,56 +22,17 @@ abstract class FeedDAO {
|
||||
@Query("DELETE FROM feed")
|
||||
abstract fun deleteAll(): Int
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT s.*, sst.progress_time
|
||||
FROM streams s
|
||||
|
||||
LEFT JOIN stream_state sst
|
||||
ON s.uid = sst.stream_id
|
||||
|
||||
LEFT JOIN stream_history sh
|
||||
ON s.uid = sh.stream_id
|
||||
|
||||
INNER JOIN feed f
|
||||
ON s.uid = f.stream_id
|
||||
|
||||
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
|
||||
LIMIT 500
|
||||
"""
|
||||
)
|
||||
abstract fun getAllStreams(): Maybe<List<StreamWithState>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT s.*, sst.progress_time
|
||||
FROM streams s
|
||||
|
||||
LEFT JOIN stream_state sst
|
||||
ON s.uid = sst.stream_id
|
||||
|
||||
LEFT JOIN stream_history sh
|
||||
ON s.uid = sh.stream_id
|
||||
|
||||
INNER JOIN feed f
|
||||
ON s.uid = f.stream_id
|
||||
|
||||
INNER JOIN feed_group_subscription_join fgs
|
||||
ON fgs.subscription_id = f.subscription_id
|
||||
|
||||
WHERE fgs.group_id = :groupId
|
||||
|
||||
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
|
||||
LIMIT 500
|
||||
"""
|
||||
)
|
||||
abstract fun getAllStreamsForGroup(groupId: Long): Maybe<List<StreamWithState>>
|
||||
|
||||
/**
|
||||
* @param groupId the group id to get feed streams of; use
|
||||
* [FeedGroupEntity.GROUP_ALL_ID] to not filter by group
|
||||
* @param includePlayed if false, only return all of the live, never-played or non-finished
|
||||
* feed streams (see `@see` items); if true no filter is applied
|
||||
* @param uploadDateBefore get only streams uploaded before this date (useful to filter out
|
||||
* future streams); use null to not filter by upload date
|
||||
* @return the feed streams filtered according to the conditions provided in the parameters
|
||||
* @see StreamStateEntity.isFinished()
|
||||
* @see StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS
|
||||
* @return all of the non-live, never-played and non-finished streams in the feed
|
||||
* (all of the cited conditions must hold for a stream to be in the returned list)
|
||||
* @see StreamStateEntity.PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS
|
||||
*/
|
||||
@Query(
|
||||
"""
|
||||
@@ -78,67 +41,57 @@ abstract class FeedDAO {
|
||||
|
||||
LEFT JOIN stream_state sst
|
||||
ON s.uid = sst.stream_id
|
||||
|
||||
|
||||
LEFT JOIN stream_history sh
|
||||
ON s.uid = sh.stream_id
|
||||
|
||||
ON s.uid = sh.stream_id
|
||||
|
||||
INNER JOIN feed f
|
||||
ON s.uid = f.stream_id
|
||||
|
||||
LEFT JOIN feed_group_subscription_join fgs
|
||||
ON (
|
||||
:groupId <> ${FeedGroupEntity.GROUP_ALL_ID}
|
||||
AND fgs.subscription_id = f.subscription_id
|
||||
)
|
||||
|
||||
WHERE (
|
||||
sh.stream_id IS NULL
|
||||
OR sst.stream_id IS NULL
|
||||
OR sst.progress_time < s.duration * 1000 - ${StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS}
|
||||
OR sst.progress_time < s.duration * 1000 * 3 / 4
|
||||
OR s.stream_type = 'LIVE_STREAM'
|
||||
OR s.stream_type = 'AUDIO_LIVE_STREAM'
|
||||
:groupId = ${FeedGroupEntity.GROUP_ALL_ID}
|
||||
OR fgs.group_id = :groupId
|
||||
)
|
||||
|
||||
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
|
||||
LIMIT 500
|
||||
"""
|
||||
)
|
||||
abstract fun getLiveOrNotPlayedStreams(): Maybe<List<StreamWithState>>
|
||||
|
||||
/**
|
||||
* @see StreamStateEntity.isFinished()
|
||||
* @see StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS
|
||||
* @param groupId the group id to get streams of
|
||||
* @return all of the non-live, never-played and non-finished streams for the given feed group
|
||||
* (all of the cited conditions must hold for a stream to be in the returned list)
|
||||
*/
|
||||
@Query(
|
||||
"""
|
||||
SELECT s.*, sst.progress_time
|
||||
FROM streams s
|
||||
|
||||
LEFT JOIN stream_state sst
|
||||
ON s.uid = sst.stream_id
|
||||
|
||||
LEFT JOIN stream_history sh
|
||||
ON s.uid = sh.stream_id
|
||||
|
||||
INNER JOIN feed f
|
||||
ON s.uid = f.stream_id
|
||||
|
||||
INNER JOIN feed_group_subscription_join fgs
|
||||
ON fgs.subscription_id = f.subscription_id
|
||||
|
||||
WHERE fgs.group_id = :groupId
|
||||
AND (
|
||||
sh.stream_id IS NULL
|
||||
:includePlayed
|
||||
OR sh.stream_id IS NULL
|
||||
OR sst.stream_id IS NULL
|
||||
OR sst.progress_time < s.duration * 1000 - ${StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS}
|
||||
OR sst.progress_time < s.duration * 1000 * 3 / 4
|
||||
OR s.stream_type = 'LIVE_STREAM'
|
||||
OR s.stream_type = 'AUDIO_LIVE_STREAM'
|
||||
)
|
||||
AND (
|
||||
:includePartiallyPlayed
|
||||
OR sh.stream_id IS NULL
|
||||
OR sst.stream_id IS NULL
|
||||
OR (sst.progress_time <= ${StreamStateEntity.PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS}
|
||||
AND sst.progress_time <= s.duration * 1000 / 4)
|
||||
OR (sst.progress_time >= s.duration * 1000 - ${StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS}
|
||||
AND sst.progress_time >= s.duration * 1000 * 3 / 4)
|
||||
)
|
||||
AND (
|
||||
:uploadDateBefore IS NULL
|
||||
OR s.upload_date IS NULL
|
||||
OR s.upload_date < :uploadDateBefore
|
||||
)
|
||||
|
||||
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
|
||||
LIMIT 500
|
||||
"""
|
||||
)
|
||||
abstract fun getLiveOrNotPlayedStreamsForGroup(groupId: Long): Maybe<List<StreamWithState>>
|
||||
abstract fun getStreams(
|
||||
groupId: Long,
|
||||
includePlayed: Boolean,
|
||||
includePartiallyPlayed: Boolean,
|
||||
uploadDateBefore: OffsetDateTime?
|
||||
): Maybe<List<StreamWithState>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
@@ -252,4 +205,21 @@ abstract class FeedDAO {
|
||||
"""
|
||||
)
|
||||
abstract fun getAllOutdatedForGroup(groupId: Long, outdatedThreshold: OffsetDateTime): Flowable<List<SubscriptionEntity>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT s.* FROM subscriptions s
|
||||
|
||||
LEFT JOIN feed_last_updated lu
|
||||
ON s.uid = lu.subscription_id
|
||||
|
||||
WHERE
|
||||
(lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold)
|
||||
AND s.notification_mode = :notificationMode
|
||||
"""
|
||||
)
|
||||
abstract fun getOutdatedWithNotificationMode(
|
||||
outdatedThreshold: OffsetDateTime,
|
||||
@NotificationMode notificationMode: Int
|
||||
): Flowable<List<SubscriptionEntity>>
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.schabi.newpipe.database.history.dao;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Query;
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns;
|
||||
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity;
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntry;
|
||||
@@ -67,6 +68,7 @@ public abstract class StreamHistoryDAO implements HistoryDAO<StreamHistoryEntity
|
||||
@Query("DELETE FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
|
||||
public abstract int deleteStreamHistory(long streamId);
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query("SELECT * FROM " + STREAM_TABLE
|
||||
|
||||
// Select the latest entry and watch count for each stream id on history table
|
||||
|
||||
@@ -4,7 +4,6 @@ import androidx.annotation.NonNull;
|
||||
import androidx.room.ColumnInfo;
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.ForeignKey;
|
||||
import androidx.room.Ignore;
|
||||
import androidx.room.Index;
|
||||
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
@@ -42,18 +41,19 @@ public class StreamHistoryEntity {
|
||||
@ColumnInfo(name = STREAM_REPEAT_COUNT)
|
||||
private long repeatCount;
|
||||
|
||||
public StreamHistoryEntity(final long streamUid, @NonNull final OffsetDateTime accessDate,
|
||||
/**
|
||||
* @param streamUid the stream id this history item will refer to
|
||||
* @param accessDate the last time the stream was accessed
|
||||
* @param repeatCount the total number of views this stream received
|
||||
*/
|
||||
public StreamHistoryEntity(final long streamUid,
|
||||
@NonNull final OffsetDateTime accessDate,
|
||||
final long repeatCount) {
|
||||
this.streamUid = streamUid;
|
||||
this.accessDate = accessDate;
|
||||
this.repeatCount = repeatCount;
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public StreamHistoryEntity(final long streamUid, @NonNull final OffsetDateTime accessDate) {
|
||||
this(streamUid, accessDate, 1);
|
||||
}
|
||||
|
||||
public long getStreamUid() {
|
||||
return streamUid;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.schabi.newpipe.database.playlist;
|
||||
|
||||
import androidx.room.ColumnInfo;
|
||||
|
||||
/**
|
||||
* This class adds a field to {@link PlaylistMetadataEntry} that contains an integer representing
|
||||
* how many times a specific stream is already contained inside a local playlist. Used to be able
|
||||
* to grey out playlists which already contain the current stream in the playlist append dialog.
|
||||
* @see org.schabi.newpipe.local.playlist.LocalPlaylistManager#getPlaylistDuplicates(String)
|
||||
*/
|
||||
public class PlaylistDuplicatesEntry extends PlaylistMetadataEntry {
|
||||
public static final String PLAYLIST_TIMES_STREAM_IS_CONTAINED = "timesStreamIsContained";
|
||||
@ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED)
|
||||
public final long timesStreamIsContained;
|
||||
|
||||
public PlaylistDuplicatesEntry(final long uid,
|
||||
final String name,
|
||||
final String thumbnailUrl,
|
||||
final long streamCount,
|
||||
final long timesStreamIsContained) {
|
||||
super(uid, name, thumbnailUrl, streamCount);
|
||||
this.timesStreamIsContained = timesStreamIsContained;
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@ package org.schabi.newpipe.database.playlist;
|
||||
import org.schabi.newpipe.database.LocalItem;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
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();
|
||||
@@ -14,14 +14,9 @@ public interface PlaylistLocalItem extends LocalItem {
|
||||
static List<PlaylistLocalItem> merge(
|
||||
final List<PlaylistMetadataEntry> localPlaylists,
|
||||
final List<PlaylistRemoteEntity> remotePlaylists) {
|
||||
final List<PlaylistLocalItem> items = new ArrayList<>(
|
||||
localPlaylists.size() + remotePlaylists.size());
|
||||
items.addAll(localPlaylists);
|
||||
items.addAll(remotePlaylists);
|
||||
|
||||
Collections.sort(items, Comparator.comparing(PlaylistLocalItem::getOrderingName,
|
||||
Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)));
|
||||
|
||||
return items;
|
||||
return Stream.concat(localPlaylists.stream(), remotePlaylists.stream())
|
||||
.sorted(Comparator.comparing(PlaylistLocalItem::getOrderingName,
|
||||
Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,21 +2,27 @@ package org.schabi.newpipe.database.playlist.dao;
|
||||
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Query;
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns;
|
||||
import androidx.room.Transaction;
|
||||
|
||||
import org.schabi.newpipe.database.BasicDAO;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
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.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_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;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_PLAYLIST_ID;
|
||||
@@ -24,6 +30,8 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JO
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_THUMBNAIL_URL;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_URL;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
|
||||
@@ -52,6 +60,17 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
|
||||
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
|
||||
Flowable<Integer> getMaximumIndexOf(long playlistId);
|
||||
|
||||
@Query("SELECT CASE WHEN COUNT(*) != 0 then " + STREAM_ID
|
||||
+ " ELSE " + PlaylistEntity.DEFAULT_THUMBNAIL_ID + " END"
|
||||
+ " FROM " + STREAM_TABLE
|
||||
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
|
||||
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
|
||||
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId "
|
||||
+ " LIMIT 1"
|
||||
)
|
||||
Flowable<Long> getAutomaticThumbnailStreamId(long playlistId);
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Transaction
|
||||
@Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN "
|
||||
// get ids of streams of the given playlist
|
||||
@@ -72,13 +91,64 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
|
||||
Flowable<List<PlaylistStreamEntry>> getOrderedStreamsOf(long playlistId);
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", " + PLAYLIST_THUMBNAIL_URL + ", "
|
||||
@Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ","
|
||||
|
||||
+ " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = "
|
||||
+ PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'"
|
||||
+ " ELSE (SELECT " + STREAM_THUMBNAIL_URL
|
||||
+ " FROM " + STREAM_TABLE
|
||||
+ " WHERE " + STREAM_TABLE + "." + STREAM_ID + " = " + PLAYLIST_THUMBNAIL_STREAM_ID
|
||||
+ " ) END AS " + PLAYLIST_THUMBNAIL_URL + ", "
|
||||
|
||||
+ "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT
|
||||
+ " FROM " + PLAYLIST_TABLE
|
||||
+ " 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")
|
||||
Flowable<List<PlaylistMetadataEntry>> getPlaylistMetadata();
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Transaction
|
||||
@Query("SELECT *, MIN(" + JOIN_INDEX + ")"
|
||||
+ " FROM " + STREAM_TABLE + " INNER JOIN"
|
||||
+ " (SELECT " + JOIN_STREAM_ID + "," + JOIN_INDEX
|
||||
+ " FROM " + PLAYLIST_STREAM_JOIN_TABLE
|
||||
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId)"
|
||||
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
|
||||
+ " LEFT JOIN "
|
||||
+ "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", "
|
||||
+ STREAM_PROGRESS_MILLIS
|
||||
+ " FROM " + STREAM_STATE_TABLE + " )"
|
||||
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS
|
||||
+ " GROUP BY " + STREAM_ID
|
||||
+ " ORDER BY MIN(" + JOIN_INDEX + ") ASC")
|
||||
Flowable<List<PlaylistStreamEntry>> getStreamsWithoutDuplicates(long playlistId);
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT " + PLAYLIST_TABLE + "." + PLAYLIST_ID + ", "
|
||||
+ PLAYLIST_NAME + ", "
|
||||
|
||||
+ " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = "
|
||||
+ PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'"
|
||||
+ " ELSE (SELECT " + STREAM_THUMBNAIL_URL
|
||||
+ " FROM " + STREAM_TABLE
|
||||
+ " WHERE " + STREAM_TABLE + "." + STREAM_ID + " = " + PLAYLIST_THUMBNAIL_STREAM_ID
|
||||
+ " ) END AS " + PLAYLIST_THUMBNAIL_URL + ", "
|
||||
|
||||
+ "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT + ", "
|
||||
+ "COALESCE(SUM(" + STREAM_URL + " = :streamUrl), 0) AS "
|
||||
+ PLAYLIST_TIMES_STREAM_IS_CONTAINED
|
||||
|
||||
+ " FROM " + PLAYLIST_TABLE
|
||||
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
|
||||
+ " ON " + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID
|
||||
+ " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID
|
||||
|
||||
+ " LEFT JOIN " + STREAM_TABLE
|
||||
+ " ON " + STREAM_TABLE + "." + STREAM_ID + " = " + JOIN_STREAM_ID
|
||||
+ " AND :streamUrl = :streamUrl"
|
||||
|
||||
+ " GROUP BY " + JOIN_PLAYLIST_ID
|
||||
+ " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC")
|
||||
Flowable<List<PlaylistMetadataEntry>> getPlaylistMetadata();
|
||||
Flowable<List<PlaylistDuplicatesEntry>> getPlaylistDuplicatesMetadata(String streamUrl);
|
||||
}
|
||||
|
||||
@@ -8,13 +8,22 @@ 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;
|
||||
|
||||
@Entity(tableName = PLAYLIST_TABLE,
|
||||
indices = {@Index(value = {PLAYLIST_NAME})})
|
||||
public class PlaylistEntity {
|
||||
|
||||
public static final String DEFAULT_THUMBNAIL = "drawable://"
|
||||
+ R.drawable.placeholder_thumbnail_playlist;
|
||||
public static final long DEFAULT_THUMBNAIL_ID = -1;
|
||||
|
||||
public static final String PLAYLIST_TABLE = "playlists";
|
||||
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_THUMBNAIL_PERMANENT = "is_thumbnail_permanent";
|
||||
public static final String PLAYLIST_THUMBNAIL_STREAM_ID = "thumbnail_stream_id";
|
||||
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = PLAYLIST_ID)
|
||||
@@ -23,12 +32,17 @@ public class PlaylistEntity {
|
||||
@ColumnInfo(name = PLAYLIST_NAME)
|
||||
private String name;
|
||||
|
||||
@ColumnInfo(name = PLAYLIST_THUMBNAIL_URL)
|
||||
private String thumbnailUrl;
|
||||
@ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT)
|
||||
private boolean isThumbnailPermanent;
|
||||
|
||||
public PlaylistEntity(final String name, final String thumbnailUrl) {
|
||||
@ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID)
|
||||
private long thumbnailStreamId;
|
||||
|
||||
public PlaylistEntity(final String name, final boolean isThumbnailPermanent,
|
||||
final long thumbnailStreamId) {
|
||||
this.name = name;
|
||||
this.thumbnailUrl = thumbnailUrl;
|
||||
this.isThumbnailPermanent = isThumbnailPermanent;
|
||||
this.thumbnailStreamId = thumbnailStreamId;
|
||||
}
|
||||
|
||||
public long getUid() {
|
||||
@@ -47,11 +61,20 @@ public class PlaylistEntity {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getThumbnailUrl() {
|
||||
return thumbnailUrl;
|
||||
public long getThumbnailStreamId() {
|
||||
return thumbnailStreamId;
|
||||
}
|
||||
|
||||
public void setThumbnailUrl(final String thumbnailUrl) {
|
||||
this.thumbnailUrl = thumbnailUrl;
|
||||
public void setThumbnailStreamId(final long thumbnailStreamId) {
|
||||
this.thumbnailStreamId = thumbnailStreamId;
|
||||
}
|
||||
|
||||
public boolean getIsThumbnailPermanent() {
|
||||
return isThumbnailPermanent;
|
||||
}
|
||||
|
||||
public void setIsThumbnailPermanent(final boolean isThumbnailSet) {
|
||||
this.isThumbnailPermanent = isThumbnailSet;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ import org.schabi.newpipe.database.BasicDAO
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM
|
||||
import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM
|
||||
import org.schabi.newpipe.util.StreamTypeUtil
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@Dao
|
||||
@@ -39,6 +38,9 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
internal abstract fun silentInsertAllInternal(streams: List<StreamEntity>): List<Long>
|
||||
|
||||
@Query("SELECT COUNT(*) != 0 FROM streams WHERE url = :url AND service_id = :serviceId")
|
||||
internal abstract fun exists(serviceId: Int, url: String): Boolean
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration
|
||||
@@ -88,8 +90,7 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
|
||||
?: throw IllegalStateException("Stream cannot be null just after insertion.")
|
||||
newerStream.uid = existentMinimalStream.uid
|
||||
|
||||
val isNewerStreamLive = newerStream.streamType == AUDIO_LIVE_STREAM || newerStream.streamType == LIVE_STREAM
|
||||
if (!isNewerStreamLive) {
|
||||
if (!StreamTypeUtil.isLiveStream(newerStream.streamType)) {
|
||||
|
||||
// Use the existent upload date if the newer stream does not have a better precision
|
||||
// (i.e. is an approximation). This is done to prevent unnecessary changes.
|
||||
|
||||
@@ -30,7 +30,7 @@ public class StreamStateEntity {
|
||||
/**
|
||||
* Playback state will not be saved, if playback time is less than this threshold (5000ms = 5s).
|
||||
*/
|
||||
private static final long PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS = 5000;
|
||||
public static final long PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS = 5000;
|
||||
|
||||
/**
|
||||
* Stream will be considered finished if the playback time left exceeds this threshold
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.schabi.newpipe.database.subscription;
|
||||
|
||||
import androidx.annotation.IntDef;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
|
||||
@IntDef({NotificationMode.DISABLED, NotificationMode.ENABLED})
|
||||
@Retention(RetentionPolicy.SOURCE)
|
||||
public @interface NotificationMode {
|
||||
|
||||
int DISABLED = 0;
|
||||
int ENABLED = 1;
|
||||
//other values reserved for the future
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||
import androidx.room.Transaction
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
@@ -31,6 +32,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
|
||||
)
|
||||
abstract fun getSubscriptionsFiltered(filter: String): Flowable<List<SubscriptionEntity>>
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM subscriptions s
|
||||
@@ -47,6 +49,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
|
||||
currentGroupId: Long
|
||||
): Flowable<List<SubscriptionEntity>>
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM subscriptions s
|
||||
|
||||
@@ -26,6 +26,7 @@ public class SubscriptionEntity {
|
||||
public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url";
|
||||
public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
|
||||
public static final String SUBSCRIPTION_DESCRIPTION = "description";
|
||||
public static final String SUBSCRIPTION_NOTIFICATION_MODE = "notification_mode";
|
||||
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
private long uid = 0;
|
||||
@@ -48,6 +49,9 @@ public class SubscriptionEntity {
|
||||
@ColumnInfo(name = SUBSCRIPTION_DESCRIPTION)
|
||||
private String description;
|
||||
|
||||
@ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE)
|
||||
private int notificationMode;
|
||||
|
||||
@Ignore
|
||||
public static SubscriptionEntity from(@NonNull final ChannelInfo info) {
|
||||
final SubscriptionEntity result = new SubscriptionEntity();
|
||||
@@ -114,6 +118,15 @@ public class SubscriptionEntity {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
@NotificationMode
|
||||
public int getNotificationMode() {
|
||||
return notificationMode;
|
||||
}
|
||||
|
||||
public void setNotificationMode(@NotificationMode final int notificationMode) {
|
||||
this.notificationMode = notificationMode;
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public void setData(final String n, final String au, final String d, final Long sc) {
|
||||
this.setName(n);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package org.schabi.newpipe.download;
|
||||
|
||||
import static org.schabi.newpipe.extractor.stream.DeliveryMethod.PROGRESSIVE_HTTP;
|
||||
import static org.schabi.newpipe.util.ListHelper.getStreamsOfSpecifiedDelivery;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
@@ -13,7 +17,6 @@ import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
import android.util.SparseArray;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
@@ -32,6 +35,7 @@ import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.view.menu.ActionMenuItemView;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.collection.SparseArrayCompat;
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
import androidx.preference.PreferenceManager;
|
||||
@@ -61,15 +65,19 @@ import org.schabi.newpipe.util.FilenameUtils;
|
||||
import org.schabi.newpipe.util.ListHelper;
|
||||
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.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;
|
||||
import java.util.Optional;
|
||||
|
||||
import icepick.Icepick;
|
||||
import icepick.State;
|
||||
@@ -81,8 +89,6 @@ import us.shandian.giga.service.DownloadManagerService;
|
||||
import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder;
|
||||
import us.shandian.giga.service.MissionState;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
public class DownloadDialog extends DialogFragment
|
||||
implements RadioGroup.OnCheckedChangeListener, AdapterView.OnItemSelectedListener {
|
||||
private static final String TAG = "DialogFragment";
|
||||
@@ -91,17 +97,19 @@ public class DownloadDialog extends DialogFragment
|
||||
@State
|
||||
StreamInfo currentInfo;
|
||||
@State
|
||||
StreamSizeWrapper<AudioStream> wrappedAudioStreams = StreamSizeWrapper.empty();
|
||||
StreamSizeWrapper<VideoStream> wrappedVideoStreams;
|
||||
@State
|
||||
StreamSizeWrapper<VideoStream> wrappedVideoStreams = StreamSizeWrapper.empty();
|
||||
StreamSizeWrapper<SubtitlesStream> wrappedSubtitleStreams;
|
||||
@State
|
||||
StreamSizeWrapper<SubtitlesStream> wrappedSubtitleStreams = StreamSizeWrapper.empty();
|
||||
AudioTracksWrapper wrappedAudioTracks;
|
||||
@State
|
||||
int selectedVideoIndex = 0;
|
||||
int selectedAudioTrackIndex;
|
||||
@State
|
||||
int selectedAudioIndex = 0;
|
||||
int selectedVideoIndex; // set in the constructor
|
||||
@State
|
||||
int selectedSubtitleIndex = 0;
|
||||
int selectedAudioIndex = 0; // default to the first item
|
||||
@State
|
||||
int selectedSubtitleIndex = 0; // default to the first item
|
||||
|
||||
@Nullable
|
||||
private OnDismissListener onDismissListener = null;
|
||||
@@ -113,6 +121,7 @@ public class DownloadDialog extends DialogFragment
|
||||
private Context context;
|
||||
private boolean askForSavePath;
|
||||
|
||||
private AudioTrackAdapter audioTrackAdapter;
|
||||
private StreamItemAdapter<AudioStream, Stream> audioStreamsAdapter;
|
||||
private StreamItemAdapter<VideoStream, AudioStream> videoStreamsAdapter;
|
||||
private StreamItemAdapter<SubtitlesStream, Stream> subtitleStreamsAdapter;
|
||||
@@ -142,77 +151,57 @@ public class DownloadDialog extends DialogFragment
|
||||
// Instance creation
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
public static DownloadDialog newInstance(final StreamInfo info) {
|
||||
final DownloadDialog dialog = new DownloadDialog();
|
||||
dialog.setInfo(info);
|
||||
return dialog;
|
||||
public DownloadDialog() {
|
||||
// Just an empty default no-arg ctor to keep Fragment.instantiate() happy
|
||||
// otherwise InstantiationException will be thrown when fragment is recreated
|
||||
// TODO: Maybe use a custom FragmentFactory instead?
|
||||
}
|
||||
|
||||
public static DownloadDialog newInstance(final Context context, final StreamInfo info) {
|
||||
final ArrayList<VideoStream> streamsList = new ArrayList<>(ListHelper
|
||||
.getSortedStreamVideosList(context, info.getVideoStreams(),
|
||||
info.getVideoOnlyStreams(), false));
|
||||
final int selectedStreamIndex = ListHelper.getDefaultResolutionIndex(context, streamsList);
|
||||
|
||||
final DownloadDialog instance = newInstance(info);
|
||||
instance.setVideoStreams(streamsList);
|
||||
instance.setSelectedVideoStream(selectedStreamIndex);
|
||||
instance.setAudioStreams(info.getAudioStreams());
|
||||
instance.setSubtitleStreams(info.getSubtitles());
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Setters
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private void setInfo(final StreamInfo info) {
|
||||
/**
|
||||
* Create a new download dialog with the video, audio and subtitle streams from the provided
|
||||
* stream info. Video streams and video-only streams will be put into a single list menu,
|
||||
* sorted according to their resolution and the default video resolution will be selected.
|
||||
*
|
||||
* @param context the context to use just to obtain preferences and strings (will not be stored)
|
||||
* @param info the info from which to obtain downloadable streams and other info (e.g. title)
|
||||
*/
|
||||
public DownloadDialog(@NonNull final Context context, @NonNull final StreamInfo info) {
|
||||
this.currentInfo = info;
|
||||
|
||||
final List<AudioStream> audioStreams =
|
||||
getStreamsOfSpecifiedDelivery(info.getAudioStreams(), PROGRESSIVE_HTTP);
|
||||
final List<List<AudioStream>> groupedAudioStreams =
|
||||
ListHelper.getGroupedAudioStreams(context, audioStreams);
|
||||
this.wrappedAudioTracks = new AudioTracksWrapper(groupedAudioStreams, context);
|
||||
this.selectedAudioTrackIndex =
|
||||
ListHelper.getDefaultAudioTrackGroup(context, groupedAudioStreams);
|
||||
|
||||
// TODO: Adapt this code when the downloader support other types of stream deliveries
|
||||
final List<VideoStream> videoStreams = ListHelper.getSortedStreamVideosList(
|
||||
context,
|
||||
getStreamsOfSpecifiedDelivery(info.getVideoStreams(), PROGRESSIVE_HTTP),
|
||||
getStreamsOfSpecifiedDelivery(info.getVideoOnlyStreams(), PROGRESSIVE_HTTP),
|
||||
false,
|
||||
// If there are multiple languages available, prefer streams without audio
|
||||
// to allow language selection
|
||||
wrappedAudioTracks.size() > 1
|
||||
);
|
||||
|
||||
this.wrappedVideoStreams = new StreamSizeWrapper<>(videoStreams, context);
|
||||
this.wrappedSubtitleStreams = new StreamSizeWrapper<>(
|
||||
getStreamsOfSpecifiedDelivery(info.getSubtitles(), PROGRESSIVE_HTTP), context);
|
||||
|
||||
this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams);
|
||||
}
|
||||
|
||||
public void setAudioStreams(final List<AudioStream> audioStreams) {
|
||||
setAudioStreams(new StreamSizeWrapper<>(audioStreams, getContext()));
|
||||
}
|
||||
|
||||
public void setAudioStreams(final StreamSizeWrapper<AudioStream> was) {
|
||||
this.wrappedAudioStreams = was;
|
||||
}
|
||||
|
||||
public void setVideoStreams(final List<VideoStream> videoStreams) {
|
||||
setVideoStreams(new StreamSizeWrapper<>(videoStreams, getContext()));
|
||||
}
|
||||
|
||||
public void setVideoStreams(final StreamSizeWrapper<VideoStream> wvs) {
|
||||
this.wrappedVideoStreams = wvs;
|
||||
}
|
||||
|
||||
public void setSubtitleStreams(final List<SubtitlesStream> subtitleStreams) {
|
||||
setSubtitleStreams(new StreamSizeWrapper<>(subtitleStreams, getContext()));
|
||||
}
|
||||
|
||||
public void setSubtitleStreams(
|
||||
final StreamSizeWrapper<SubtitlesStream> wss) {
|
||||
this.wrappedSubtitleStreams = wss;
|
||||
}
|
||||
|
||||
public void setSelectedVideoStream(final int svi) {
|
||||
this.selectedVideoIndex = svi;
|
||||
}
|
||||
|
||||
public void setSelectedAudioStream(final int sai) {
|
||||
this.selectedAudioIndex = sai;
|
||||
}
|
||||
|
||||
public void setSelectedSubtitleStream(final int ssi) {
|
||||
this.selectedSubtitleIndex = ssi;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param onDismissListener the listener to call in {@link #onDismiss(DialogInterface)}
|
||||
*/
|
||||
public void setOnDismissListener(@Nullable final OnDismissListener onDismissListener) {
|
||||
this.onDismissListener = onDismissListener;
|
||||
}
|
||||
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Android lifecycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@@ -236,30 +225,9 @@ public class DownloadDialog extends DialogFragment
|
||||
setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(context));
|
||||
Icepick.restoreInstanceState(this, savedInstanceState);
|
||||
|
||||
final SparseArray<SecondaryStreamHelper<AudioStream>> secondaryStreams
|
||||
= new SparseArray<>(4);
|
||||
final List<VideoStream> videoStreams = wrappedVideoStreams.getStreamsList();
|
||||
|
||||
for (int i = 0; i < videoStreams.size(); i++) {
|
||||
if (!videoStreams.get(i).isVideoOnly()) {
|
||||
continue;
|
||||
}
|
||||
final AudioStream audioStream = SecondaryStreamHelper
|
||||
.getAudioStreamFor(wrappedAudioStreams.getStreamsList(), videoStreams.get(i));
|
||||
|
||||
if (audioStream != null) {
|
||||
secondaryStreams
|
||||
.append(i, new SecondaryStreamHelper<>(wrappedAudioStreams, audioStream));
|
||||
} else if (DEBUG) {
|
||||
Log.w(TAG, "No audio stream candidates for video format "
|
||||
+ videoStreams.get(i).getFormat().name());
|
||||
}
|
||||
}
|
||||
|
||||
this.videoStreamsAdapter = new StreamItemAdapter<>(context, wrappedVideoStreams,
|
||||
secondaryStreams);
|
||||
this.audioStreamsAdapter = new StreamItemAdapter<>(context, wrappedAudioStreams);
|
||||
this.subtitleStreamsAdapter = new StreamItemAdapter<>(context, wrappedSubtitleStreams);
|
||||
this.audioTrackAdapter = new AudioTrackAdapter(wrappedAudioTracks);
|
||||
this.subtitleStreamsAdapter = new StreamItemAdapter<>(wrappedSubtitleStreams);
|
||||
updateSecondaryStreams();
|
||||
|
||||
final Intent intent = new Intent(context, DownloadManagerService.class);
|
||||
context.startService(intent);
|
||||
@@ -286,8 +254,42 @@ public class DownloadDialog extends DialogFragment
|
||||
}, Context.BIND_AUTO_CREATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the displayed video streams based on the selected audio track.
|
||||
*/
|
||||
private void updateSecondaryStreams() {
|
||||
final StreamSizeWrapper<AudioStream> audioStreams = getWrappedAudioStreams();
|
||||
final var secondaryStreams = new SparseArrayCompat<SecondaryStreamHelper<AudioStream>>(4);
|
||||
final List<VideoStream> videoStreams = wrappedVideoStreams.getStreamsList();
|
||||
wrappedVideoStreams.resetSizes();
|
||||
|
||||
for (int i = 0; i < videoStreams.size(); i++) {
|
||||
if (!videoStreams.get(i).isVideoOnly()) {
|
||||
continue;
|
||||
}
|
||||
final AudioStream audioStream = SecondaryStreamHelper
|
||||
.getAudioStreamFor(audioStreams.getStreamsList(), videoStreams.get(i));
|
||||
|
||||
if (audioStream != null) {
|
||||
secondaryStreams.append(i, new SecondaryStreamHelper<>(audioStreams, audioStream));
|
||||
} else if (DEBUG) {
|
||||
final MediaFormat mediaFormat = videoStreams.get(i).getFormat();
|
||||
if (mediaFormat != null) {
|
||||
Log.w(TAG, "No audio stream candidates for video format "
|
||||
+ mediaFormat.name());
|
||||
} else {
|
||||
Log.w(TAG, "No audio stream candidates for unknown video format");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.videoStreamsAdapter = new StreamItemAdapter<>(wrappedVideoStreams, secondaryStreams);
|
||||
this.audioStreamsAdapter = new StreamItemAdapter<>(audioStreams);
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container,
|
||||
public View onCreateView(@NonNull final LayoutInflater inflater,
|
||||
final ViewGroup container,
|
||||
final Bundle savedInstanceState) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreateView() called with: "
|
||||
@@ -298,19 +300,20 @@ public class DownloadDialog extends DialogFragment
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) {
|
||||
public void onViewCreated(@NonNull final View view,
|
||||
@Nullable final Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
dialogBinding = DownloadDialogBinding.bind(view);
|
||||
|
||||
dialogBinding.fileName.setText(FilenameUtils.createFilename(getContext(),
|
||||
currentInfo.getName()));
|
||||
selectedAudioIndex = ListHelper
|
||||
.getDefaultAudioFormat(getContext(), currentInfo.getAudioStreams());
|
||||
selectedAudioIndex = ListHelper.getDefaultAudioFormat(getContext(),
|
||||
getWrappedAudioStreams().getStreamsList());
|
||||
|
||||
selectedSubtitleIndex = getSubtitleIndexBy(subtitleStreamsAdapter.getAll());
|
||||
|
||||
dialogBinding.qualitySpinner.setOnItemSelectedListener(this);
|
||||
|
||||
dialogBinding.audioTrackSpinner.setOnItemSelectedListener(this);
|
||||
dialogBinding.videoAudioGroup.setOnCheckedChangeListener(this);
|
||||
|
||||
initToolbar(dialogBinding.toolbarLayout.toolbar);
|
||||
@@ -321,21 +324,16 @@ public class DownloadDialog extends DialogFragment
|
||||
final int threads = prefs.getInt(getString(R.string.default_download_threads), 3);
|
||||
dialogBinding.threadsCount.setText(String.valueOf(threads));
|
||||
dialogBinding.threads.setProgress(threads - 1);
|
||||
dialogBinding.threads.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
|
||||
dialogBinding.threads.setOnSeekBarChangeListener(new SimpleOnSeekBarChangeListener() {
|
||||
@Override
|
||||
public void onProgressChanged(final SeekBar seekbar, final int progress,
|
||||
public void onProgressChanged(@NonNull final SeekBar seekbar,
|
||||
final int progress,
|
||||
final boolean fromUser) {
|
||||
final int newProgress = progress + 1;
|
||||
prefs.edit().putInt(getString(R.string.default_download_threads), newProgress)
|
||||
.apply();
|
||||
dialogBinding.threadsCount.setText(String.valueOf(newProgress));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStartTrackingTouch(final SeekBar p1) { }
|
||||
|
||||
@Override
|
||||
public void onStopTrackingTouch(final SeekBar p1) { }
|
||||
});
|
||||
|
||||
fetchStreamsSize();
|
||||
@@ -407,7 +405,7 @@ public class DownloadDialog extends DialogFragment
|
||||
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
|
||||
"Downloading video stream size",
|
||||
currentInfo.getServiceId()))));
|
||||
disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedAudioStreams)
|
||||
disposables.add(StreamSizeWrapper.fetchSizeForWrapper(getWrappedAudioStreams())
|
||||
.subscribe(result -> {
|
||||
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
|
||||
== R.id.audio_button) {
|
||||
@@ -429,14 +427,28 @@ public class DownloadDialog extends DialogFragment
|
||||
currentInfo.getServiceId()))));
|
||||
}
|
||||
|
||||
private void setupAudioTrackSpinner() {
|
||||
if (getContext() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dialogBinding.audioTrackSpinner.setAdapter(audioTrackAdapter);
|
||||
dialogBinding.audioTrackSpinner.setSelection(selectedAudioTrackIndex);
|
||||
}
|
||||
|
||||
private void setupAudioSpinner() {
|
||||
if (getContext() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dialogBinding.qualitySpinner.setAdapter(audioStreamsAdapter);
|
||||
dialogBinding.qualitySpinner.setSelection(selectedAudioIndex);
|
||||
dialogBinding.qualitySpinner.setVisibility(View.GONE);
|
||||
setRadioButtonsState(true);
|
||||
dialogBinding.audioStreamSpinner.setAdapter(audioStreamsAdapter);
|
||||
dialogBinding.audioStreamSpinner.setSelection(selectedAudioIndex);
|
||||
dialogBinding.audioStreamSpinner.setVisibility(View.VISIBLE);
|
||||
dialogBinding.audioTrackSpinner.setVisibility(
|
||||
wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE);
|
||||
dialogBinding.audioTrackPresentInVideoText.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private void setupVideoSpinner() {
|
||||
@@ -446,7 +458,19 @@ public class DownloadDialog extends DialogFragment
|
||||
|
||||
dialogBinding.qualitySpinner.setAdapter(videoStreamsAdapter);
|
||||
dialogBinding.qualitySpinner.setSelection(selectedVideoIndex);
|
||||
dialogBinding.qualitySpinner.setVisibility(View.VISIBLE);
|
||||
setRadioButtonsState(true);
|
||||
dialogBinding.audioStreamSpinner.setVisibility(View.GONE);
|
||||
onVideoStreamSelected();
|
||||
}
|
||||
|
||||
private void onVideoStreamSelected() {
|
||||
final boolean isVideoOnly = videoStreamsAdapter.getItem(selectedVideoIndex).isVideoOnly();
|
||||
|
||||
dialogBinding.audioTrackSpinner.setVisibility(
|
||||
isVideoOnly && wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE);
|
||||
dialogBinding.audioTrackPresentInVideoText.setVisibility(
|
||||
!isVideoOnly && wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
private void setupSubtitleSpinner() {
|
||||
@@ -456,7 +480,11 @@ public class DownloadDialog extends DialogFragment
|
||||
|
||||
dialogBinding.qualitySpinner.setAdapter(subtitleStreamsAdapter);
|
||||
dialogBinding.qualitySpinner.setSelection(selectedSubtitleIndex);
|
||||
dialogBinding.qualitySpinner.setVisibility(View.VISIBLE);
|
||||
setRadioButtonsState(true);
|
||||
dialogBinding.audioStreamSpinner.setVisibility(View.GONE);
|
||||
dialogBinding.audioTrackSpinner.setVisibility(View.GONE);
|
||||
dialogBinding.audioTrackPresentInVideoText.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
|
||||
@@ -474,7 +502,7 @@ public class DownloadDialog extends DialogFragment
|
||||
result, getString(R.string.download_path_video_key), DownloadManager.TAG_VIDEO);
|
||||
}
|
||||
|
||||
private void requestDownloadSaveAsResult(final ActivityResult result) {
|
||||
private void requestDownloadSaveAsResult(@NonNull final ActivityResult result) {
|
||||
if (result.getResultCode() != Activity.RESULT_OK) {
|
||||
return;
|
||||
}
|
||||
@@ -491,8 +519,8 @@ public class DownloadDialog extends DialogFragment
|
||||
return;
|
||||
}
|
||||
|
||||
final DocumentFile docFile
|
||||
= DocumentFile.fromSingleUri(context, result.getData().getData());
|
||||
final DocumentFile docFile = DocumentFile.fromSingleUri(context,
|
||||
result.getData().getData());
|
||||
if (docFile == null) {
|
||||
showFailedDialog(R.string.general_error);
|
||||
return;
|
||||
@@ -503,7 +531,7 @@ public class DownloadDialog extends DialogFragment
|
||||
docFile.getType());
|
||||
}
|
||||
|
||||
private void requestDownloadPickFolderResult(final ActivityResult result,
|
||||
private void requestDownloadPickFolderResult(@NonNull final ActivityResult result,
|
||||
final String key,
|
||||
final String tag) {
|
||||
if (result.getResultCode() != Activity.RESULT_OK) {
|
||||
@@ -523,12 +551,11 @@ public class DownloadDialog extends DialogFragment
|
||||
StoredDirectoryHelper.PERMISSION_FLAGS);
|
||||
}
|
||||
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
.putString(key, uri.toString()).apply();
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit().putString(key,
|
||||
uri.toString()).apply();
|
||||
|
||||
try {
|
||||
final StoredDirectoryHelper mainStorage
|
||||
= new StoredDirectoryHelper(context, uri, tag);
|
||||
final StoredDirectoryHelper mainStorage = new StoredDirectoryHelper(context, uri, tag);
|
||||
checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp),
|
||||
filenameTmp, mimeTmp);
|
||||
} catch (final IOException e) {
|
||||
@@ -566,23 +593,71 @@ public class DownloadDialog extends DialogFragment
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onItemSelected(final AdapterView<?> parent, final View view,
|
||||
final int position, final long id) {
|
||||
public void onItemSelected(final AdapterView<?> parent,
|
||||
final View view,
|
||||
final int position,
|
||||
final long id) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onItemSelected() called with: "
|
||||
+ "parent = [" + parent + "], view = [" + view + "], "
|
||||
+ "position = [" + position + "], id = [" + id + "]");
|
||||
}
|
||||
switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) {
|
||||
case R.id.audio_button:
|
||||
|
||||
switch (parent.getId()) {
|
||||
case R.id.quality_spinner:
|
||||
switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) {
|
||||
case R.id.video_button:
|
||||
selectedVideoIndex = position;
|
||||
onVideoStreamSelected();
|
||||
break;
|
||||
case R.id.subtitle_button:
|
||||
selectedSubtitleIndex = position;
|
||||
break;
|
||||
}
|
||||
onItemSelectedSetFileName();
|
||||
break;
|
||||
case R.id.audio_track_spinner:
|
||||
final boolean trackChanged = selectedAudioTrackIndex != position;
|
||||
selectedAudioTrackIndex = position;
|
||||
if (trackChanged) {
|
||||
updateSecondaryStreams();
|
||||
fetchStreamsSize();
|
||||
}
|
||||
break;
|
||||
case R.id.audio_stream_spinner:
|
||||
selectedAudioIndex = position;
|
||||
break;
|
||||
case R.id.video_button:
|
||||
selectedVideoIndex = position;
|
||||
break;
|
||||
case R.id.subtitle_button:
|
||||
selectedSubtitleIndex = position;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void onItemSelectedSetFileName() {
|
||||
final String fileName = FilenameUtils.createFilename(getContext(), currentInfo.getName());
|
||||
final String prevFileName = Optional.ofNullable(dialogBinding.fileName.getText())
|
||||
.map(Object::toString)
|
||||
.orElse("");
|
||||
|
||||
if (prevFileName.isEmpty()
|
||||
|| prevFileName.equals(fileName)
|
||||
|| prevFileName.startsWith(getString(R.string.caption_file_name, fileName, ""))) {
|
||||
// only update the file name field if it was not edited by the user
|
||||
|
||||
switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) {
|
||||
case R.id.audio_button:
|
||||
case R.id.video_button:
|
||||
if (!prevFileName.equals(fileName)) {
|
||||
// since the user might have switched between audio and video, the correct
|
||||
// text might already be in place, so avoid resetting the cursor position
|
||||
dialogBinding.fileName.setText(fileName);
|
||||
}
|
||||
break;
|
||||
|
||||
case R.id.subtitle_button:
|
||||
final String setSubtitleLanguageCode = subtitleStreamsAdapter
|
||||
.getItem(selectedSubtitleIndex).getLanguageTag();
|
||||
// this will reset the cursor position, which is bad UX, but it can't be avoided
|
||||
dialogBinding.fileName.setText(getString(
|
||||
R.string.caption_file_name, fileName, setSubtitleLanguageCode));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,19 +672,22 @@ public class DownloadDialog extends DialogFragment
|
||||
|
||||
protected void setupDownloadOptions() {
|
||||
setRadioButtonsState(false);
|
||||
setupAudioTrackSpinner();
|
||||
|
||||
final boolean isVideoStreamsAvailable = videoStreamsAdapter.getCount() > 0;
|
||||
final boolean isAudioStreamsAvailable = audioStreamsAdapter.getCount() > 0;
|
||||
final boolean isSubtitleStreamsAvailable = subtitleStreamsAdapter.getCount() > 0;
|
||||
|
||||
dialogBinding.audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE : View.GONE);
|
||||
dialogBinding.videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE : View.GONE);
|
||||
dialogBinding.audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE
|
||||
: View.GONE);
|
||||
dialogBinding.videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE
|
||||
: View.GONE);
|
||||
dialogBinding.subtitleButton.setVisibility(isSubtitleStreamsAvailable
|
||||
? View.VISIBLE : View.GONE);
|
||||
|
||||
prefs = PreferenceManager.getDefaultSharedPreferences(requireContext());
|
||||
final String defaultMedia = prefs.getString(getString(R.string.last_used_download_type),
|
||||
getString(R.string.last_download_type_video_key));
|
||||
getString(R.string.last_download_type_video_key));
|
||||
|
||||
if (isVideoStreamsAvailable
|
||||
&& (defaultMedia.equals(getString(R.string.last_download_type_video_key)))) {
|
||||
@@ -645,7 +723,14 @@ public class DownloadDialog extends DialogFragment
|
||||
dialogBinding.subtitleButton.setEnabled(enabled);
|
||||
}
|
||||
|
||||
private int getSubtitleIndexBy(final List<SubtitlesStream> streams) {
|
||||
private StreamSizeWrapper<AudioStream> getWrappedAudioStreams() {
|
||||
if (selectedAudioTrackIndex < 0 || selectedAudioTrackIndex > wrappedAudioTracks.size()) {
|
||||
return StreamSizeWrapper.empty();
|
||||
}
|
||||
return wrappedAudioTracks.getTracksList().get(selectedAudioTrackIndex);
|
||||
}
|
||||
|
||||
private int getSubtitleIndexBy(@NonNull final List<SubtitlesStream> streams) {
|
||||
final Localization preferredLocalization = NewPipe.getPreferredLocalization();
|
||||
|
||||
int candidate = 0;
|
||||
@@ -671,8 +756,10 @@ public class DownloadDialog extends DialogFragment
|
||||
return candidate;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private String getNameEditText() {
|
||||
final String str = dialogBinding.fileName.getText().toString().trim();
|
||||
final String str = Objects.requireNonNull(dialogBinding.fileName.getText()).toString()
|
||||
.trim();
|
||||
|
||||
return FilenameUtils.createFilename(context, str.isEmpty() ? currentInfo.getName() : str);
|
||||
}
|
||||
@@ -683,17 +770,12 @@ public class DownloadDialog extends DialogFragment
|
||||
.setTitle(R.string.general_error)
|
||||
.setMessage(msg)
|
||||
.setNegativeButton(getString(R.string.ok), null)
|
||||
.create()
|
||||
.show();
|
||||
}
|
||||
|
||||
private void launchDirectoryPicker(final ActivityResultLauncher<Intent> launcher) {
|
||||
NoFileManagerSafeGuard.launchSafe(
|
||||
launcher,
|
||||
StoredDirectoryHelper.getPicker(context),
|
||||
TAG,
|
||||
context
|
||||
);
|
||||
NoFileManagerSafeGuard.launchSafe(launcher, StoredDirectoryHelper.getPicker(context), TAG,
|
||||
context);
|
||||
}
|
||||
|
||||
private void prepareSelectedDownload() {
|
||||
@@ -714,7 +796,7 @@ public class DownloadDialog extends DialogFragment
|
||||
if (format == MediaFormat.WEBMA_OPUS) {
|
||||
mimeTmp = "audio/ogg";
|
||||
filenameTmp += "opus";
|
||||
} else {
|
||||
} else if (format != null) {
|
||||
mimeTmp = format.mimeType;
|
||||
filenameTmp += format.suffix;
|
||||
}
|
||||
@@ -723,22 +805,30 @@ public class DownloadDialog extends DialogFragment
|
||||
selectedMediaType = getString(R.string.last_download_type_video_key);
|
||||
mainStorage = mainStorageVideo;
|
||||
format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat();
|
||||
mimeTmp = format.mimeType;
|
||||
filenameTmp += format.suffix;
|
||||
if (format != null) {
|
||||
mimeTmp = format.mimeType;
|
||||
filenameTmp += format.suffix;
|
||||
}
|
||||
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();
|
||||
mimeTmp = format.mimeType;
|
||||
filenameTmp += (format == MediaFormat.TTML ? MediaFormat.SRT : format).suffix;
|
||||
if (format != null) {
|
||||
mimeTmp = format.mimeType;
|
||||
}
|
||||
|
||||
if (format == MediaFormat.TTML) {
|
||||
filenameTmp += MediaFormat.SRT.suffix;
|
||||
} else if (format != null) {
|
||||
filenameTmp += format.suffix;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new RuntimeException("No stream selected");
|
||||
}
|
||||
|
||||
if (!askForSavePath
|
||||
&& (mainStorage == null
|
||||
if (!askForSavePath && (mainStorage == null
|
||||
|| mainStorage.isDirect() == NewPipeSettings.useStorageAccessFramework(context)
|
||||
|| mainStorage.isInvalidSafStorage())) {
|
||||
// Pick new download folder if one of:
|
||||
@@ -772,18 +862,16 @@ public class DownloadDialog extends DialogFragment
|
||||
initialPath = Uri.parse(initialSavePath.getAbsolutePath());
|
||||
}
|
||||
|
||||
NoFileManagerSafeGuard.launchSafe(
|
||||
requestDownloadSaveAsLauncher,
|
||||
StoredFileHelper.getNewPicker(context, filenameTmp, mimeTmp, initialPath),
|
||||
TAG,
|
||||
context
|
||||
);
|
||||
NoFileManagerSafeGuard.launchSafe(requestDownloadSaveAsLauncher,
|
||||
StoredFileHelper.getNewPicker(context, filenameTmp, mimeTmp, initialPath), TAG,
|
||||
context);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// check for existing file with the same name
|
||||
checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp, mimeTmp);
|
||||
checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp,
|
||||
mimeTmp);
|
||||
|
||||
// remember the last media type downloaded by the user
|
||||
prefs.edit().putString(getString(R.string.last_used_download_type), selectedMediaType)
|
||||
@@ -791,7 +879,8 @@ public class DownloadDialog extends DialogFragment
|
||||
}
|
||||
|
||||
private void checkSelectedDownload(final StoredDirectoryHelper mainStorage,
|
||||
final Uri targetFile, final String filename,
|
||||
final Uri targetFile,
|
||||
final String filename,
|
||||
final String mime) {
|
||||
StoredFileHelper storage;
|
||||
|
||||
@@ -893,7 +982,7 @@ public class DownloadDialog extends DialogFragment
|
||||
break;
|
||||
}
|
||||
|
||||
askDialog.create().show();
|
||||
askDialog.show();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -937,7 +1026,7 @@ public class DownloadDialog extends DialogFragment
|
||||
}
|
||||
});
|
||||
|
||||
askDialog.create().show();
|
||||
askDialog.show();
|
||||
}
|
||||
|
||||
private void continueSelectedDownload(@NonNull final StoredFileHelper storage) {
|
||||
@@ -952,7 +1041,7 @@ public class DownloadDialog extends DialogFragment
|
||||
storage.truncate();
|
||||
}
|
||||
} catch (final IOException e) {
|
||||
Log.e(TAG, "failed to truncate the file: " + storage.getUri().toString(), e);
|
||||
Log.e(TAG, "Failed to truncate the file: " + storage.getUri().toString(), e);
|
||||
showFailedDialog(R.string.overwrite_failed);
|
||||
return;
|
||||
}
|
||||
@@ -996,9 +1085,8 @@ public class DownloadDialog extends DialogFragment
|
||||
psName = Postprocessing.ALGORITHM_WEBM_MUXER;
|
||||
}
|
||||
|
||||
psArgs = null;
|
||||
final long videoSize = wrappedVideoStreams
|
||||
.getSizeInBytes((VideoStream) selectedStream);
|
||||
final long videoSize = wrappedVideoStreams.getSizeInBytes(
|
||||
(VideoStream) selectedStream);
|
||||
|
||||
// set nearLength, only, if both sizes are fetched or known. This probably
|
||||
// does not work on slow networks but is later updated in the downloader
|
||||
@@ -1014,7 +1102,7 @@ public class DownloadDialog extends DialogFragment
|
||||
|
||||
if (selectedStream.getFormat() == MediaFormat.TTML) {
|
||||
psName = Postprocessing.ALGORITHM_TTML_CONVERTER;
|
||||
psArgs = new String[]{
|
||||
psArgs = new String[] {
|
||||
selectedStream.getFormat().getSuffix(),
|
||||
"false" // ignore empty frames
|
||||
};
|
||||
@@ -1025,17 +1113,22 @@ public class DownloadDialog extends DialogFragment
|
||||
}
|
||||
|
||||
if (secondaryStream == null) {
|
||||
urls = new String[]{
|
||||
selectedStream.getUrl()
|
||||
urls = new String[] {
|
||||
selectedStream.getContent()
|
||||
};
|
||||
recoveryInfo = new MissionRecoveryInfo[]{
|
||||
recoveryInfo = new MissionRecoveryInfo[] {
|
||||
new MissionRecoveryInfo(selectedStream)
|
||||
};
|
||||
} else {
|
||||
urls = new String[]{
|
||||
selectedStream.getUrl(), secondaryStream.getUrl()
|
||||
if (secondaryStream.getDeliveryMethod() != PROGRESSIVE_HTTP) {
|
||||
throw new IllegalArgumentException("Unsupported stream delivery format"
|
||||
+ secondaryStream.getDeliveryMethod());
|
||||
}
|
||||
|
||||
urls = new String[] {
|
||||
selectedStream.getContent(), secondaryStream.getContent()
|
||||
};
|
||||
recoveryInfo = new MissionRecoveryInfo[]{new MissionRecoveryInfo(selectedStream),
|
||||
recoveryInfo = new MissionRecoveryInfo[] {new MissionRecoveryInfo(selectedStream),
|
||||
new MissionRecoveryInfo(secondaryStream)};
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Arrays;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/*
|
||||
* Created by Christian Schabesberger on 24.10.15.
|
||||
@@ -65,11 +66,11 @@ public class ErrorActivity extends AppCompatActivity {
|
||||
public static final String ERROR_EMAIL_ADDRESS = "crashreport@newpipe.schabi.org";
|
||||
public static final String ERROR_EMAIL_SUBJECT = "Exception in ";
|
||||
|
||||
public static final String ERROR_GITHUB_ISSUE_URL
|
||||
= "https://github.com/TeamNewPipe/NewPipe/issues";
|
||||
public static final String ERROR_GITHUB_ISSUE_URL =
|
||||
"https://github.com/TeamNewPipe/NewPipe/issues";
|
||||
|
||||
public static final DateTimeFormatter CURRENT_TIMESTAMP_FORMATTER
|
||||
= DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||
public static final DateTimeFormatter CURRENT_TIMESTAMP_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||
|
||||
|
||||
private ErrorInfo errorInfo;
|
||||
@@ -159,7 +160,7 @@ public class ErrorActivity extends AppCompatActivity {
|
||||
.setMessage(R.string.start_accept_privacy_policy)
|
||||
.setCancelable(false)
|
||||
.setNeutralButton(R.string.read_privacy_policy, (dialog, which) ->
|
||||
ShareUtils.openUrlInBrowser(context,
|
||||
ShareUtils.openUrlInApp(context,
|
||||
context.getString(R.string.privacy_policy_url)))
|
||||
.setPositiveButton(R.string.accept, (dialog, which) -> {
|
||||
if (action.equals("EMAIL")) { // send on email
|
||||
@@ -170,26 +171,19 @@ public class ErrorActivity extends AppCompatActivity {
|
||||
+ getString(R.string.app_name) + " "
|
||||
+ BuildConfig.VERSION_NAME)
|
||||
.putExtra(Intent.EXTRA_TEXT, buildJson());
|
||||
ShareUtils.openIntentInApp(context, i, true);
|
||||
ShareUtils.openIntentInApp(context, i);
|
||||
} else if (action.equals("GITHUB")) { // open the NewPipe issue page on GitHub
|
||||
ShareUtils.openUrlInBrowser(this, ERROR_GITHUB_ISSUE_URL, false);
|
||||
ShareUtils.openUrlInApp(this, ERROR_GITHUB_ISSUE_URL);
|
||||
}
|
||||
})
|
||||
.setNegativeButton(R.string.decline, (dialog, which) -> {
|
||||
// do nothing
|
||||
})
|
||||
.setNegativeButton(R.string.decline, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private String formErrorText(final String[] el) {
|
||||
final StringBuilder text = new StringBuilder();
|
||||
if (el != null) {
|
||||
for (final String e : el) {
|
||||
text.append("-------------------------------------\n").append(e);
|
||||
}
|
||||
}
|
||||
text.append("-------------------------------------");
|
||||
return text.toString();
|
||||
final String separator = "-------------------------------------";
|
||||
return Arrays.stream(el)
|
||||
.collect(Collectors.joining(separator + "\n", separator + "\n", separator));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,15 +7,13 @@ import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.Info
|
||||
import org.schabi.newpipe.extractor.NewPipe
|
||||
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 java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import org.schabi.newpipe.util.ServiceHelper
|
||||
|
||||
@Parcelize
|
||||
class ErrorInfo(
|
||||
@@ -65,7 +63,7 @@ class ErrorInfo(
|
||||
constructor(throwable: Throwable, userAction: UserAction, request: String) :
|
||||
this(throwable, userAction, SERVICE_NONE, request)
|
||||
constructor(throwable: Throwable, userAction: UserAction, request: String, serviceId: Int) :
|
||||
this(throwable, userAction, NewPipe.getNameOfService(serviceId), request)
|
||||
this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request)
|
||||
constructor(throwable: Throwable, userAction: UserAction, request: String, info: Info?) :
|
||||
this(throwable, userAction, getInfoServiceName(info), request)
|
||||
|
||||
@@ -73,29 +71,20 @@ class ErrorInfo(
|
||||
constructor(throwable: List<Throwable>, userAction: UserAction, request: String) :
|
||||
this(throwable, userAction, SERVICE_NONE, request)
|
||||
constructor(throwable: List<Throwable>, userAction: UserAction, request: String, serviceId: Int) :
|
||||
this(throwable, userAction, NewPipe.getNameOfService(serviceId), request)
|
||||
this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request)
|
||||
constructor(throwable: List<Throwable>, userAction: UserAction, request: String, info: Info?) :
|
||||
this(throwable, userAction, getInfoServiceName(info), request)
|
||||
|
||||
companion object {
|
||||
const val SERVICE_NONE = "none"
|
||||
|
||||
private fun getStackTrace(throwable: Throwable): String {
|
||||
StringWriter().use { stringWriter ->
|
||||
PrintWriter(stringWriter, true).use { printWriter ->
|
||||
throwable.printStackTrace(printWriter)
|
||||
return stringWriter.buffer.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
fun throwableToStringList(throwable: Throwable) = arrayOf(throwable.stackTraceToString())
|
||||
|
||||
fun throwableToStringList(throwable: Throwable) = arrayOf(getStackTrace(throwable))
|
||||
|
||||
fun throwableListToStringList(throwable: List<Throwable>) =
|
||||
Array(throwable.size) { i -> getStackTrace(throwable[i]) }
|
||||
fun throwableListToStringList(throwableList: List<Throwable>) =
|
||||
throwableList.map { it.stackTraceToString() }.toTypedArray()
|
||||
|
||||
private fun getInfoServiceName(info: Info?) =
|
||||
if (info == null) SERVICE_NONE else NewPipe.getNameOfService(info.serviceId)
|
||||
if (info == null) SERVICE_NONE else ServiceHelper.getNameOfServiceById(info.serviceId)
|
||||
|
||||
@StringRes
|
||||
private fun getMessageStringId(
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.Nullable
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
@@ -15,7 +14,6 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import org.schabi.newpipe.MainActivity
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.NewPipe
|
||||
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
||||
import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
@@ -31,6 +29,7 @@ import org.schabi.newpipe.ktx.animate
|
||||
import org.schabi.newpipe.ktx.isInterruptedCaused
|
||||
import org.schabi.newpipe.ktx.isNetworkRelated
|
||||
import org.schabi.newpipe.util.ServiceHelper
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ErrorPanelHelper(
|
||||
@@ -53,6 +52,8 @@ class ErrorPanelHelper(
|
||||
errorPanelRoot.findViewById(R.id.error_action_button)
|
||||
private val errorRetryButton: Button =
|
||||
errorPanelRoot.findViewById(R.id.error_retry_button)
|
||||
private val errorOpenInBrowserButton: Button =
|
||||
errorPanelRoot.findViewById(R.id.error_open_in_browser)
|
||||
|
||||
private var errorDisposable: Disposable? = null
|
||||
|
||||
@@ -70,6 +71,7 @@ class ErrorPanelHelper(
|
||||
errorServiceExplanationTextView.isVisible = false
|
||||
errorActionButton.isVisible = false
|
||||
errorRetryButton.isVisible = false
|
||||
errorOpenInBrowserButton.isVisible = false
|
||||
}
|
||||
|
||||
fun showError(errorInfo: ErrorInfo) {
|
||||
@@ -100,13 +102,14 @@ class ErrorPanelHelper(
|
||||
}
|
||||
|
||||
errorRetryButton.isVisible = true
|
||||
showAndSetOpenInBrowserButtonAction(errorInfo)
|
||||
} else if (errorInfo.throwable is AccountTerminatedException) {
|
||||
errorTextView.setText(R.string.account_terminated)
|
||||
|
||||
if (!isNullOrEmpty((errorInfo.throwable as AccountTerminatedException).message)) {
|
||||
errorServiceInfoTextView.text = context.resources.getString(
|
||||
R.string.service_provides_reason,
|
||||
NewPipe.getNameOfService(ServiceHelper.getSelectedServiceId(context))
|
||||
ServiceHelper.getSelectedService(context)?.serviceInfo?.name ?: "<unknown>"
|
||||
)
|
||||
errorServiceInfoTextView.isVisible = true
|
||||
|
||||
@@ -129,6 +132,7 @@ class ErrorPanelHelper(
|
||||
// show retry button only for content which is not unavailable or unsupported
|
||||
errorRetryButton.isVisible = true
|
||||
}
|
||||
showAndSetOpenInBrowserButtonAction(errorInfo)
|
||||
}
|
||||
|
||||
setRootVisible()
|
||||
@@ -139,13 +143,22 @@ class ErrorPanelHelper(
|
||||
*/
|
||||
private fun showAndSetErrorButtonAction(
|
||||
@StringRes resid: Int,
|
||||
@Nullable listener: View.OnClickListener
|
||||
listener: View.OnClickListener
|
||||
) {
|
||||
errorActionButton.isVisible = true
|
||||
errorActionButton.setText(resid)
|
||||
errorActionButton.setOnClickListener(listener)
|
||||
}
|
||||
|
||||
fun showAndSetOpenInBrowserButtonAction(
|
||||
errorInfo: ErrorInfo
|
||||
) {
|
||||
errorOpenInBrowserButton.isVisible = true
|
||||
errorOpenInBrowserButton.setOnClickListener {
|
||||
ShareUtils.openUrlInBrowser(context, errorInfo.request)
|
||||
}
|
||||
}
|
||||
|
||||
fun showTextError(errorString: String) {
|
||||
ensureDefaultVisibility()
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
package org.schabi.newpipe.error
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.schabi.newpipe.R
|
||||
@@ -105,18 +104,6 @@ class ErrorUtil {
|
||||
*/
|
||||
@JvmStatic
|
||||
fun createNotification(context: Context, errorInfo: ErrorInfo) {
|
||||
val notificationManager =
|
||||
ContextCompat.getSystemService(context, NotificationManager::class.java)
|
||||
if (notificationManager == null) {
|
||||
// this should never happen, but just in case open error activity
|
||||
openActivity(context, errorInfo)
|
||||
}
|
||||
|
||||
var pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
pendingIntentFlags = pendingIntentFlags or PendingIntent.FLAG_IMMUTABLE
|
||||
}
|
||||
|
||||
val notificationBuilder: NotificationCompat.Builder =
|
||||
NotificationCompat.Builder(
|
||||
context,
|
||||
@@ -127,15 +114,17 @@ class ErrorUtil {
|
||||
.setContentText(context.getString(errorInfo.messageStringId))
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
PendingIntentCompat.getActivity(
|
||||
context,
|
||||
0,
|
||||
getErrorActivityIntent(context, errorInfo),
|
||||
pendingIntentFlags
|
||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
false
|
||||
)
|
||||
)
|
||||
|
||||
notificationManager!!.notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build())
|
||||
NotificationManagerCompat.from(context)
|
||||
.notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build())
|
||||
|
||||
// since the notification is silent, also show a toast, otherwise the user is confused
|
||||
Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT)
|
||||
|
||||
@@ -3,14 +3,15 @@ package org.schabi.newpipe.error;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.webkit.CookieManager;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -18,16 +19,15 @@ import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.app.NavUtils;
|
||||
import androidx.preference.PreferenceManager;
|
||||
import androidx.webkit.WebViewClientCompat;
|
||||
|
||||
import org.schabi.newpipe.databinding.ActivityRecaptchaBinding;
|
||||
import org.schabi.newpipe.DownloaderImpl;
|
||||
import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.ActivityRecaptchaBinding;
|
||||
import org.schabi.newpipe.extractor.utils.Utils;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLDecoder;
|
||||
|
||||
/*
|
||||
* Created by beneth <bmauduit@beneth.fr> on 06.12.16.
|
||||
@@ -86,14 +86,15 @@ public class ReCaptchaActivity extends AppCompatActivity {
|
||||
webSettings.setJavaScriptEnabled(true);
|
||||
webSettings.setUserAgentString(DownloaderImpl.USER_AGENT);
|
||||
|
||||
recaptchaBinding.reCaptchaWebView.setWebViewClient(new WebViewClientCompat() {
|
||||
recaptchaBinding.reCaptchaWebView.setWebViewClient(new WebViewClient() {
|
||||
@Override
|
||||
public boolean shouldOverrideUrlLoading(final WebView view, final String url) {
|
||||
public boolean shouldOverrideUrlLoading(final WebView view,
|
||||
final WebResourceRequest request) {
|
||||
if (MainActivity.DEBUG) {
|
||||
Log.d(TAG, "shouldOverrideUrlLoading: url=" + url);
|
||||
Log.d(TAG, "shouldOverrideUrlLoading: url=" + request.getUrl().toString());
|
||||
}
|
||||
|
||||
handleCookiesFromUrl(url);
|
||||
handleCookiesFromUrl(request.getUrl().toString());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -107,12 +108,7 @@ public class ReCaptchaActivity extends AppCompatActivity {
|
||||
// cleaning cache, history and cookies from webView
|
||||
recaptchaBinding.reCaptchaWebView.clearCache(true);
|
||||
recaptchaBinding.reCaptchaWebView.clearHistory();
|
||||
final CookieManager cookieManager = CookieManager.getInstance();
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
cookieManager.removeAllCookies(value -> { });
|
||||
} else {
|
||||
cookieManager.removeAllCookie();
|
||||
}
|
||||
CookieManager.getInstance().removeAllCookies(null);
|
||||
|
||||
recaptchaBinding.reCaptchaWebView.loadUrl(url);
|
||||
}
|
||||
@@ -192,7 +188,7 @@ public class ReCaptchaActivity extends AppCompatActivity {
|
||||
|
||||
try {
|
||||
String abuseCookie = url.substring(abuseStart + 13, abuseEnd);
|
||||
abuseCookie = URLDecoder.decode(abuseCookie, "UTF-8");
|
||||
abuseCookie = Utils.decodeUrlUtf8(abuseCookie);
|
||||
handleCookies(abuseCookie);
|
||||
} catch (UnsupportedEncodingException | StringIndexOutOfBoundsException e) {
|
||||
if (MainActivity.DEBUG) {
|
||||
|
||||
@@ -26,10 +26,11 @@ public enum UserAction {
|
||||
DOWNLOAD_OPEN_DIALOG("download open dialog"),
|
||||
DOWNLOAD_POSTPROCESSING("download post-processing"),
|
||||
DOWNLOAD_FAILED("download failed"),
|
||||
NEW_STREAMS_NOTIFICATIONS("new streams notifications"),
|
||||
PREFERENCES_MIGRATION("migration of preferences"),
|
||||
SHARE_TO_NEWPIPE("share to newpipe"),
|
||||
CHECK_FOR_NEW_APP_VERSION("check for new app version");
|
||||
|
||||
CHECK_FOR_NEW_APP_VERSION("check for new app version"),
|
||||
OPEN_INFO_ITEM_DIALOG("open info item dialog");
|
||||
|
||||
private final String message;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.schabi.newpipe.fragments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager;
|
||||
@@ -10,7 +11,7 @@ import androidx.recyclerview.widget.StaggeredGridLayoutManager;
|
||||
*/
|
||||
public abstract class OnScrollBelowItemsListener extends RecyclerView.OnScrollListener {
|
||||
@Override
|
||||
public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) {
|
||||
public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) {
|
||||
super.onScrolled(recyclerView, dx, dy);
|
||||
if (dy > 0) {
|
||||
int pastVisibleItems = 0;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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;
|
||||
@@ -24,19 +30,11 @@ 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.external_communication.TextLinkifier;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import org.schabi.newpipe.util.text.TextLinkifier;
|
||||
|
||||
import icepick.State;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
|
||||
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;
|
||||
|
||||
public class DescriptionFragment extends BaseFragment {
|
||||
|
||||
@State
|
||||
@@ -84,7 +82,7 @@ public class DescriptionFragment extends BaseFragment {
|
||||
private void setupDescription() {
|
||||
final Description description = streamInfo.getDescription();
|
||||
if (description == null || isEmpty(description.getContent())
|
||||
|| description == Description.emptyDescription) {
|
||||
|| description == Description.EMPTY_DESCRIPTION) {
|
||||
binding.detailDescriptionView.setVisibility(View.GONE);
|
||||
binding.detailSelectDescriptionButton.setVisibility(View.GONE);
|
||||
return;
|
||||
@@ -115,7 +113,10 @@ public class DescriptionFragment extends BaseFragment {
|
||||
|
||||
private void disableDescriptionSelection() {
|
||||
// show description content again, otherwise some links are not clickable
|
||||
loadDescriptionContent();
|
||||
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);
|
||||
@@ -126,52 +127,32 @@ public class DescriptionFragment extends BaseFragment {
|
||||
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all);
|
||||
}
|
||||
|
||||
private void loadDescriptionContent() {
|
||||
final Description description = streamInfo.getDescription();
|
||||
switch (description.getType()) {
|
||||
case Description.HTML:
|
||||
TextLinkifier.createLinksFromHtmlBlock(binding.detailDescriptionView,
|
||||
description.getContent(), HtmlCompat.FROM_HTML_MODE_LEGACY, streamInfo,
|
||||
descriptionDisposables);
|
||||
break;
|
||||
case Description.MARKDOWN:
|
||||
TextLinkifier.createLinksFromMarkdownText(binding.detailDescriptionView,
|
||||
description.getContent(), streamInfo, descriptionDisposables);
|
||||
break;
|
||||
case Description.PLAIN_TEXT: default:
|
||||
TextLinkifier.createLinksFromPlainText(binding.detailDescriptionView,
|
||||
description.getContent(), streamInfo, descriptionDisposables);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void setupMetadata(final LayoutInflater inflater,
|
||||
final LinearLayout layout) {
|
||||
addMetadataItem(inflater, layout, false,
|
||||
R.string.metadata_category, streamInfo.getCategory());
|
||||
addMetadataItem(inflater, layout, false, R.string.metadata_category,
|
||||
streamInfo.getCategory());
|
||||
|
||||
addMetadataItem(inflater, layout, false,
|
||||
R.string.metadata_licence, streamInfo.getLicence());
|
||||
addMetadataItem(inflater, layout, false, R.string.metadata_licence,
|
||||
streamInfo.getLicence());
|
||||
|
||||
addPrivacyMetadataItem(inflater, layout);
|
||||
|
||||
if (streamInfo.getAgeLimit() != NO_AGE_LIMIT) {
|
||||
addMetadataItem(inflater, layout, false,
|
||||
R.string.metadata_age_limit, String.valueOf(streamInfo.getAgeLimit()));
|
||||
addMetadataItem(inflater, layout, false, R.string.metadata_age_limit,
|
||||
String.valueOf(streamInfo.getAgeLimit()));
|
||||
}
|
||||
|
||||
if (streamInfo.getLanguageInfo() != null) {
|
||||
addMetadataItem(inflater, layout, false,
|
||||
R.string.metadata_language, streamInfo.getLanguageInfo().getDisplayLanguage());
|
||||
addMetadataItem(inflater, layout, false, R.string.metadata_language,
|
||||
streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale(getContext())));
|
||||
}
|
||||
|
||||
addMetadataItem(inflater, layout, true,
|
||||
R.string.metadata_support, streamInfo.getSupportInfo());
|
||||
addMetadataItem(inflater, layout, true,
|
||||
R.string.metadata_host, streamInfo.getHost());
|
||||
addMetadataItem(inflater, layout, true,
|
||||
R.string.metadata_thumbnail_url, streamInfo.getThumbnailUrl());
|
||||
addMetadataItem(inflater, layout, true, R.string.metadata_support,
|
||||
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);
|
||||
}
|
||||
@@ -185,8 +166,8 @@ public class DescriptionFragment extends BaseFragment {
|
||||
return;
|
||||
}
|
||||
|
||||
final ItemMetadataBinding itemBinding
|
||||
= ItemMetadataBinding.inflate(inflater, layout, false);
|
||||
final ItemMetadataBinding itemBinding =
|
||||
ItemMetadataBinding.inflate(inflater, layout, false);
|
||||
|
||||
itemBinding.metadataTypeView.setText(type);
|
||||
itemBinding.metadataTypeView.setOnLongClickListener(v -> {
|
||||
@@ -195,30 +176,29 @@ public class DescriptionFragment extends BaseFragment {
|
||||
});
|
||||
|
||||
if (linkifyContent) {
|
||||
TextLinkifier.createLinksFromPlainText(itemBinding.metadataContentView, content, null,
|
||||
descriptionDisposables);
|
||||
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 ItemMetadataTagsBinding itemBinding
|
||||
= ItemMetadataTagsBinding.inflate(inflater, layout, false);
|
||||
final var itemBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false);
|
||||
|
||||
final List<String> tags = new ArrayList<>(streamInfo.getTags());
|
||||
Collections.sort(tags);
|
||||
for (final String tag : tags) {
|
||||
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());
|
||||
}
|
||||
@@ -252,14 +232,15 @@ public class DescriptionFragment extends BaseFragment {
|
||||
case INTERNAL:
|
||||
contentRes = R.string.metadata_privacy_internal;
|
||||
break;
|
||||
case OTHER: default:
|
||||
case OTHER:
|
||||
default:
|
||||
contentRes = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
if (contentRes != 0) {
|
||||
addMetadataItem(inflater, layout, false,
|
||||
R.string.metadata_privacy, getString(contentRes));
|
||||
addMetadataItem(inflater, layout, false, R.string.metadata_privacy,
|
||||
getString(contentRes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.schabi.newpipe.fragments.detail;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
|
||||
import java.io.Serializable;
|
||||
@@ -46,6 +48,7 @@ class StackItem implements Serializable {
|
||||
return playQueue;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return getServiceId() + ":" + getUrl() + " > " + getTitle();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,12 @@
|
||||
package org.schabi.newpipe.fragments.detail;
|
||||
|
||||
import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW;
|
||||
import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_DECODING_FAILED;
|
||||
import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_UNSPECIFIED;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
import android.util.Pair;
|
||||
import android.view.ContextThemeWrapper;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.ViewGroup;
|
||||
@@ -15,6 +20,7 @@ import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import com.google.android.exoplayer2.C;
|
||||
import com.google.android.exoplayer2.ExoPlaybackException;
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
|
||||
@@ -23,9 +29,7 @@ import org.schabi.newpipe.player.Player;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
@@ -38,46 +42,34 @@ public final class VideoDetailPlayerCrasher {
|
||||
// https://stackoverflow.com/a/54744028
|
||||
private static final String TAG = "VideoDetPlayerCrasher";
|
||||
|
||||
private static final Map<String, Supplier<ExoPlaybackException>> AVAILABLE_EXCEPTION_TYPES =
|
||||
getExceptionTypes();
|
||||
private static final String DEFAULT_MSG = "Dummy";
|
||||
|
||||
private static final List<Pair<String, Supplier<ExoPlaybackException>>>
|
||||
AVAILABLE_EXCEPTION_TYPES = List.of(
|
||||
new Pair<>("Source", () -> ExoPlaybackException.createForSource(
|
||||
new IOException(DEFAULT_MSG),
|
||||
ERROR_CODE_BEHIND_LIVE_WINDOW
|
||||
)),
|
||||
new Pair<>("Renderer", () -> ExoPlaybackException.createForRenderer(
|
||||
new Exception(DEFAULT_MSG),
|
||||
"Dummy renderer",
|
||||
0,
|
||||
null,
|
||||
C.FORMAT_HANDLED,
|
||||
/*isRecoverable=*/false,
|
||||
ERROR_CODE_DECODING_FAILED
|
||||
)),
|
||||
new Pair<>("Unexpected", () -> ExoPlaybackException.createForUnexpected(
|
||||
new RuntimeException(DEFAULT_MSG),
|
||||
ERROR_CODE_UNSPECIFIED
|
||||
)),
|
||||
new Pair<>("Remote", () -> ExoPlaybackException.createForRemote(DEFAULT_MSG))
|
||||
);
|
||||
|
||||
private VideoDetailPlayerCrasher() {
|
||||
// No impls
|
||||
}
|
||||
|
||||
private static Map<String, Supplier<ExoPlaybackException>> getExceptionTypes() {
|
||||
final String defaultMsg = "Dummy";
|
||||
final Map<String, Supplier<ExoPlaybackException>> exceptionTypes = new LinkedHashMap<>();
|
||||
exceptionTypes.put(
|
||||
"Source",
|
||||
() -> ExoPlaybackException.createForSource(
|
||||
new IOException(defaultMsg)
|
||||
)
|
||||
);
|
||||
exceptionTypes.put(
|
||||
"Renderer",
|
||||
() -> ExoPlaybackException.createForRenderer(
|
||||
new Exception(defaultMsg),
|
||||
"Dummy renderer",
|
||||
0,
|
||||
null,
|
||||
C.FORMAT_HANDLED
|
||||
)
|
||||
);
|
||||
exceptionTypes.put(
|
||||
"Unexpected",
|
||||
() -> ExoPlaybackException.createForUnexpected(
|
||||
new RuntimeException(defaultMsg)
|
||||
)
|
||||
);
|
||||
exceptionTypes.put(
|
||||
"Remote",
|
||||
() -> ExoPlaybackException.createForRemote(defaultMsg)
|
||||
);
|
||||
|
||||
return Collections.unmodifiableMap(exceptionTypes);
|
||||
}
|
||||
|
||||
private static Context getThemeWrapperContext(final Context context) {
|
||||
return new ContextThemeWrapper(
|
||||
context,
|
||||
@@ -88,8 +80,7 @@ public final class VideoDetailPlayerCrasher {
|
||||
|
||||
public static void onCrashThePlayer(
|
||||
@NonNull final Context context,
|
||||
@Nullable final Player player,
|
||||
@NonNull final LayoutInflater layoutInflater
|
||||
@Nullable final Player player
|
||||
) {
|
||||
if (player == null) {
|
||||
Log.d(TAG, "Player is not available");
|
||||
@@ -100,24 +91,22 @@ public final class VideoDetailPlayerCrasher {
|
||||
}
|
||||
|
||||
// -- Build the dialog/UI --
|
||||
|
||||
final Context themeWrapperContext = getThemeWrapperContext(context);
|
||||
|
||||
final LayoutInflater inflater = LayoutInflater.from(themeWrapperContext);
|
||||
final RadioGroup radioGroup = SingleChoiceDialogViewBinding.inflate(layoutInflater)
|
||||
.list;
|
||||
|
||||
final AlertDialog alertDialog = new AlertDialog.Builder(getThemeWrapperContext(context))
|
||||
final SingleChoiceDialogViewBinding binding =
|
||||
SingleChoiceDialogViewBinding.inflate(inflater);
|
||||
|
||||
final AlertDialog alertDialog = new AlertDialog.Builder(themeWrapperContext)
|
||||
.setTitle("Choose an exception")
|
||||
.setView(radioGroup)
|
||||
.setView(binding.getRoot())
|
||||
.setCancelable(true)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.create();
|
||||
|
||||
for (final Map.Entry<String, Supplier<ExoPlaybackException>> entry
|
||||
: AVAILABLE_EXCEPTION_TYPES.entrySet()) {
|
||||
for (final Pair<String, Supplier<ExoPlaybackException>> entry : AVAILABLE_EXCEPTION_TYPES) {
|
||||
final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater).getRoot();
|
||||
radioButton.setText(entry.getKey());
|
||||
radioButton.setText(entry.first);
|
||||
radioButton.setChecked(false);
|
||||
radioButton.setLayoutParams(
|
||||
new RadioGroup.LayoutParams(
|
||||
@@ -126,12 +115,10 @@ public final class VideoDetailPlayerCrasher {
|
||||
)
|
||||
);
|
||||
radioButton.setOnClickListener(v -> {
|
||||
tryCrashPlayerWith(player, entry.getValue().get());
|
||||
if (alertDialog != null) {
|
||||
alertDialog.cancel();
|
||||
}
|
||||
tryCrashPlayerWith(player, entry.second.get());
|
||||
alertDialog.cancel();
|
||||
});
|
||||
radioGroup.addView(radioButton);
|
||||
binding.list.addView(radioButton);
|
||||
}
|
||||
|
||||
alertDialog.show();
|
||||
@@ -139,7 +126,7 @@ public final class VideoDetailPlayerCrasher {
|
||||
|
||||
/**
|
||||
* Note that this method does not crash the underlying exoplayer directly (it's not possible).
|
||||
* It simply supplies a Exception to {@link Player#onPlayerError(ExoPlaybackException)}.
|
||||
* It simply supplies a Exception to {@link Player#onPlayerError(PlaybackException)}.
|
||||
* @param player
|
||||
* @param exception
|
||||
*/
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package org.schabi.newpipe.fragments.list;
|
||||
|
||||
import android.app.Activity;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.res.Configuration;
|
||||
import android.content.res.Resources;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
@@ -17,37 +18,25 @@ import androidx.appcompat.app.ActionBar;
|
||||
import androidx.preference.PreferenceManager;
|
||||
import androidx.recyclerview.widget.GridLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.viewbinding.ViewBinding;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.PignateFooterBinding;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.fragments.BaseStateFragment;
|
||||
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
|
||||
import org.schabi.newpipe.info_list.InfoItemDialog;
|
||||
import org.schabi.newpipe.info_list.InfoListAdapter;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.info_list.ItemViewMode;
|
||||
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.OnClickGesture;
|
||||
import org.schabi.newpipe.util.StateSaver;
|
||||
import org.schabi.newpipe.util.StreamDialogEntry;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.views.SuperScrollLayoutManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
implements ListViewContract<I, N>, StateSaver.WriteRead,
|
||||
@@ -79,11 +68,6 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDetach() {
|
||||
super.onDetach();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(final Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
@@ -108,11 +92,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
|
||||
if (updateFlags != 0) {
|
||||
if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) {
|
||||
final boolean useGrid = isGridLayout();
|
||||
itemsList.setLayoutManager(useGrid
|
||||
? getGridLayoutManager() : getListLayoutManager());
|
||||
infoListAdapter.setUseGridVariant(useGrid);
|
||||
infoListAdapter.notifyDataSetChanged();
|
||||
refreshItemViewMode();
|
||||
}
|
||||
updateFlags = 0;
|
||||
}
|
||||
@@ -220,14 +200,10 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Nullable
|
||||
protected ViewBinding getListHeader() {
|
||||
protected Supplier<View> getListHeaderSupplier() {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected ViewBinding getListFooter() {
|
||||
return PignateFooterBinding.inflate(activity.getLayoutInflater(), itemsList, false);
|
||||
}
|
||||
|
||||
protected RecyclerView.LayoutManager getListLayoutManager() {
|
||||
return new SuperScrollLayoutManager(activity);
|
||||
}
|
||||
@@ -236,27 +212,33 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
final Resources resources = activity.getResources();
|
||||
int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width);
|
||||
width += (24 * resources.getDisplayMetrics().density);
|
||||
final int spanCount = (int) Math.floor(resources.getDisplayMetrics().widthPixels
|
||||
/ (double) width);
|
||||
final int spanCount = Math.floorDiv(resources.getDisplayMetrics().widthPixels, width);
|
||||
final GridLayoutManager lm = new GridLayoutManager(activity, spanCount);
|
||||
lm.setSpanSizeLookup(infoListAdapter.getSpanSizeLookup(spanCount));
|
||||
return lm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the item view mode based on user preference.
|
||||
*/
|
||||
private void refreshItemViewMode() {
|
||||
final ItemViewMode itemViewMode = getItemViewMode();
|
||||
itemsList.setLayoutManager((itemViewMode == ItemViewMode.GRID)
|
||||
? getGridLayoutManager() : getListLayoutManager());
|
||||
infoListAdapter.setItemViewMode(itemViewMode);
|
||||
infoListAdapter.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
||||
super.initViews(rootView, savedInstanceState);
|
||||
|
||||
final boolean useGrid = isGridLayout();
|
||||
itemsList = rootView.findViewById(R.id.items_list);
|
||||
itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
|
||||
refreshItemViewMode();
|
||||
|
||||
infoListAdapter.setUseGridVariant(useGrid);
|
||||
infoListAdapter.setFooter(getListFooter().getRoot());
|
||||
|
||||
final ViewBinding listHeader = getListHeader();
|
||||
if (listHeader != null) {
|
||||
infoListAdapter.setHeader(listHeader.getRoot());
|
||||
final Supplier<View> listHeaderSupplier = getListHeaderSupplier();
|
||||
if (listHeaderSupplier != null) {
|
||||
infoListAdapter.setHeaderSupplier(listHeaderSupplier);
|
||||
}
|
||||
|
||||
itemsList.setAdapter(infoListAdapter);
|
||||
@@ -271,7 +253,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
@Override
|
||||
protected void initListeners() {
|
||||
super.initListeners();
|
||||
infoListAdapter.setOnStreamSelectedListener(new OnClickGesture<StreamInfoItem>() {
|
||||
infoListAdapter.setOnStreamSelectedListener(new OnClickGesture<>() {
|
||||
@Override
|
||||
public void selected(final StreamInfoItem selectedItem) {
|
||||
onStreamSelected(selectedItem);
|
||||
@@ -279,56 +261,116 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
|
||||
@Override
|
||||
public void held(final StreamInfoItem selectedItem) {
|
||||
showStreamDialog(selectedItem);
|
||||
showInfoItemDialog(selectedItem);
|
||||
}
|
||||
});
|
||||
|
||||
infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<ChannelInfoItem>() {
|
||||
@Override
|
||||
public void selected(final ChannelInfoItem selectedItem) {
|
||||
try {
|
||||
onItemSelected(selectedItem);
|
||||
NavigationHelper.openChannelFragment(getFM(),
|
||||
selectedItem.getServiceId(),
|
||||
selectedItem.getUrl(),
|
||||
selectedItem.getName());
|
||||
} catch (final Exception e) {
|
||||
ErrorUtil.showUiErrorSnackbar(
|
||||
BaseListFragment.this, "Opening channel fragment", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture<PlaylistInfoItem>() {
|
||||
@Override
|
||||
public void selected(final PlaylistInfoItem selectedItem) {
|
||||
try {
|
||||
onItemSelected(selectedItem);
|
||||
NavigationHelper.openPlaylistFragment(getFM(),
|
||||
selectedItem.getServiceId(),
|
||||
selectedItem.getUrl(),
|
||||
selectedItem.getName());
|
||||
} catch (final Exception e) {
|
||||
ErrorUtil.showUiErrorSnackbar(BaseListFragment.this,
|
||||
"Opening playlist fragment", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
infoListAdapter.setOnCommentsSelectedListener(new OnClickGesture<CommentsInfoItem>() {
|
||||
@Override
|
||||
public void selected(final CommentsInfoItem selectedItem) {
|
||||
infoListAdapter.setOnChannelSelectedListener(selectedItem -> {
|
||||
try {
|
||||
onItemSelected(selectedItem);
|
||||
NavigationHelper.openChannelFragment(getFM(), selectedItem.getServiceId(),
|
||||
selectedItem.getUrl(), selectedItem.getName());
|
||||
} catch (final Exception e) {
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e);
|
||||
}
|
||||
});
|
||||
|
||||
itemsList.clearOnScrollListeners();
|
||||
itemsList.addOnScrollListener(new OnScrollBelowItemsListener() {
|
||||
@Override
|
||||
public void onScrolledDown(final RecyclerView recyclerView) {
|
||||
onScrollToBottom();
|
||||
infoListAdapter.setOnPlaylistSelectedListener(selectedItem -> {
|
||||
try {
|
||||
onItemSelected(selectedItem);
|
||||
NavigationHelper.openPlaylistFragment(getFM(), selectedItem.getServiceId(),
|
||||
selectedItem.getUrl(), selectedItem.getName());
|
||||
} catch (final Exception e) {
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Opening playlist fragment", e);
|
||||
}
|
||||
});
|
||||
|
||||
infoListAdapter.setOnCommentsSelectedListener(this::onItemSelected);
|
||||
|
||||
// Ensure that there is always a scroll listener (e.g. when rotating the device)
|
||||
useNormalItemListScrollListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all listeners and adds the normal scroll listener to the {@link #itemsList}.
|
||||
*/
|
||||
protected void useNormalItemListScrollListener() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "useNormalItemListScrollListener called");
|
||||
}
|
||||
itemsList.clearOnScrollListeners();
|
||||
itemsList.addOnScrollListener(new DefaultItemListOnScrolledDownListener());
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all listeners and adds the initial scroll listener to the {@link #itemsList}.
|
||||
* <br/>
|
||||
* Which tries to load more items when not enough are in the view (not scrollable)
|
||||
* and more are available.
|
||||
* <br/>
|
||||
* Note: This method only works because "This callback will also be called if visible
|
||||
* item range changes after a layout calculation. In that case, dx and dy will be 0."
|
||||
* - which might be unexpected because no actual scrolling occurs...
|
||||
* <br/>
|
||||
* This listener will be replaced by DefaultItemListOnScrolledDownListener when
|
||||
* <ul>
|
||||
* <li>the view was actually scrolled</li>
|
||||
* <li>the view is scrollable</li>
|
||||
* <li>no more items can be loaded</li>
|
||||
* </ul>
|
||||
*/
|
||||
protected void useInitialItemListLoadScrollListener() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "useInitialItemListLoadScrollListener called");
|
||||
}
|
||||
itemsList.clearOnScrollListeners();
|
||||
itemsList.addOnScrollListener(new DefaultItemListOnScrolledDownListener() {
|
||||
@Override
|
||||
public void onScrolled(@NonNull final RecyclerView recyclerView,
|
||||
final int dx, final int dy) {
|
||||
super.onScrolled(recyclerView, dx, dy);
|
||||
|
||||
if (dy != 0) {
|
||||
log("Vertical scroll occurred");
|
||||
|
||||
useNormalItemListScrollListener();
|
||||
return;
|
||||
}
|
||||
if (isLoading.get()) {
|
||||
log("Still loading data -> Skipping");
|
||||
return;
|
||||
}
|
||||
if (!hasMoreItems()) {
|
||||
log("No more items to load");
|
||||
|
||||
useNormalItemListScrollListener();
|
||||
return;
|
||||
}
|
||||
if (itemsList.canScrollVertically(1)
|
||||
|| itemsList.canScrollVertically(-1)) {
|
||||
log("View is scrollable");
|
||||
|
||||
useNormalItemListScrollListener();
|
||||
return;
|
||||
}
|
||||
|
||||
log("Loading more data");
|
||||
loadMoreItems();
|
||||
}
|
||||
|
||||
private void log(final String msg) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "initItemListLoadScrollListener - " + msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class DefaultItemListOnScrolledDownListener extends OnScrollBelowItemsListener {
|
||||
@Override
|
||||
public void onScrolledDown(final RecyclerView recyclerView) {
|
||||
onScrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
private void onStreamSelected(final StreamInfoItem selectedItem) {
|
||||
@@ -344,55 +386,12 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
}
|
||||
}
|
||||
|
||||
protected void showStreamDialog(final StreamInfoItem item) {
|
||||
final Context context = getContext();
|
||||
final Activity activity = getActivity();
|
||||
if (context == null || context.getResources() == null || activity == null) {
|
||||
return;
|
||||
protected void showInfoItemDialog(final StreamInfoItem item) {
|
||||
try {
|
||||
new InfoItemDialog.Builder(getActivity(), getContext(), this, item).create().show();
|
||||
} catch (final IllegalArgumentException e) {
|
||||
InfoItemDialog.Builder.reportErrorDuringInitialization(e, item);
|
||||
}
|
||||
final List<StreamDialogEntry> entries = new ArrayList<>();
|
||||
|
||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
||||
entries.add(StreamDialogEntry.enqueue);
|
||||
|
||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
||||
entries.add(StreamDialogEntry.enqueue_next);
|
||||
}
|
||||
}
|
||||
|
||||
if (item.getStreamType() == StreamType.AUDIO_STREAM) {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
} else {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.start_here_on_popup,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
}
|
||||
entries.add(StreamDialogEntry.open_in_browser);
|
||||
if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) {
|
||||
entries.add(StreamDialogEntry.play_with_kodi);
|
||||
}
|
||||
|
||||
// show "mark as watched" only when watch history is enabled
|
||||
if (StreamDialogEntry.shouldAddMarkAsWatched(item.getStreamType(), context)) {
|
||||
entries.add(
|
||||
StreamDialogEntry.mark_as_watched
|
||||
);
|
||||
}
|
||||
if (!isNullOrEmpty(item.getUploaderUrl())) {
|
||||
entries.add(StreamDialogEntry.show_channel_details);
|
||||
}
|
||||
|
||||
StreamDialogEntry.setEnabledEntries(entries);
|
||||
|
||||
new InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context),
|
||||
(dialog, which) -> StreamDialogEntry.clickOn(which, this, item)).show();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
@@ -418,6 +417,12 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
// Load and handle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected void startLoading(final boolean forceLoad) {
|
||||
useInitialItemListLoadScrollListener();
|
||||
super.startLoading(forceLoad);
|
||||
}
|
||||
|
||||
protected abstract void loadMoreItems();
|
||||
|
||||
protected abstract boolean hasMoreItems();
|
||||
@@ -469,21 +474,16 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
@Override
|
||||
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
|
||||
final String key) {
|
||||
if (key.equals(getString(R.string.list_view_mode_key))) {
|
||||
if (getString(R.string.list_view_mode_key).equals(key)) {
|
||||
updateFlags |= LIST_MODE_UPDATE_FLAG;
|
||||
}
|
||||
}
|
||||
|
||||
protected boolean isGridLayout() {
|
||||
final String listMode = PreferenceManager.getDefaultSharedPreferences(activity)
|
||||
.getString(getString(R.string.list_view_mode_key),
|
||||
getString(R.string.list_view_mode_value));
|
||||
if ("auto".equals(listMode)) {
|
||||
final Configuration configuration = getResources().getConfiguration();
|
||||
return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
&& configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE);
|
||||
} else {
|
||||
return "grid".equals(listMode);
|
||||
}
|
||||
/**
|
||||
* Returns preferred item view mode.
|
||||
* @return ItemViewMode
|
||||
*/
|
||||
protected ItemViewMode getItemViewMode() {
|
||||
return ThemeHelper.getItemViewMode(requireContext());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.annotation.NonNull;
|
||||
|
||||
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;
|
||||
@@ -27,8 +28,8 @@ import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
extends BaseListFragment<I, ListExtractor.InfoItemsPage> {
|
||||
public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInfo<I>>
|
||||
extends BaseListFragment<L, ListExtractor.InfoItemsPage<I>> {
|
||||
@State
|
||||
protected int serviceId = Constants.NO_SERVICE_ID;
|
||||
@State
|
||||
@@ -37,7 +38,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
protected String url;
|
||||
|
||||
private final UserAction errorUserAction;
|
||||
protected I currentInfo;
|
||||
protected L currentInfo;
|
||||
protected Page currentNextPage;
|
||||
protected Disposable currentWorker;
|
||||
|
||||
@@ -65,7 +66,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
super.onResume();
|
||||
// Check if it was loading when the fragment was stopped/paused,
|
||||
if (wasLoading.getAndSet(false)) {
|
||||
if (hasMoreItems() && infoListAdapter.getItemsList().size() > 0) {
|
||||
if (hasMoreItems() && !infoListAdapter.getItemsList().isEmpty()) {
|
||||
loadMoreItems();
|
||||
} else {
|
||||
doInitialLoadLogic();
|
||||
@@ -97,7 +98,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
@SuppressWarnings("unchecked")
|
||||
public void readFrom(@NonNull final Queue<Object> savedObjects) throws Exception {
|
||||
super.readFrom(savedObjects);
|
||||
currentInfo = (I) savedObjects.poll();
|
||||
currentInfo = (L) savedObjects.poll();
|
||||
currentNextPage = (Page) savedObjects.poll();
|
||||
}
|
||||
|
||||
@@ -105,6 +106,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
// Load and handle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected void doInitialLoadLogic() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "doInitialLoadLogic() called");
|
||||
@@ -123,7 +125,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
* @param forceLoad allow or disallow the result to come from the cache
|
||||
* @return Rx {@link Single} containing the {@link ListInfo}
|
||||
*/
|
||||
protected abstract Single<I> loadResult(boolean forceLoad);
|
||||
protected abstract Single<L> loadResult(boolean forceLoad);
|
||||
|
||||
@Override
|
||||
public void startLoading(final boolean forceLoad) {
|
||||
@@ -139,7 +141,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
currentWorker = loadResult(forceLoad)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe((@NonNull I result) -> {
|
||||
.subscribe((@NonNull L result) -> {
|
||||
isLoading.set(false);
|
||||
currentInfo = result;
|
||||
currentNextPage = result.getNextPage();
|
||||
@@ -156,8 +158,9 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
*
|
||||
* @return Rx {@link Single} containing the {@link ListExtractor.InfoItemsPage}
|
||||
*/
|
||||
protected abstract Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic();
|
||||
protected abstract Single<ListExtractor.InfoItemsPage<I>> loadMoreItemsLogic();
|
||||
|
||||
@Override
|
||||
protected void loadMoreItems() {
|
||||
isLoading.set(true);
|
||||
|
||||
@@ -171,9 +174,9 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doFinally(this::allowDownwardFocusScroll)
|
||||
.subscribe((@NonNull ListExtractor.InfoItemsPage InfoItemsPage) -> {
|
||||
.subscribe(infoItemsPage -> {
|
||||
isLoading.set(false);
|
||||
handleNextItems(InfoItemsPage);
|
||||
handleNextItems(infoItemsPage);
|
||||
}, (@NonNull Throwable throwable) ->
|
||||
dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(throwable,
|
||||
errorUserAction, "Loading more items: " + url, serviceId)));
|
||||
@@ -192,7 +195,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleNextItems(final ListExtractor.InfoItemsPage result) {
|
||||
public void handleNextItems(final ListExtractor.InfoItemsPage<I> result) {
|
||||
super.handleNextItems(result);
|
||||
|
||||
currentNextPage = result.getNextPage();
|
||||
@@ -216,14 +219,14 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void handleResult(@NonNull final I result) {
|
||||
public void handleResult(@NonNull final L result) {
|
||||
super.handleResult(result);
|
||||
|
||||
name = result.getName();
|
||||
setTitle(name);
|
||||
|
||||
if (infoListAdapter.getItemsList().isEmpty()) {
|
||||
if (result.getRelatedItems().size() > 0) {
|
||||
if (!result.getRelatedItems().isEmpty()) {
|
||||
infoListAdapter.addInfoItemList(result.getRelatedItems());
|
||||
showListFooter(hasMoreItems());
|
||||
} else {
|
||||
@@ -240,7 +243,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
final List<Throwable> errors = new ArrayList<>(result.getErrors());
|
||||
// handling ContentNotSupportedException not to show the error but an appropriate string
|
||||
// so that crashes won't be sent uselessly and the user will understand what happened
|
||||
errors.removeIf(throwable -> throwable instanceof ContentNotSupportedException);
|
||||
errors.removeIf(ContentNotSupportedException.class::isInstance);
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(result.getErrors(),
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package org.schabi.newpipe.fragments.list.channel;
|
||||
|
||||
import static org.schabi.newpipe.ktx.TextViewUtils.animateTextColor;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
@@ -17,11 +22,12 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.viewbinding.ViewBinding;
|
||||
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
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;
|
||||
@@ -29,7 +35,6 @@ 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.InfoItem;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
|
||||
@@ -37,19 +42,21 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.ktx.AnimationType;
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||
import org.schabi.newpipe.player.MainPlayer.PlayerType;
|
||||
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.util.ExtractorHelper;
|
||||
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.PicassoHelper;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Observable;
|
||||
@@ -61,11 +68,7 @@ import io.reactivex.rxjava3.functions.Consumer;
|
||||
import io.reactivex.rxjava3.functions.Function;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
import static org.schabi.newpipe.ktx.TextViewUtils.animateTextColor;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor;
|
||||
|
||||
public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, ChannelInfo>
|
||||
implements View.OnClickListener {
|
||||
|
||||
private static final int BUTTON_DEBOUNCE_INTERVAL = 100;
|
||||
@@ -74,6 +77,8 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||
private Disposable subscribeButtonMonitor;
|
||||
|
||||
private boolean channelContentNotSupported = false;
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Views
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@@ -85,6 +90,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
private PlaylistControlBinding playlistControlBinding;
|
||||
|
||||
private MenuItem menuRssButton;
|
||||
private MenuItem menuNotifyButton;
|
||||
|
||||
public static ChannelFragment getInstance(final int serviceId, final String url,
|
||||
final String name) {
|
||||
@@ -126,6 +132,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
|
||||
super.onViewCreated(rootView, savedInstanceState);
|
||||
channelBinding = FragmentChannelBinding.bind(rootView);
|
||||
showContentNotSupportedIfNeeded();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -145,12 +152,12 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected ViewBinding getListHeader() {
|
||||
protected Supplier<View> getListHeaderSupplier() {
|
||||
headerBinding = ChannelHeaderBinding
|
||||
.inflate(activity.getLayoutInflater(), itemsList, false);
|
||||
playlistControlBinding = headerBinding.playlistControl;
|
||||
|
||||
return headerBinding;
|
||||
return headerBinding::getRoot;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -180,13 +187,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
|
||||
}
|
||||
menuRssButton = menu.findItem(R.id.menu_item_rss);
|
||||
}
|
||||
}
|
||||
|
||||
private void openRssFeed() {
|
||||
final ChannelInfo info = currentInfo;
|
||||
if (info != null) {
|
||||
ShareUtils.openUrlInBrowser(requireContext(), info.getFeedUrl(), false);
|
||||
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,8 +197,15 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
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.menu_item_rss:
|
||||
openRssFeed();
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl());
|
||||
}
|
||||
break;
|
||||
case R.id.menu_item_openInBrowser:
|
||||
if (currentInfo != null) {
|
||||
@@ -237,15 +245,22 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
.subscribe(getSubscribeUpdateMonitor(info), onError));
|
||||
|
||||
disposables.add(observable
|
||||
// Some updates are very rapid
|
||||
// (for example when calling the updateSubscription(info))
|
||||
// so only update the UI for the latest emission
|
||||
// ("sync" the subscribe button's state)
|
||||
.debounce(100, TimeUnit.MILLISECONDS)
|
||||
.map(List::isEmpty)
|
||||
.distinctUntilChanged()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe((List<SubscriptionEntity> subscriptionEntities) ->
|
||||
updateSubscribeButton(!subscriptionEntities.isEmpty()), onError));
|
||||
.subscribe(isEmpty -> updateSubscribeButton(!isEmpty), onError));
|
||||
|
||||
disposables.add(observable
|
||||
.map(List::isEmpty)
|
||||
.distinctUntilChanged()
|
||||
.skip(1) // channel has just been opened
|
||||
.filter(x -> NotificationHelper.areNewStreamsNotificationsEnabled(requireContext()))
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(isEmpty -> {
|
||||
if (!isEmpty) {
|
||||
showNotifySnackbar();
|
||||
}
|
||||
}, onError));
|
||||
}
|
||||
|
||||
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription,
|
||||
@@ -325,6 +340,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
info.getAvatarUrl(),
|
||||
info.getDescription(),
|
||||
info.getSubscriberCount());
|
||||
updateNotifyButton(null);
|
||||
subscribeButtonMonitor = monitorSubscribeButton(
|
||||
headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info));
|
||||
} else {
|
||||
@@ -332,6 +348,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
Log.d(TAG, "Found subscription to this channel!");
|
||||
}
|
||||
final SubscriptionEntity subscription = subscriptionEntities.get(0);
|
||||
updateNotifyButton(subscription);
|
||||
subscribeButtonMonitor = monitorSubscribeButton(
|
||||
headerBinding.channelSubscribeButton, mapOnUnsubscribe(subscription));
|
||||
}
|
||||
@@ -374,12 +391,51 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
AnimationType.LIGHT_SCALE_AND_ALPHA);
|
||||
}
|
||||
|
||||
private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) {
|
||||
if (menuNotifyButton == null) {
|
||||
return;
|
||||
}
|
||||
if (subscription != null) {
|
||||
menuNotifyButton.setEnabled(
|
||||
NotificationHelper.areNewStreamsNotificationsEnabled(requireContext())
|
||||
);
|
||||
menuNotifyButton.setChecked(
|
||||
subscription.getNotificationMode() == NotificationMode.ENABLED
|
||||
);
|
||||
}
|
||||
|
||||
menuNotifyButton.setVisible(subscription != null);
|
||||
}
|
||||
|
||||
private void setNotify(final boolean isEnabled) {
|
||||
disposables.add(
|
||||
subscriptionManager
|
||||
.updateNotificationMode(
|
||||
currentInfo.getServiceId(),
|
||||
currentInfo.getUrl(),
|
||||
isEnabled ? NotificationMode.ENABLED : NotificationMode.DISABLED)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
.setAction(R.string.get_notified, v -> setNotify(true))
|
||||
.setActionTextColor(Color.YELLOW)
|
||||
.show();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Load and handle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
||||
protected Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
|
||||
return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextPage);
|
||||
}
|
||||
|
||||
@@ -470,9 +526,12 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
playlistControlBinding.getRoot().setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
channelContentNotSupported = false;
|
||||
for (final Throwable throwable : result.getErrors()) {
|
||||
if (throwable instanceof ContentNotSupportedException) {
|
||||
showContentNotSupported();
|
||||
channelContentNotSupported = true;
|
||||
showContentNotSupportedIfNeeded();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,7 +563,13 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
});
|
||||
}
|
||||
|
||||
private void showContentNotSupported() {
|
||||
private void showContentNotSupportedIfNeeded() {
|
||||
// channelBinding might not be initialized when handleResult() is called
|
||||
// (e.g. after rotating the screen, #6696)
|
||||
if (!channelContentNotSupported || channelBinding == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
channelBinding.errorContentNotSupported.setVisibility(View.VISIBLE);
|
||||
channelBinding.channelKaomoji.setText("(︶︹︺)");
|
||||
channelBinding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f);
|
||||
@@ -512,18 +577,13 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
}
|
||||
|
||||
private PlayQueue getPlayQueue() {
|
||||
return getPlayQueue(0);
|
||||
}
|
||||
final List<StreamInfoItem> streamItems = infoListAdapter.getItemsList().stream()
|
||||
.filter(StreamInfoItem.class::isInstance)
|
||||
.map(StreamInfoItem.class::cast)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
private PlayQueue getPlayQueue(final int index) {
|
||||
final List<StreamInfoItem> streamItems = new ArrayList<>();
|
||||
for (final InfoItem i : infoListAdapter.getItemsList()) {
|
||||
if (i instanceof StreamInfoItem) {
|
||||
streamItems.add((StreamInfoItem) i);
|
||||
}
|
||||
}
|
||||
return new ChannelPlayQueue(currentInfo.getServiceId(), currentInfo.getUrl(),
|
||||
currentInfo.getNextPage(), streamItems, index);
|
||||
currentInfo.getNextPage(), streamItems, 0);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -15,14 +15,16 @@ import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfo;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.info_list.ItemViewMode;
|
||||
import org.schabi.newpipe.ktx.ViewUtils;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
|
||||
public class CommentsFragment extends BaseListInfoFragment<CommentsInfo> {
|
||||
public class CommentsFragment extends BaseListInfoFragment<CommentsInfoItem, CommentsInfo> {
|
||||
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||
|
||||
private TextView emptyStateDesc;
|
||||
@@ -67,7 +69,7 @@ public class CommentsFragment extends BaseListInfoFragment<CommentsInfo> {
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
||||
protected Single<ListExtractor.InfoItemsPage<CommentsInfoItem>> loadMoreItemsLogic() {
|
||||
return ExtractorHelper.getMoreCommentItems(serviceId, currentInfo, currentNextPage);
|
||||
}
|
||||
|
||||
@@ -105,7 +107,7 @@ public class CommentsFragment extends BaseListInfoFragment<CommentsInfo> {
|
||||
@NonNull final MenuInflater inflater) { }
|
||||
|
||||
@Override
|
||||
protected boolean isGridLayout() {
|
||||
return false;
|
||||
protected ItemViewMode getItemViewMode() {
|
||||
return ItemViewMode.LIST;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ 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.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.KioskTranslator;
|
||||
@@ -53,7 +54,7 @@ import io.reactivex.rxjava3.core.Single;
|
||||
* </p>
|
||||
*/
|
||||
|
||||
public class KioskFragment extends BaseListInfoFragment<KioskInfo> {
|
||||
public class KioskFragment extends BaseListInfoFragment<StreamInfoItem, KioskInfo> {
|
||||
@State
|
||||
String kioskId = "";
|
||||
String kioskTranslatedName;
|
||||
@@ -145,7 +146,7 @@ public class KioskFragment extends BaseListInfoFragment<KioskInfo> {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
||||
public Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
|
||||
return ExtractorHelper.getMoreKioskItems(serviceId, url, currentNextPage);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.schabi.newpipe.fragments.list.playlist;
|
||||
|
||||
import android.app.Activity;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
@@ -15,13 +17,16 @@ import android.view.ViewGroup;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.viewbinding.ViewBinding;
|
||||
|
||||
import com.google.android.material.shape.CornerFamily;
|
||||
import com.google.android.material.shape.ShapeAppearanceModel;
|
||||
|
||||
import org.reactivestreams.Subscriber;
|
||||
import org.reactivestreams.Subscription;
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.databinding.PlaylistControlBinding;
|
||||
import org.schabi.newpipe.databinding.PlaylistHeaderBinding;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
@@ -33,26 +38,25 @@ 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.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.info_list.InfoItemDialog;
|
||||
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.MainPlayer.PlayerType;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
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.PicassoHelper;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
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.StreamDialogEntry;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
@@ -60,11 +64,7 @@ import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
|
||||
|
||||
public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||
public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, PlaylistInfo> {
|
||||
|
||||
private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG";
|
||||
|
||||
@@ -120,18 +120,20 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected ViewBinding getListHeader() {
|
||||
protected Supplier<View> getListHeaderSupplier() {
|
||||
headerBinding = PlaylistHeaderBinding
|
||||
.inflate(activity.getLayoutInflater(), itemsList, false);
|
||||
playlistControlBinding = headerBinding.playlistControl;
|
||||
|
||||
return headerBinding;
|
||||
return headerBinding::getRoot;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
||||
super.initViews(rootView, savedInstanceState);
|
||||
|
||||
// Is mini variant still relevant?
|
||||
// Only the remote playlist screen uses it now
|
||||
infoListAdapter.setUseMiniVariant(true);
|
||||
}
|
||||
|
||||
@@ -140,60 +142,22 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void showStreamDialog(final StreamInfoItem item) {
|
||||
protected void showInfoItemDialog(final StreamInfoItem item) {
|
||||
final Context context = getContext();
|
||||
final Activity activity = getActivity();
|
||||
if (context == null || context.getResources() == null || activity == null) {
|
||||
return;
|
||||
try {
|
||||
final InfoItemDialog.Builder dialogBuilder =
|
||||
new InfoItemDialog.Builder(getActivity(), context, this, item);
|
||||
|
||||
dialogBuilder
|
||||
.setAction(
|
||||
StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND,
|
||||
(f, infoItem) -> NavigationHelper.playOnBackgroundPlayer(
|
||||
context, getPlayQueueStartingAt(infoItem), true))
|
||||
.create()
|
||||
.show();
|
||||
} catch (final IllegalArgumentException e) {
|
||||
InfoItemDialog.Builder.reportErrorDuringInitialization(e, item);
|
||||
}
|
||||
|
||||
final ArrayList<StreamDialogEntry> entries = new ArrayList<>();
|
||||
|
||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
||||
entries.add(StreamDialogEntry.enqueue);
|
||||
|
||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
||||
entries.add(StreamDialogEntry.enqueue_next);
|
||||
}
|
||||
}
|
||||
|
||||
if (item.getStreamType() == StreamType.AUDIO_STREAM) {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
} else {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.start_here_on_popup,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
}
|
||||
entries.add(StreamDialogEntry.open_in_browser);
|
||||
if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) {
|
||||
entries.add(StreamDialogEntry.play_with_kodi);
|
||||
}
|
||||
|
||||
// show "mark as watched" only when watch history is enabled
|
||||
if (StreamDialogEntry.shouldAddMarkAsWatched(item.getStreamType(), context)) {
|
||||
entries.add(
|
||||
StreamDialogEntry.mark_as_watched
|
||||
);
|
||||
}
|
||||
if (!isNullOrEmpty(item.getUploaderUrl())) {
|
||||
entries.add(StreamDialogEntry.show_channel_details);
|
||||
}
|
||||
|
||||
StreamDialogEntry.setEnabledEntries(entries);
|
||||
|
||||
StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItem) ->
|
||||
NavigationHelper.playOnBackgroundPlayer(context,
|
||||
getPlayQueueStartingAt(infoItem), true));
|
||||
|
||||
new InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context),
|
||||
(dialog, which) -> StreamDialogEntry.clickOn(which, this, item)).show();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -249,7 +213,7 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
||||
protected Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
|
||||
return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextPage);
|
||||
}
|
||||
|
||||
@@ -268,14 +232,25 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||
ShareUtils.openUrlInBrowser(requireContext(), url);
|
||||
break;
|
||||
case R.id.menu_item_share:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.shareText(requireContext(), name, url,
|
||||
currentInfo.getThumbnailUrl());
|
||||
}
|
||||
ShareUtils.shareText(requireContext(), name, url,
|
||||
currentInfo == null ? null : currentInfo.getThumbnailUrl());
|
||||
break;
|
||||
case R.id.menu_item_bookmark:
|
||||
onBookmarkClicked();
|
||||
break;
|
||||
case R.id.menu_item_append_playlist:
|
||||
if (currentInfo != null) {
|
||||
disposables.add(PlaylistDialog.createCorrespondingDialog(
|
||||
getContext(),
|
||||
getPlayQueue()
|
||||
.getStreams()
|
||||
.stream()
|
||||
.map(StreamEntity::new)
|
||||
.collect(Collectors.toList()),
|
||||
dialog -> dialog.show(getFM(), TAG)
|
||||
));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
@@ -328,9 +303,12 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||
&& (YoutubeParsingHelper.isYoutubeMixId(result.getId())
|
||||
|| YoutubeParsingHelper.isYoutubeMusicMixId(result.getId()))) {
|
||||
// this is an auto-generated playlist (e.g. Youtube mix), so a radio is shown
|
||||
headerBinding.uploaderAvatarView.setDisableCircularTransformation(true);
|
||||
headerBinding.uploaderAvatarView.setBorderColor(
|
||||
getResources().getColor(R.color.transparent_background_color));
|
||||
final ShapeAppearanceModel model = ShapeAppearanceModel.builder()
|
||||
.setAllCorners(CornerFamily.ROUNDED, 0f)
|
||||
.build(); // this turns the image back into a square
|
||||
headerBinding.uploaderAvatarView.setShapeAppearanceModel(model);
|
||||
headerBinding.uploaderAvatarView.setStrokeColor(AppCompatResources
|
||||
.getColorStateList(requireContext(), R.color.transparent_background_color));
|
||||
headerBinding.uploaderAvatarView.setImageDrawable(
|
||||
AppCompatResources.getDrawable(requireContext(),
|
||||
R.drawable.ic_radio)
|
||||
@@ -413,7 +391,7 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||
}
|
||||
|
||||
private Subscriber<List<PlaylistRemoteEntity>> getPlaylistBookmarkSubscriber() {
|
||||
return new Subscriber<List<PlaylistRemoteEntity>>() {
|
||||
return new Subscriber<>() {
|
||||
@Override
|
||||
public void onSubscribe(final Subscription s) {
|
||||
if (bookmarkReactor != null) {
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.TooltipCompat;
|
||||
import androidx.collection.SparseArrayCompat;
|
||||
import androidx.core.text.HtmlCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
@@ -70,9 +71,7 @@ import org.schabi.newpipe.util.ServiceHelper;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -141,7 +140,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
@State
|
||||
boolean wasSearchFocused = false;
|
||||
|
||||
@Nullable private Map<Integer, String> menuItemToFilterName = null;
|
||||
private final SparseArrayCompat<String> menuItemToFilterName = new SparseArrayCompat<>();
|
||||
private StreamingService service;
|
||||
private Page nextPage;
|
||||
private boolean showLocalSuggestions = true;
|
||||
@@ -200,7 +199,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
showLocalSuggestions = NewPipeSettings.showLocalSearchSuggestions(activity, prefs);
|
||||
showRemoteSuggestions = NewPipeSettings.showRemoteSearchSuggestions(activity, prefs);
|
||||
|
||||
suggestionListAdapter = new SuggestionListAdapter(activity);
|
||||
suggestionListAdapter = new SuggestionListAdapter();
|
||||
historyRecordManager = new HistoryRecordManager(context);
|
||||
}
|
||||
|
||||
@@ -340,6 +339,8 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
super.initViews(rootView, savedInstanceState);
|
||||
|
||||
searchBinding.suggestionsList.setAdapter(suggestionListAdapter);
|
||||
// animations are just strange and useless, since the suggestions keep changing too much
|
||||
searchBinding.suggestionsList.setItemAnimator(null);
|
||||
new ItemTouchHelper(new ItemTouchHelper.Callback() {
|
||||
@Override
|
||||
public int getMovementFlags(@NonNull final RecyclerView recyclerView,
|
||||
@@ -424,8 +425,6 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
supportActionBar.setDisplayHomeAsUpEnabled(true);
|
||||
}
|
||||
|
||||
menuItemToFilterName = new HashMap<>();
|
||||
|
||||
int itemId = 0;
|
||||
boolean isFirstItem = true;
|
||||
final Context c = getContext();
|
||||
@@ -466,11 +465,8 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
|
||||
if (menuItemToFilterName != null) {
|
||||
final List<String> cf = new ArrayList<>(1);
|
||||
cf.add(menuItemToFilterName.get(item.getItemId()));
|
||||
changeContentFilter(item, cf);
|
||||
}
|
||||
final var filter = Collections.singletonList(menuItemToFilterName.get(item.getItemId()));
|
||||
changeContentFilter(item, filter);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -497,9 +493,6 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
+ lastSearchedString);
|
||||
}
|
||||
searchEditText.setText(searchString);
|
||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP) {
|
||||
searchEditText.setHintTextColor(searchEditText.getTextColors().withAlpha(128));
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(searchString) || TextUtils.isEmpty(searchEditText.getText())) {
|
||||
searchToolbarContainer.setTranslationX(100);
|
||||
@@ -533,7 +526,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
searchBinding.correctSuggestion.setVisibility(View.GONE);
|
||||
|
||||
searchEditText.setText("");
|
||||
suggestionListAdapter.setItems(new ArrayList<>());
|
||||
suggestionListAdapter.submitList(null);
|
||||
showKeyboardSearch();
|
||||
});
|
||||
|
||||
@@ -922,7 +915,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
filterItemCheckedId = item.getItemId();
|
||||
item.setChecked(true);
|
||||
|
||||
contentFilter = new String[]{theContentFilter.get(0)};
|
||||
contentFilter = theContentFilter.toArray(new String[0]);
|
||||
|
||||
if (!TextUtils.isEmpty(searchString)) {
|
||||
search(searchString, contentFilter, sortFilter);
|
||||
@@ -947,8 +940,8 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "handleSuggestions() called with: suggestions = [" + suggestions + "]");
|
||||
}
|
||||
searchBinding.suggestionsList.smoothScrollToPosition(0);
|
||||
searchBinding.suggestionsList.post(() -> suggestionListAdapter.setItems(suggestions));
|
||||
suggestionListAdapter.submitList(suggestions,
|
||||
() -> searchBinding.suggestionsList.scrollToPosition(0));
|
||||
|
||||
if (suggestionsPanelVisible && isErrorPanelVisible()) {
|
||||
hideLoading();
|
||||
@@ -983,8 +976,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
isCorrectedSearch = result.isCorrectedSearch();
|
||||
|
||||
// List<MetaInfo> cannot be bundled without creating some containers
|
||||
metaInfo = new MetaInfo[result.getMetaInfo().size()];
|
||||
metaInfo = result.getMetaInfo().toArray(metaInfo);
|
||||
metaInfo = result.getMetaInfo().toArray(new MetaInfo[0]);
|
||||
showMetaInfoInTextView(result.getMetaInfo(), searchBinding.searchMetaInfoTextView,
|
||||
searchBinding.searchMetaInfoSeparator, disposables);
|
||||
|
||||
@@ -1070,14 +1062,14 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
return 0;
|
||||
}
|
||||
|
||||
final SuggestionItem item = suggestionListAdapter.getItem(position);
|
||||
final SuggestionItem item = suggestionListAdapter.getCurrentList().get(position);
|
||||
return item.fromHistory ? makeMovementFlags(0,
|
||||
ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT) : 0;
|
||||
}
|
||||
|
||||
public void onSuggestionItemSwiped(@NonNull final RecyclerView.ViewHolder viewHolder) {
|
||||
final int position = viewHolder.getBindingAdapterPosition();
|
||||
final String query = suggestionListAdapter.getItem(position).query;
|
||||
final String query = suggestionListAdapter.getCurrentList().get(position).query;
|
||||
final Disposable onDelete = historyRecordManager.deleteSearchHistory(query)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
|
||||
@@ -1,80 +1,58 @@
|
||||
package org.schabi.newpipe.fragments.list.search;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.DiffUtil;
|
||||
import androidx.recyclerview.widget.ListAdapter;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.schabi.newpipe.databinding.ItemSearchSuggestionBinding;
|
||||
|
||||
public class SuggestionListAdapter
|
||||
extends RecyclerView.Adapter<SuggestionListAdapter.SuggestionItemHolder> {
|
||||
private final ArrayList<SuggestionItem> items = new ArrayList<>();
|
||||
private final Context context;
|
||||
extends ListAdapter<SuggestionItem, SuggestionListAdapter.SuggestionItemHolder> {
|
||||
private OnSuggestionItemSelected listener;
|
||||
|
||||
public SuggestionListAdapter(final Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public void setItems(final List<SuggestionItem> items) {
|
||||
this.items.clear();
|
||||
this.items.addAll(items);
|
||||
notifyDataSetChanged();
|
||||
public SuggestionListAdapter() {
|
||||
super(new SuggestionItemCallback());
|
||||
}
|
||||
|
||||
public void setListener(final OnSuggestionItemSelected listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public SuggestionItemHolder onCreateViewHolder(final ViewGroup parent, final int viewType) {
|
||||
return new SuggestionItemHolder(LayoutInflater.from(context)
|
||||
.inflate(R.layout.item_search_suggestion, parent, false));
|
||||
public SuggestionItemHolder onCreateViewHolder(@NonNull final ViewGroup parent,
|
||||
final int viewType) {
|
||||
return new SuggestionItemHolder(ItemSearchSuggestionBinding
|
||||
.inflate(LayoutInflater.from(parent.getContext()), parent, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(final SuggestionItemHolder holder, final int position) {
|
||||
final SuggestionItem currentItem = getItem(position);
|
||||
holder.updateFrom(currentItem);
|
||||
holder.queryView.setOnClickListener(v -> {
|
||||
holder.itemBinding.suggestionSearch.setOnClickListener(v -> {
|
||||
if (listener != null) {
|
||||
listener.onSuggestionItemSelected(currentItem);
|
||||
}
|
||||
});
|
||||
holder.queryView.setOnLongClickListener(v -> {
|
||||
holder.itemBinding.suggestionSearch.setOnLongClickListener(v -> {
|
||||
if (listener != null) {
|
||||
listener.onSuggestionItemLongClick(currentItem);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
holder.insertView.setOnClickListener(v -> {
|
||||
holder.itemBinding.suggestionInsert.setOnClickListener(v -> {
|
||||
if (listener != null) {
|
||||
listener.onSuggestionItemInserted(currentItem);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
SuggestionItem getItem(final int position) {
|
||||
return items.get(position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return items.size();
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return getItemCount() == 0;
|
||||
}
|
||||
|
||||
public interface OnSuggestionItemSelected {
|
||||
void onSuggestionItemSelected(SuggestionItem item);
|
||||
|
||||
@@ -84,30 +62,32 @@ public class SuggestionListAdapter
|
||||
}
|
||||
|
||||
public static final class SuggestionItemHolder extends RecyclerView.ViewHolder {
|
||||
private final TextView itemSuggestionQuery;
|
||||
private final ImageView suggestionIcon;
|
||||
private final View queryView;
|
||||
private final View insertView;
|
||||
private final ItemSearchSuggestionBinding itemBinding;
|
||||
|
||||
// Cache some ids, as they can potentially be constantly updated/recycled
|
||||
private final int historyResId;
|
||||
private final int searchResId;
|
||||
|
||||
private SuggestionItemHolder(final View rootView) {
|
||||
super(rootView);
|
||||
suggestionIcon = rootView.findViewById(R.id.item_suggestion_icon);
|
||||
itemSuggestionQuery = rootView.findViewById(R.id.item_suggestion_query);
|
||||
|
||||
queryView = rootView.findViewById(R.id.suggestion_search);
|
||||
insertView = rootView.findViewById(R.id.suggestion_insert);
|
||||
|
||||
historyResId = R.drawable.ic_history;
|
||||
searchResId = R.drawable.ic_search;
|
||||
private SuggestionItemHolder(final ItemSearchSuggestionBinding binding) {
|
||||
super(binding.getRoot());
|
||||
this.itemBinding = binding;
|
||||
}
|
||||
|
||||
private void updateFrom(final SuggestionItem item) {
|
||||
suggestionIcon.setImageResource(item.fromHistory ? historyResId : searchResId);
|
||||
itemSuggestionQuery.setText(item.query);
|
||||
itemBinding.itemSuggestionIcon.setImageResource(item.fromHistory ? R.drawable.ic_history
|
||||
: R.drawable.ic_search);
|
||||
itemBinding.itemSuggestionQuery.setText(item.query);
|
||||
}
|
||||
}
|
||||
|
||||
private static class SuggestionItemCallback extends DiffUtil.ItemCallback<SuggestionItem> {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull final SuggestionItem oldItem,
|
||||
@NonNull final SuggestionItem newItem) {
|
||||
return oldItem.fromHistory == newItem.fromHistory
|
||||
&& oldItem.query.equals(newItem.query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull final SuggestionItem oldItem,
|
||||
@NonNull final SuggestionItem newItem) {
|
||||
return true; // items' contents never change; the list of items themselves does
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.schabi.newpipe.fragments.list.videos;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
@@ -12,26 +11,27 @@ import android.view.ViewGroup;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.preference.PreferenceManager;
|
||||
import androidx.viewbinding.ViewBinding;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.RelatedItemsHeaderBinding;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.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;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
|
||||
public class RelatedItemsFragment extends BaseListInfoFragment<RelatedItemInfo>
|
||||
public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, RelatedItemInfo>
|
||||
implements SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
private static final String INFO_KEY = "related_info_key";
|
||||
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||
|
||||
private RelatedItemInfo relatedItemInfo;
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
@@ -54,11 +54,6 @@ public class RelatedItemsFragment extends BaseListInfoFragment<RelatedItemInfo>
|
||||
// LifeCycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onAttach(@NonNull final Context context) {
|
||||
super.onAttach(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull final LayoutInflater inflater,
|
||||
@Nullable final ViewGroup container,
|
||||
@@ -66,12 +61,6 @@ public class RelatedItemsFragment extends BaseListInfoFragment<RelatedItemInfo>
|
||||
return inflater.inflate(R.layout.fragment_related_items, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
disposables.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
headerBinding = null;
|
||||
@@ -79,26 +68,27 @@ public class RelatedItemsFragment extends BaseListInfoFragment<RelatedItemInfo>
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ViewBinding getListHeader() {
|
||||
if (relatedItemInfo != null && relatedItemInfo.getRelatedItems() != null) {
|
||||
headerBinding = RelatedItemsHeaderBinding
|
||||
.inflate(activity.getLayoutInflater(), itemsList, false);
|
||||
|
||||
final SharedPreferences pref = PreferenceManager
|
||||
.getDefaultSharedPreferences(requireContext());
|
||||
final boolean autoplay = pref.getBoolean(getString(R.string.auto_queue_key), false);
|
||||
headerBinding.autoplaySwitch.setChecked(autoplay);
|
||||
headerBinding.autoplaySwitch.setOnCheckedChangeListener((compoundButton, b) ->
|
||||
PreferenceManager.getDefaultSharedPreferences(requireContext()).edit()
|
||||
.putBoolean(getString(R.string.auto_queue_key), b).apply());
|
||||
return headerBinding;
|
||||
} else {
|
||||
protected Supplier<View> getListHeaderSupplier() {
|
||||
if (relatedItemInfo == null || relatedItemInfo.getRelatedItems() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
headerBinding = RelatedItemsHeaderBinding
|
||||
.inflate(activity.getLayoutInflater(), itemsList, false);
|
||||
|
||||
final SharedPreferences pref = PreferenceManager
|
||||
.getDefaultSharedPreferences(requireContext());
|
||||
final boolean autoplay = pref.getBoolean(getString(R.string.auto_queue_key), false);
|
||||
headerBinding.autoplaySwitch.setChecked(autoplay);
|
||||
headerBinding.autoplaySwitch.setOnCheckedChangeListener((compoundButton, b) ->
|
||||
PreferenceManager.getDefaultSharedPreferences(requireContext()).edit()
|
||||
.putBoolean(getString(R.string.auto_queue_key), b).apply());
|
||||
|
||||
return headerBinding::getRoot;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
||||
protected Single<ListExtractor.InfoItemsPage<InfoItem>> loadMoreItemsLogic() {
|
||||
return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage);
|
||||
}
|
||||
|
||||
@@ -128,7 +118,6 @@ public class RelatedItemsFragment extends BaseListInfoFragment<RelatedItemInfo>
|
||||
}
|
||||
ViewUtils.slideUp(requireView(), 120, 96, 0.06f);
|
||||
|
||||
disposables.clear();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
@@ -137,11 +126,13 @@ public class RelatedItemsFragment extends BaseListInfoFragment<RelatedItemInfo>
|
||||
|
||||
@Override
|
||||
public void setTitle(final String title) {
|
||||
// Nothing to do - override parent
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) {
|
||||
// Nothing to do - override parent
|
||||
}
|
||||
|
||||
private void setInitialData(final StreamInfo info) {
|
||||
@@ -168,17 +159,19 @@ public class RelatedItemsFragment extends BaseListInfoFragment<RelatedItemInfo>
|
||||
|
||||
@Override
|
||||
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
|
||||
final String s) {
|
||||
final SharedPreferences pref =
|
||||
PreferenceManager.getDefaultSharedPreferences(requireContext());
|
||||
final boolean autoplay = pref.getBoolean(getString(R.string.auto_queue_key), false);
|
||||
if (headerBinding != null) {
|
||||
headerBinding.autoplaySwitch.setChecked(autoplay);
|
||||
final String key) {
|
||||
if (headerBinding != null && getString(R.string.auto_queue_key).equals(key)) {
|
||||
headerBinding.autoplaySwitch.setChecked(sharedPreferences.getBoolean(key, false));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isGridLayout() {
|
||||
return false;
|
||||
protected ItemViewMode getItemViewMode() {
|
||||
ItemViewMode mode = super.getItemViewMode();
|
||||
// Only list mode is supported. Either List or card will be used.
|
||||
if (mode != ItemViewMode.LIST && mode != ItemViewMode.CARD) {
|
||||
mode = ItemViewMode.LIST;
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,8 +67,8 @@ public class InfoItemBuilder {
|
||||
public View buildView(@NonNull final ViewGroup parent, @NonNull final InfoItem infoItem,
|
||||
final HistoryRecordManager historyRecordManager,
|
||||
final boolean useMiniVariant) {
|
||||
final InfoItemHolder holder
|
||||
= holderFromInfoType(parent, infoItem.getInfoType(), useMiniVariant);
|
||||
final InfoItemHolder holder =
|
||||
holderFromInfoType(parent, infoItem.getInfoType(), useMiniVariant);
|
||||
holder.updateFromItem(infoItem, historyRecordManager);
|
||||
return holder.itemView;
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
package org.schabi.newpipe.info_list;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.DialogInterface;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
|
||||
public class InfoItemDialog {
|
||||
private final AlertDialog dialog;
|
||||
|
||||
public InfoItemDialog(@NonNull final Activity activity,
|
||||
@NonNull final StreamInfoItem info,
|
||||
@NonNull final String[] commands,
|
||||
@NonNull final DialogInterface.OnClickListener actions) {
|
||||
this(activity, commands, actions, info.getName(), info.getUploaderName());
|
||||
}
|
||||
|
||||
public InfoItemDialog(@NonNull final Activity activity,
|
||||
@NonNull final String[] commands,
|
||||
@NonNull final DialogInterface.OnClickListener actions,
|
||||
@NonNull final String title,
|
||||
@Nullable final String additionalDetail) {
|
||||
|
||||
final View bannerView = View.inflate(activity, R.layout.dialog_title, null);
|
||||
bannerView.setSelected(true);
|
||||
|
||||
final TextView titleView = bannerView.findViewById(R.id.itemTitleView);
|
||||
titleView.setText(title);
|
||||
|
||||
final TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails);
|
||||
if (additionalDetail != null) {
|
||||
detailsView.setText(additionalDetail);
|
||||
detailsView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
detailsView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
dialog = new AlertDialog.Builder(activity)
|
||||
.setCustomTitle(bannerView)
|
||||
.setItems(commands, actions)
|
||||
.create();
|
||||
}
|
||||
|
||||
public void show() {
|
||||
dialog.show();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.schabi.newpipe.info_list;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
@@ -10,21 +11,24 @@ import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.GridLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity;
|
||||
import org.schabi.newpipe.databinding.PignateFooterBinding;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
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.InfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.PlaylistCardInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.StreamCardInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.StreamGridInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.StreamInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.StreamMiniInfoItemHolder;
|
||||
@@ -34,6 +38,7 @@ import org.schabi.newpipe.util.OnClickGesture;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/*
|
||||
* Created by Christian Schabesberger on 01.08.16.
|
||||
@@ -65,27 +70,33 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
||||
private static final int MINI_STREAM_HOLDER_TYPE = 0x100;
|
||||
private static final int STREAM_HOLDER_TYPE = 0x101;
|
||||
private static final int GRID_STREAM_HOLDER_TYPE = 0x102;
|
||||
private static final int CARD_STREAM_HOLDER_TYPE = 0x103;
|
||||
private static final int MINI_CHANNEL_HOLDER_TYPE = 0x200;
|
||||
private static final int CHANNEL_HOLDER_TYPE = 0x201;
|
||||
private static final int GRID_CHANNEL_HOLDER_TYPE = 0x202;
|
||||
private static final int CARD_CHANNEL_HOLDER_TYPE = 0x203;
|
||||
private static final int MINI_PLAYLIST_HOLDER_TYPE = 0x300;
|
||||
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 final LayoutInflater layoutInflater;
|
||||
private final InfoItemBuilder infoItemBuilder;
|
||||
private final ArrayList<InfoItem> infoItemList;
|
||||
private final List<InfoItem> infoItemList;
|
||||
private final HistoryRecordManager recordManager;
|
||||
|
||||
private boolean useMiniVariant = false;
|
||||
private boolean useGridVariant = false;
|
||||
private boolean showFooter = false;
|
||||
private View header = null;
|
||||
private View footer = null;
|
||||
|
||||
private ItemViewMode itemMode = ItemViewMode.LIST;
|
||||
|
||||
private Supplier<View> headerSupplier = null;
|
||||
|
||||
public InfoListAdapter(final Context context) {
|
||||
this.recordManager = new HistoryRecordManager(context);
|
||||
layoutInflater = LayoutInflater.from(context);
|
||||
recordManager = new HistoryRecordManager(context);
|
||||
infoItemBuilder = new InfoItemBuilder(context);
|
||||
infoItemList = new ArrayList<>();
|
||||
}
|
||||
@@ -110,8 +121,8 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
||||
this.useMiniVariant = useMiniVariant;
|
||||
}
|
||||
|
||||
public void setUseGridVariant(final boolean useGridVariant) {
|
||||
this.useGridVariant = useGridVariant;
|
||||
public void setItemViewMode(final ItemViewMode itemViewMode) {
|
||||
this.itemMode = itemViewMode;
|
||||
}
|
||||
|
||||
public void addInfoItemList(@Nullable final List<? extends InfoItem> data) {
|
||||
@@ -129,12 +140,12 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "addInfoItemList() after > offsetStart = " + offsetStart + ", "
|
||||
+ "infoItemList.size() = " + infoItemList.size() + ", "
|
||||
+ "header = " + header + ", footer = " + footer + ", "
|
||||
+ "hasHeader = " + hasHeader() + ", "
|
||||
+ "showFooter = " + showFooter);
|
||||
}
|
||||
notifyItemRangeInserted(offsetStart, data.size());
|
||||
|
||||
if (footer != null && showFooter) {
|
||||
if (showFooter) {
|
||||
final int footerNow = sizeConsideringHeaderOffset();
|
||||
notifyItemMoved(offsetStart, footerNow);
|
||||
|
||||
@@ -145,43 +156,6 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
||||
}
|
||||
}
|
||||
|
||||
public void setInfoItemList(final List<? extends InfoItem> data) {
|
||||
infoItemList.clear();
|
||||
infoItemList.addAll(data);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void addInfoItem(@Nullable final InfoItem data) {
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "addInfoItem() before > infoItemList.size() = "
|
||||
+ infoItemList.size() + ", thread = " + Thread.currentThread());
|
||||
}
|
||||
|
||||
final int positionInserted = sizeConsideringHeaderOffset();
|
||||
infoItemList.add(data);
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "addInfoItem() after > position = " + positionInserted + ", "
|
||||
+ "infoItemList.size() = " + infoItemList.size() + ", "
|
||||
+ "header = " + header + ", footer = " + footer + ", "
|
||||
+ "showFooter = " + showFooter);
|
||||
}
|
||||
notifyItemInserted(positionInserted);
|
||||
|
||||
if (footer != null && showFooter) {
|
||||
final int footerNow = sizeConsideringHeaderOffset();
|
||||
notifyItemMoved(positionInserted, footerNow);
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "addInfoItem() footer from " + positionInserted
|
||||
+ " to " + footerNow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void clearStreamItemList() {
|
||||
if (infoItemList.isEmpty()) {
|
||||
return;
|
||||
@@ -190,16 +164,16 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void setHeader(final View header) {
|
||||
final boolean changed = header != this.header;
|
||||
this.header = header;
|
||||
public void setHeaderSupplier(@Nullable final Supplier<View> headerSupplier) {
|
||||
final boolean changed = headerSupplier != this.headerSupplier;
|
||||
this.headerSupplier = headerSupplier;
|
||||
if (changed) {
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void setFooter(final View view) {
|
||||
this.footer = view;
|
||||
protected boolean hasHeader() {
|
||||
return this.headerSupplier != null;
|
||||
}
|
||||
|
||||
public void showFooter(final boolean show) {
|
||||
@@ -219,61 +193,83 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
||||
}
|
||||
|
||||
private int sizeConsideringHeaderOffset() {
|
||||
final int i = infoItemList.size() + (header != null ? 1 : 0);
|
||||
final int i = infoItemList.size() + (hasHeader() ? 1 : 0);
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "sizeConsideringHeaderOffset() called → " + i);
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
public ArrayList<InfoItem> getItemsList() {
|
||||
public List<InfoItem> getItemsList() {
|
||||
return infoItemList;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
int count = infoItemList.size();
|
||||
if (header != null) {
|
||||
if (hasHeader()) {
|
||||
count++;
|
||||
}
|
||||
if (footer != null && showFooter) {
|
||||
if (showFooter) {
|
||||
count++;
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "getItemCount() called with: "
|
||||
+ "count = " + count + ", infoItemList.size() = " + infoItemList.size() + ", "
|
||||
+ "header = " + header + ", footer = " + footer + ", "
|
||||
+ "hasHeader = " + hasHeader() + ", "
|
||||
+ "showFooter = " + showFooter);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@SuppressWarnings("FinalParameters")
|
||||
@Override
|
||||
public int getItemViewType(int position) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "getItemViewType() called with: position = [" + position + "]");
|
||||
}
|
||||
|
||||
if (header != null && position == 0) {
|
||||
if (hasHeader() && position == 0) {
|
||||
return HEADER_TYPE;
|
||||
} else if (header != null) {
|
||||
} else if (hasHeader()) {
|
||||
position--;
|
||||
}
|
||||
if (footer != null && position == infoItemList.size() && showFooter) {
|
||||
if (position == infoItemList.size() && showFooter) {
|
||||
return FOOTER_TYPE;
|
||||
}
|
||||
final InfoItem item = infoItemList.get(position);
|
||||
switch (item.getInfoType()) {
|
||||
case STREAM:
|
||||
return useGridVariant ? GRID_STREAM_HOLDER_TYPE : useMiniVariant
|
||||
? MINI_STREAM_HOLDER_TYPE : STREAM_HOLDER_TYPE;
|
||||
if (itemMode == ItemViewMode.CARD) {
|
||||
return CARD_STREAM_HOLDER_TYPE;
|
||||
} else if (itemMode == ItemViewMode.GRID) {
|
||||
return GRID_STREAM_HOLDER_TYPE;
|
||||
} else if (useMiniVariant) {
|
||||
return MINI_STREAM_HOLDER_TYPE;
|
||||
} else {
|
||||
return STREAM_HOLDER_TYPE;
|
||||
}
|
||||
case CHANNEL:
|
||||
return useGridVariant ? GRID_CHANNEL_HOLDER_TYPE : useMiniVariant
|
||||
? MINI_CHANNEL_HOLDER_TYPE : CHANNEL_HOLDER_TYPE;
|
||||
if (itemMode == ItemViewMode.CARD) {
|
||||
return CARD_CHANNEL_HOLDER_TYPE;
|
||||
} else if (itemMode == ItemViewMode.GRID) {
|
||||
return GRID_CHANNEL_HOLDER_TYPE;
|
||||
} else if (useMiniVariant) {
|
||||
return MINI_CHANNEL_HOLDER_TYPE;
|
||||
} else {
|
||||
return CHANNEL_HOLDER_TYPE;
|
||||
}
|
||||
case PLAYLIST:
|
||||
return useGridVariant ? GRID_PLAYLIST_HOLDER_TYPE : useMiniVariant
|
||||
? MINI_PLAYLIST_HOLDER_TYPE : PLAYLIST_HOLDER_TYPE;
|
||||
if (itemMode == ItemViewMode.CARD) {
|
||||
return CARD_PLAYLIST_HOLDER_TYPE;
|
||||
} else if (itemMode == ItemViewMode.GRID) {
|
||||
return GRID_PLAYLIST_HOLDER_TYPE;
|
||||
} else if (useMiniVariant) {
|
||||
return MINI_PLAYLIST_HOLDER_TYPE;
|
||||
} else {
|
||||
return PLAYLIST_HOLDER_TYPE;
|
||||
}
|
||||
case COMMENT:
|
||||
return useMiniVariant ? MINI_COMMENT_HOLDER_TYPE : COMMENT_HOLDER_TYPE;
|
||||
default:
|
||||
@@ -290,20 +286,30 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
||||
+ "parent = [" + parent + "], type = [" + type + "]");
|
||||
}
|
||||
switch (type) {
|
||||
// #4475 and #3368
|
||||
// Always create a new instance otherwise the same instance
|
||||
// is sometimes reused which causes a crash
|
||||
case HEADER_TYPE:
|
||||
return new HFHolder(header);
|
||||
return new HFHolder(headerSupplier.get());
|
||||
case FOOTER_TYPE:
|
||||
return new HFHolder(footer);
|
||||
return new HFHolder(PignateFooterBinding
|
||||
.inflate(layoutInflater, parent, false)
|
||||
.getRoot()
|
||||
);
|
||||
case MINI_STREAM_HOLDER_TYPE:
|
||||
return new StreamMiniInfoItemHolder(infoItemBuilder, parent);
|
||||
case STREAM_HOLDER_TYPE:
|
||||
return new StreamInfoItemHolder(infoItemBuilder, parent);
|
||||
case GRID_STREAM_HOLDER_TYPE:
|
||||
return new StreamGridInfoItemHolder(infoItemBuilder, parent);
|
||||
case CARD_STREAM_HOLDER_TYPE:
|
||||
return new StreamCardInfoItemHolder(infoItemBuilder, parent);
|
||||
case MINI_CHANNEL_HOLDER_TYPE:
|
||||
return new ChannelMiniInfoItemHolder(infoItemBuilder, parent);
|
||||
case CHANNEL_HOLDER_TYPE:
|
||||
return new ChannelInfoItemHolder(infoItemBuilder, parent);
|
||||
case CARD_CHANNEL_HOLDER_TYPE:
|
||||
return new ChannelCardInfoItemHolder(infoItemBuilder, parent);
|
||||
case GRID_CHANNEL_HOLDER_TYPE:
|
||||
return new ChannelGridInfoItemHolder(infoItemBuilder, parent);
|
||||
case MINI_PLAYLIST_HOLDER_TYPE:
|
||||
@@ -312,6 +318,8 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
||||
return new PlaylistInfoItemHolder(infoItemBuilder, parent);
|
||||
case GRID_PLAYLIST_HOLDER_TYPE:
|
||||
return new PlaylistGridInfoItemHolder(infoItemBuilder, parent);
|
||||
case CARD_PLAYLIST_HOLDER_TYPE:
|
||||
return new PlaylistCardInfoItemHolder(infoItemBuilder, parent);
|
||||
case MINI_COMMENT_HOLDER_TYPE:
|
||||
return new CommentsMiniInfoItemHolder(infoItemBuilder, parent);
|
||||
case COMMENT_HOLDER_TYPE:
|
||||
@@ -322,42 +330,17 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, int position) {
|
||||
public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder,
|
||||
final int position) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onBindViewHolder() called with: "
|
||||
+ "holder = [" + holder.getClass().getSimpleName() + "], "
|
||||
+ "position = [" + position + "]");
|
||||
}
|
||||
if (holder instanceof InfoItemHolder) {
|
||||
// If header isn't null, offset the items by -1
|
||||
if (header != null) {
|
||||
position--;
|
||||
}
|
||||
|
||||
((InfoItemHolder) holder).updateFromItem(infoItemList.get(position), recordManager);
|
||||
} else if (holder instanceof HFHolder && position == 0 && header != null) {
|
||||
((HFHolder) holder).view = header;
|
||||
} else if (holder instanceof HFHolder && position == sizeConsideringHeaderOffset()
|
||||
&& footer != null && showFooter) {
|
||||
((HFHolder) holder).view = footer;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position,
|
||||
@NonNull final List<Object> payloads) {
|
||||
if (!payloads.isEmpty() && holder instanceof InfoItemHolder) {
|
||||
for (final Object payload : payloads) {
|
||||
if (payload instanceof StreamStateEntity) {
|
||||
((InfoItemHolder) holder).updateState(infoItemList
|
||||
.get(header == null ? position : position - 1), recordManager);
|
||||
} else if (payload instanceof Boolean) {
|
||||
((InfoItemHolder) holder).updateState(infoItemList
|
||||
.get(header == null ? position : position - 1), recordManager);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onBindViewHolder(holder, position);
|
||||
((InfoItemHolder) holder).updateFromItem(
|
||||
// If header is present, offset the items by -1
|
||||
infoItemList.get(hasHeader() ? position - 1 : position), recordManager);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,12 +354,9 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
||||
};
|
||||
}
|
||||
|
||||
public static class HFHolder extends RecyclerView.ViewHolder {
|
||||
public View view;
|
||||
|
||||
static class HFHolder extends RecyclerView.ViewHolder {
|
||||
HFHolder(final View v) {
|
||||
super(v);
|
||||
view = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.schabi.newpipe.info_list;
|
||||
|
||||
/**
|
||||
* Item view mode for streams & playlist listing screens.
|
||||
*/
|
||||
public enum ItemViewMode {
|
||||
/**
|
||||
* Default mode.
|
||||
*/
|
||||
AUTO,
|
||||
/**
|
||||
* Full width list item with thumb on the left and two line title & uploader in right.
|
||||
*/
|
||||
LIST,
|
||||
/**
|
||||
* Grid mode places two cards per row.
|
||||
*/
|
||||
GRID,
|
||||
/**
|
||||
* A full width card in phone - portrait.
|
||||
*/
|
||||
CARD
|
||||
}
|
||||
@@ -61,5 +61,6 @@ class StreamSegmentAdapter(
|
||||
|
||||
interface StreamSegmentListener {
|
||||
fun onItemClick(item: StreamSegmentItem, seconds: Int)
|
||||
fun onItemLongClick(item: StreamSegmentItem, seconds: Int)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ class StreamSegmentItem(
|
||||
viewHolder.root.findViewById<TextView>(R.id.textViewStartSeconds).text =
|
||||
Localization.getDurationString(item.startTimeSeconds.toLong())
|
||||
viewHolder.root.setOnClickListener { onClick.onItemClick(this, item.startTimeSeconds) }
|
||||
viewHolder.root.setOnLongClickListener { onClick.onItemLongClick(this, item.startTimeSeconds); true }
|
||||
viewHolder.root.isSelected = isSelected
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
package org.schabi.newpipe.info_list.dialog;
|
||||
|
||||
import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.schabi.newpipe.App;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.util.StreamTypeUtil;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Dialog for a {@link StreamInfoItem}.
|
||||
* The dialog's content are actions that can be performed on the {@link StreamInfoItem}.
|
||||
* This dialog is mostly used for longpress context menus.
|
||||
*/
|
||||
public final class InfoItemDialog {
|
||||
private static final String TAG = Build.class.getSimpleName();
|
||||
/**
|
||||
* Ideally, {@link InfoItemDialog} would extend {@link AlertDialog}.
|
||||
* However, extending {@link AlertDialog} requires many additional lines
|
||||
* and brings more complexity to this class, especially the constructor.
|
||||
* To circumvent this, an {@link AlertDialog.Builder} is used in the constructor.
|
||||
* Its result is stored in this class variable to allow access via the {@link #show()} method.
|
||||
*/
|
||||
private final AlertDialog dialog;
|
||||
|
||||
private InfoItemDialog(@NonNull final Activity activity,
|
||||
@NonNull final Fragment fragment,
|
||||
@NonNull final StreamInfoItem info,
|
||||
@NonNull final List<StreamDialogEntry> entries) {
|
||||
|
||||
// Create the dialog's title
|
||||
final View bannerView = View.inflate(activity, R.layout.dialog_title, null);
|
||||
bannerView.setSelected(true);
|
||||
|
||||
final TextView titleView = bannerView.findViewById(R.id.itemTitleView);
|
||||
titleView.setText(info.getName());
|
||||
|
||||
final TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails);
|
||||
if (info.getUploaderName() != null) {
|
||||
detailsView.setText(info.getUploaderName());
|
||||
detailsView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
detailsView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
// Get the entry's descriptions which are displayed in the dialog
|
||||
final String[] items = entries.stream()
|
||||
.map(entry -> entry.getString(activity)).toArray(String[]::new);
|
||||
|
||||
// Call an entry's action / onClick method when the entry is selected.
|
||||
final DialogInterface.OnClickListener action = (d, index) ->
|
||||
entries.get(index).action.onClick(fragment, info);
|
||||
|
||||
dialog = new AlertDialog.Builder(activity)
|
||||
.setCustomTitle(bannerView)
|
||||
.setItems(items, action)
|
||||
.create();
|
||||
|
||||
}
|
||||
|
||||
public void show() {
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Builder to generate a {@link InfoItemDialog} for a {@link StreamInfoItem}.</p>
|
||||
* Use {@link #addEntry(StreamDialogDefaultEntry)}
|
||||
* and {@link #addAllEntries(StreamDialogDefaultEntry...)} to add options to the dialog.
|
||||
* <br>
|
||||
* Custom actions for entries can be set using
|
||||
* {@link #setAction(StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)}.
|
||||
*/
|
||||
public static class Builder {
|
||||
@NonNull private final Activity activity;
|
||||
@NonNull private final Context context;
|
||||
@NonNull private final StreamInfoItem infoItem;
|
||||
@NonNull private final Fragment fragment;
|
||||
@NonNull private final List<StreamDialogEntry> entries = new ArrayList<>();
|
||||
private final boolean addDefaultEntriesAutomatically;
|
||||
|
||||
/**
|
||||
* <p>Create a {@link Builder builder} instance for a {@link StreamInfoItem}
|
||||
* that automatically adds the some default entries
|
||||
* at the top and bottom of the dialog.</p>
|
||||
* The dialog has the following structure:
|
||||
* <pre>
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* | ENQUEUE |
|
||||
* | ENQUEUE_NEXT |
|
||||
* | START_ON_BACKGROUND |
|
||||
* | START_ON_POPUP |
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* | entries added manually with |
|
||||
* | addEntry() and addAllEntries() |
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* | APPEND_PLAYLIST |
|
||||
* | SHARE |
|
||||
* | OPEN_IN_BROWSER |
|
||||
* | PLAY_WITH_KODI |
|
||||
* | MARK_AS_WATCHED |
|
||||
* | SHOW_CHANNEL_DETAILS |
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* </pre>
|
||||
* Please note that some entries are not added depending on the user's preferences,
|
||||
* the item's {@link StreamType} and the current player state.
|
||||
*
|
||||
* @param activity
|
||||
* @param context
|
||||
* @param fragment
|
||||
* @param infoItem the item for this dialog; all entries and their actions work with
|
||||
* this {@link StreamInfoItem}
|
||||
* @throws IllegalArgumentException if <code>activity, context</code>
|
||||
* or resources is <code>null</code>
|
||||
*/
|
||||
public Builder(final Activity activity,
|
||||
final Context context,
|
||||
@NonNull final Fragment fragment,
|
||||
@NonNull final StreamInfoItem infoItem) {
|
||||
this(activity, context, fragment, infoItem, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Create an instance of this {@link Builder} for a {@link StreamInfoItem}.</p>
|
||||
* <p>If {@code addDefaultEntriesAutomatically} is set to {@code true},
|
||||
* some default entries are added to the top and bottom of the dialog.</p>
|
||||
* The dialog has the following structure:
|
||||
* <pre>
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* | ENQUEUE |
|
||||
* | ENQUEUE_NEXT |
|
||||
* | START_ON_BACKGROUND |
|
||||
* | START_ON_POPUP |
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* | entries added manually with |
|
||||
* | addEntry() and addAllEntries() |
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* | APPEND_PLAYLIST |
|
||||
* | SHARE |
|
||||
* | OPEN_IN_BROWSER |
|
||||
* | PLAY_WITH_KODI |
|
||||
* | MARK_AS_WATCHED |
|
||||
* | SHOW_CHANNEL_DETAILS |
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* </pre>
|
||||
* Please note that some entries are not added depending on the user's preferences,
|
||||
* the item's {@link StreamType} and the current player state.
|
||||
*
|
||||
* @param activity
|
||||
* @param context
|
||||
* @param fragment
|
||||
* @param infoItem
|
||||
* @param addDefaultEntriesAutomatically
|
||||
* whether default entries added with {@link #addDefaultBeginningEntries()}
|
||||
* and {@link #addDefaultEndEntries()} are added automatically when generating
|
||||
* the {@link InfoItemDialog}.
|
||||
* <br/>
|
||||
* Entries added with {@link #addEntry(StreamDialogDefaultEntry)} and
|
||||
* {@link #addAllEntries(StreamDialogDefaultEntry...)} are added in between.
|
||||
* @throws IllegalArgumentException if <code>activity, context</code>
|
||||
* or resources is <code>null</code>
|
||||
*/
|
||||
public Builder(final Activity activity,
|
||||
final Context context,
|
||||
@NonNull final Fragment fragment,
|
||||
@NonNull final StreamInfoItem infoItem,
|
||||
final boolean addDefaultEntriesAutomatically) {
|
||||
if (activity == null || context == null || context.getResources() == null) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "activity, context or resources is null: activity = "
|
||||
+ activity + ", context = " + context);
|
||||
}
|
||||
throw new IllegalArgumentException("activity, context or resources is null");
|
||||
}
|
||||
this.activity = activity;
|
||||
this.context = context;
|
||||
this.fragment = fragment;
|
||||
this.infoItem = infoItem;
|
||||
this.addDefaultEntriesAutomatically = addDefaultEntriesAutomatically;
|
||||
if (addDefaultEntriesAutomatically) {
|
||||
addDefaultBeginningEntries();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new entry and appends it to the current entry list.
|
||||
* @param entry the entry to add
|
||||
* @return the current {@link Builder} instance
|
||||
*/
|
||||
public Builder addEntry(@NonNull final StreamDialogDefaultEntry entry) {
|
||||
entries.add(entry.toStreamDialogEntry());
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds new entries. These are appended to the current entry list.
|
||||
* @param newEntries the entries to add
|
||||
* @return the current {@link Builder} instance
|
||||
*/
|
||||
public Builder addAllEntries(@NonNull final StreamDialogDefaultEntry... newEntries) {
|
||||
Stream.of(newEntries).forEach(this::addEntry);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Change an entries' action that is called when the entry is selected.</p>
|
||||
* <p><strong>Warning:</strong> Only use this method when the entry has been already added.
|
||||
* Changing the action of an entry which has not been added to the Builder yet
|
||||
* does not have an effect.</p>
|
||||
* @param entry the entry to change
|
||||
* @param action the action to perform when the entry is selected
|
||||
* @return the current {@link Builder} instance
|
||||
*/
|
||||
public Builder setAction(@NonNull final StreamDialogDefaultEntry entry,
|
||||
@NonNull final StreamDialogEntry.StreamDialogEntryAction action) {
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
if (entries.get(i).resource == entry.resource) {
|
||||
entries.set(i, new StreamDialogEntry(entry.resource, action));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds {@link StreamDialogDefaultEntry#ENQUEUE} if the player is open and
|
||||
* {@link StreamDialogDefaultEntry#ENQUEUE_NEXT} if there are multiple streams
|
||||
* in the play queue.
|
||||
* @return the current {@link Builder} instance
|
||||
*/
|
||||
public Builder addEnqueueEntriesIfNeeded() {
|
||||
final PlayerHolder holder = PlayerHolder.getInstance();
|
||||
if (holder.isPlayQueueReady()) {
|
||||
addEntry(StreamDialogDefaultEntry.ENQUEUE);
|
||||
|
||||
if (holder.getQueuePosition() < holder.getQueueSize() - 1) {
|
||||
addEntry(StreamDialogDefaultEntry.ENQUEUE_NEXT);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the {@link StreamDialogDefaultEntry#START_HERE_ON_BACKGROUND}.
|
||||
* If the {@link #infoItem} is not a pure audio (live) stream,
|
||||
* {@link StreamDialogDefaultEntry#START_HERE_ON_POPUP} is added, too.
|
||||
* @return the current {@link Builder} instance
|
||||
*/
|
||||
public Builder addStartHereEntries() {
|
||||
addEntry(StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND);
|
||||
if (!StreamTypeUtil.isAudio(infoItem.getStreamType())) {
|
||||
addEntry(StreamDialogDefaultEntry.START_HERE_ON_POPUP);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds {@link StreamDialogDefaultEntry#MARK_AS_WATCHED} if the watch history is enabled
|
||||
* and the stream is not a livestream.
|
||||
* @return the current {@link Builder} instance
|
||||
*/
|
||||
public Builder addMarkAsWatchedEntryIfNeeded() {
|
||||
final boolean isWatchHistoryEnabled = PreferenceManager
|
||||
.getDefaultSharedPreferences(context)
|
||||
.getBoolean(context.getString(R.string.enable_watch_history_key), false);
|
||||
if (isWatchHistoryEnabled && !StreamTypeUtil.isLiveStream(infoItem.getStreamType())) {
|
||||
addEntry(StreamDialogDefaultEntry.MARK_AS_WATCHED);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the {@link StreamDialogDefaultEntry#PLAY_WITH_KODI} entry if it is needed.
|
||||
* @return the current {@link Builder} instance
|
||||
*/
|
||||
public Builder addPlayWithKodiEntryIfNeeded() {
|
||||
if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) {
|
||||
addEntry(StreamDialogDefaultEntry.PLAY_WITH_KODI);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the entries which are usually at the top of the action list.
|
||||
* <br/>
|
||||
* This method adds the "enqueue" (see {@link #addEnqueueEntriesIfNeeded()})
|
||||
* and "start here" (see {@link #addStartHereEntries()} entries.
|
||||
* @return the current {@link Builder} instance
|
||||
*/
|
||||
public Builder addDefaultBeginningEntries() {
|
||||
addEnqueueEntriesIfNeeded();
|
||||
addStartHereEntries();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the entries which are usually at the bottom of the action list.
|
||||
* @return the current {@link Builder} instance
|
||||
*/
|
||||
public Builder addDefaultEndEntries() {
|
||||
addAllEntries(
|
||||
StreamDialogDefaultEntry.DOWNLOAD,
|
||||
StreamDialogDefaultEntry.APPEND_PLAYLIST,
|
||||
StreamDialogDefaultEntry.SHARE,
|
||||
StreamDialogDefaultEntry.OPEN_IN_BROWSER
|
||||
);
|
||||
addPlayWithKodiEntryIfNeeded();
|
||||
addMarkAsWatchedEntryIfNeeded();
|
||||
addEntry(StreamDialogDefaultEntry.SHOW_CHANNEL_DETAILS);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the {@link InfoItemDialog}.
|
||||
* @return a new instance of {@link InfoItemDialog}
|
||||
*/
|
||||
public InfoItemDialog create() {
|
||||
if (addDefaultEntriesAutomatically) {
|
||||
addDefaultEndEntries();
|
||||
}
|
||||
return new InfoItemDialog(this.activity, this.fragment, this.infoItem, this.entries);
|
||||
}
|
||||
|
||||
public static void reportErrorDuringInitialization(final Throwable throwable,
|
||||
final InfoItem item) {
|
||||
ErrorUtil.showSnackbar(App.getApp().getBaseContext(), new ErrorInfo(
|
||||
throwable,
|
||||
UserAction.OPEN_INFO_ITEM_DIALOG,
|
||||
"none",
|
||||
item.getServiceId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package org.schabi.newpipe.info_list.dialog;
|
||||
|
||||
import static org.schabi.newpipe.util.NavigationHelper.openChannelFragment;
|
||||
import static org.schabi.newpipe.util.SparseItemUtil.fetchItemInfoIfSparse;
|
||||
import static org.schabi.newpipe.util.SparseItemUtil.fetchStreamInfoAndSaveToDatabase;
|
||||
import static org.schabi.newpipe.util.SparseItemUtil.fetchUploaderUrlIfSparse;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.StringRes;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.download.DownloadDialog;
|
||||
import org.schabi.newpipe.local.dialog.PlaylistAppendDialog;
|
||||
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* This enum provides entries that are accepted
|
||||
* by the {@link InfoItemDialog.Builder}.
|
||||
* </p>
|
||||
* <p>
|
||||
* These entries contain a String {@link #resource} which is displayed in the dialog and
|
||||
* a default {@link #action} that is executed
|
||||
* when the entry is selected (via <code>onClick()</code>).
|
||||
* <br/>
|
||||
* They action can be overridden by using the Builder's
|
||||
* {@link InfoItemDialog.Builder#setAction(
|
||||
* StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)}
|
||||
* method.
|
||||
* </p>
|
||||
*/
|
||||
public enum StreamDialogDefaultEntry {
|
||||
SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) ->
|
||||
fetchUploaderUrlIfSparse(fragment.requireContext(), item.getServiceId(), item.getUrl(),
|
||||
item.getUploaderUrl(), url -> openChannelFragment(fragment, item, url))
|
||||
),
|
||||
|
||||
/**
|
||||
* Enqueues the stream automatically to the current PlayerType.
|
||||
*/
|
||||
ENQUEUE(R.string.enqueue_stream, (fragment, item) ->
|
||||
fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue ->
|
||||
NavigationHelper.enqueueOnPlayer(fragment.getContext(), singlePlayQueue))
|
||||
),
|
||||
|
||||
/**
|
||||
* Enqueues the stream automatically to the current PlayerType
|
||||
* after the currently playing stream.
|
||||
*/
|
||||
ENQUEUE_NEXT(R.string.enqueue_next_stream, (fragment, item) ->
|
||||
fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue ->
|
||||
NavigationHelper.enqueueNextOnPlayer(fragment.getContext(), singlePlayQueue))
|
||||
),
|
||||
|
||||
START_HERE_ON_BACKGROUND(R.string.start_here_on_background, (fragment, item) ->
|
||||
fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue ->
|
||||
NavigationHelper.playOnBackgroundPlayer(
|
||||
fragment.getContext(), singlePlayQueue, true))),
|
||||
|
||||
START_HERE_ON_POPUP(R.string.start_here_on_popup, (fragment, item) ->
|
||||
fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue ->
|
||||
NavigationHelper.playOnPopupPlayer(fragment.getContext(), singlePlayQueue, true))),
|
||||
|
||||
SET_AS_PLAYLIST_THUMBNAIL(R.string.set_as_playlist_thumbnail, (fragment, item) -> {
|
||||
throw new UnsupportedOperationException("This needs to be implemented manually "
|
||||
+ "by using InfoItemDialog.Builder.setAction()");
|
||||
}),
|
||||
|
||||
DELETE(R.string.delete, (fragment, item) -> {
|
||||
throw new UnsupportedOperationException("This needs to be implemented manually "
|
||||
+ "by using InfoItemDialog.Builder.setAction()");
|
||||
}),
|
||||
|
||||
/**
|
||||
* Opens a {@link PlaylistDialog} to either append the stream to a playlist
|
||||
* or create a new playlist if there are no local playlists.
|
||||
*/
|
||||
APPEND_PLAYLIST(R.string.add_to_playlist, (fragment, item) ->
|
||||
PlaylistDialog.createCorrespondingDialog(
|
||||
fragment.getContext(),
|
||||
List.of(new StreamEntity(item)),
|
||||
dialog -> dialog.show(
|
||||
fragment.getParentFragmentManager(),
|
||||
"StreamDialogEntry@"
|
||||
+ (dialog instanceof PlaylistAppendDialog ? "append" : "create")
|
||||
+ "_playlist"
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
PLAY_WITH_KODI(R.string.play_with_kodi_title, (fragment, item) ->
|
||||
KoreUtils.playWithKore(fragment.requireContext(), Uri.parse(item.getUrl()))),
|
||||
|
||||
SHARE(R.string.share, (fragment, item) ->
|
||||
ShareUtils.shareText(fragment.requireContext(), item.getName(), item.getUrl(),
|
||||
item.getThumbnailUrl())),
|
||||
|
||||
/**
|
||||
* Opens a {@link DownloadDialog} after fetching some stream info.
|
||||
* If the user quits the current fragment, it will not open a DownloadDialog.
|
||||
*/
|
||||
DOWNLOAD(R.string.download, (fragment, item) ->
|
||||
fetchStreamInfoAndSaveToDatabase(fragment.requireContext(), item.getServiceId(),
|
||||
item.getUrl(), info -> {
|
||||
if (fragment.getContext() != null) {
|
||||
final DownloadDialog downloadDialog =
|
||||
new DownloadDialog(fragment.requireContext(), info);
|
||||
downloadDialog.show(fragment.getChildFragmentManager(),
|
||||
"downloadDialog");
|
||||
}
|
||||
})
|
||||
),
|
||||
|
||||
OPEN_IN_BROWSER(R.string.open_in_browser, (fragment, item) ->
|
||||
ShareUtils.openUrlInBrowser(fragment.requireContext(), item.getUrl())),
|
||||
|
||||
|
||||
MARK_AS_WATCHED(R.string.mark_as_watched, (fragment, item) ->
|
||||
new HistoryRecordManager(fragment.getContext())
|
||||
.markAsWatched(item)
|
||||
.onErrorComplete()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe()
|
||||
);
|
||||
|
||||
|
||||
@StringRes
|
||||
public final int resource;
|
||||
@NonNull
|
||||
public final StreamDialogEntry.StreamDialogEntryAction action;
|
||||
|
||||
StreamDialogDefaultEntry(@StringRes final int resource,
|
||||
@NonNull final StreamDialogEntry.StreamDialogEntryAction action) {
|
||||
this.resource = resource;
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public StreamDialogEntry toStreamDialogEntry() {
|
||||
return new StreamDialogEntry(resource, action);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.schabi.newpipe.info_list.dialog;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
|
||||
public class StreamDialogEntry {
|
||||
|
||||
@StringRes
|
||||
public final int resource;
|
||||
@NonNull
|
||||
public final StreamDialogEntryAction action;
|
||||
|
||||
public StreamDialogEntry(@StringRes final int resource,
|
||||
@NonNull final StreamDialogEntryAction action) {
|
||||
this.resource = resource;
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
public String getString(@NonNull final Context context) {
|
||||
return context.getString(resource);
|
||||
}
|
||||
|
||||
public interface StreamDialogEntryAction {
|
||||
void onClick(Fragment fragment, StreamInfoItem infoItem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.schabi.newpipe.info_list.holder;
|
||||
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||
|
||||
public class ChannelCardInfoItemHolder extends ChannelMiniInfoItemHolder {
|
||||
public ChannelCardInfoItemHolder(final InfoItemBuilder infoItemBuilder,
|
||||
final ViewGroup parent) {
|
||||
super(infoItemBuilder, R.layout.list_channel_card_item, parent);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getDescriptionMaxLineCount(@Nullable final String content) {
|
||||
// Based on `list_channel_card_item` left side content (thumbnail 100dp
|
||||
// + additional details), Right side description can grow up to 8 lines.
|
||||
return 8;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
package org.schabi.newpipe.info_list.holder;
|
||||
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
||||
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
|
||||
/*
|
||||
* Created by Christian Schabesberger on 12.02.17.
|
||||
@@ -31,40 +26,7 @@ import org.schabi.newpipe.util.Localization;
|
||||
*/
|
||||
|
||||
public class ChannelInfoItemHolder extends ChannelMiniInfoItemHolder {
|
||||
private final TextView itemChannelDescriptionView;
|
||||
|
||||
public ChannelInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) {
|
||||
super(infoItemBuilder, R.layout.list_channel_item, parent);
|
||||
itemChannelDescriptionView = itemView.findViewById(R.id.itemChannelDescriptionView);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateFromItem(final InfoItem infoItem,
|
||||
final HistoryRecordManager historyRecordManager) {
|
||||
super.updateFromItem(infoItem, historyRecordManager);
|
||||
|
||||
if (!(infoItem instanceof ChannelInfoItem)) {
|
||||
return;
|
||||
}
|
||||
final ChannelInfoItem item = (ChannelInfoItem) infoItem;
|
||||
|
||||
itemChannelDescriptionView.setText(item.getDescription());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getDetailLine(final ChannelInfoItem item) {
|
||||
String details = super.getDetailLine(item);
|
||||
|
||||
if (item.getStreamCount() >= 0) {
|
||||
final String formattedVideoAmount = Localization.localizeStreamCount(
|
||||
itemBuilder.getContext(), item.getStreamCount());
|
||||
|
||||
if (!details.isEmpty()) {
|
||||
details += " • " + formattedVideoAmount;
|
||||
} else {
|
||||
details = formattedVideoAmount;
|
||||
}
|
||||
}
|
||||
return details;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
package org.schabi.newpipe.info_list.holder;
|
||||
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
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.Localization;
|
||||
|
||||
import de.hdodenhof.circleimageview.CircleImageView;
|
||||
|
||||
public class ChannelMiniInfoItemHolder extends InfoItemHolder {
|
||||
public final CircleImageView itemThumbnailView;
|
||||
public final TextView itemTitleView;
|
||||
private final ImageView itemThumbnailView;
|
||||
private final TextView itemTitleView;
|
||||
private final TextView itemAdditionalDetailView;
|
||||
private final TextView itemChannelDescriptionView;
|
||||
|
||||
ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId,
|
||||
final ViewGroup parent) {
|
||||
@@ -25,6 +29,7 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder {
|
||||
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
|
||||
itemTitleView = itemView.findViewById(R.id.itemTitleView);
|
||||
itemAdditionalDetailView = itemView.findViewById(R.id.itemAdditionalDetails);
|
||||
itemChannelDescriptionView = itemView.findViewById(R.id.itemChannelDescriptionView);
|
||||
}
|
||||
|
||||
public ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder,
|
||||
@@ -41,9 +46,17 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder {
|
||||
final ChannelInfoItem item = (ChannelInfoItem) infoItem;
|
||||
|
||||
itemTitleView.setText(item.getName());
|
||||
itemAdditionalDetailView.setText(getDetailLine(item));
|
||||
itemTitleView.setSelected(true);
|
||||
|
||||
PicassoHelper.loadThumbnail(item.getThumbnailUrl()).into(itemThumbnailView);
|
||||
final String detailLine = getDetailLine(item);
|
||||
if (detailLine == null) {
|
||||
itemAdditionalDetailView.setVisibility(View.GONE);
|
||||
} else {
|
||||
itemAdditionalDetailView.setVisibility(View.VISIBLE);
|
||||
itemAdditionalDetailView.setText(getDetailLine(item));
|
||||
}
|
||||
|
||||
PicassoHelper.loadAvatar(item.getThumbnailUrl()).into(itemThumbnailView);
|
||||
|
||||
itemView.setOnClickListener(view -> {
|
||||
if (itemBuilder.getOnChannelSelectedListener() != null) {
|
||||
@@ -57,14 +70,48 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder {
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (itemChannelDescriptionView != null) {
|
||||
// itemChannelDescriptionView will be null in the mini variant
|
||||
if (Utils.isBlank(item.getDescription())) {
|
||||
itemChannelDescriptionView.setVisibility(View.GONE);
|
||||
} else {
|
||||
itemChannelDescriptionView.setVisibility(View.VISIBLE);
|
||||
itemChannelDescriptionView.setText(item.getDescription());
|
||||
// setMaxLines utilize the line space for description if the additional details
|
||||
// (sub / video count) are not present.
|
||||
// Case1: 2 lines of description + 1 line additional details
|
||||
// Case2: 3 lines of description (additionalDetails is GONE)
|
||||
itemChannelDescriptionView.setMaxLines(getDescriptionMaxLineCount(detailLine));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected String getDetailLine(final ChannelInfoItem item) {
|
||||
String details = "";
|
||||
if (item.getSubscriberCount() >= 0) {
|
||||
details += Localization.shortSubscriberCount(itemBuilder.getContext(),
|
||||
/**
|
||||
* Returns max number of allowed lines for the description field.
|
||||
* @param content additional detail content (video / sub count)
|
||||
* @return max line count
|
||||
*/
|
||||
protected int getDescriptionMaxLineCount(@Nullable final String content) {
|
||||
return content == null ? 3 : 2;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String getDetailLine(final ChannelInfoItem item) {
|
||||
if (item.getStreamCount() >= 0 && item.getSubscriberCount() >= 0) {
|
||||
return Localization.concatenateStrings(
|
||||
Localization.shortSubscriberCount(itemBuilder.getContext(),
|
||||
item.getSubscriberCount()),
|
||||
Localization.localizeStreamCount(itemBuilder.getContext(),
|
||||
item.getStreamCount()));
|
||||
} else if (item.getStreamCount() >= 0) {
|
||||
return Localization.localizeStreamCount(itemBuilder.getContext(),
|
||||
item.getStreamCount());
|
||||
} else if (item.getSubscriberCount() >= 0) {
|
||||
return Localization.shortSubscriberCount(itemBuilder.getContext(),
|
||||
item.getSubscriberCount());
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return details;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,48 @@
|
||||
package org.schabi.newpipe.info_list.holder;
|
||||
|
||||
import android.text.TextUtils;
|
||||
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.text.util.Linkify;
|
||||
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.CommentTextOnTouchListener;
|
||||
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.TimestampExtractor;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
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.regex.Matcher;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import de.hdodenhof.circleimageview.CircleImageView;
|
||||
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;
|
||||
@@ -39,35 +50,19 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
||||
private final int commentHorizontalPadding;
|
||||
private final int commentVerticalPadding;
|
||||
|
||||
private final Paint paintAtContentSize;
|
||||
private final float ellipsisWidthPx;
|
||||
|
||||
private final RelativeLayout itemRoot;
|
||||
public final CircleImageView itemThumbnailView;
|
||||
private final ImageView itemThumbnailView;
|
||||
private final TextView itemContentView;
|
||||
private final TextView itemLikesCountView;
|
||||
private final TextView itemPublishedTime;
|
||||
|
||||
private String commentText;
|
||||
private String streamUrl;
|
||||
|
||||
private final Linkify.TransformFilter timestampLink = new Linkify.TransformFilter() {
|
||||
@Override
|
||||
public String transformUrl(final Matcher match, final String url) {
|
||||
try {
|
||||
final TimestampExtractor.TimestampMatchDTO timestampMatchDTO =
|
||||
TimestampExtractor.getTimestampFromMatcher(match, commentText);
|
||||
|
||||
if (timestampMatchDTO == null) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return streamUrl + url.replace(
|
||||
match.group(0),
|
||||
"#timestamp=" + timestampMatchDTO.seconds());
|
||||
} catch (final Exception ex) {
|
||||
Log.e(TAG, "Unable to process url='" + url + "' as timestampLink", ex);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
};
|
||||
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) {
|
||||
@@ -83,6 +78,10 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
||||
.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,
|
||||
@@ -112,18 +111,20 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
||||
|
||||
itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item));
|
||||
|
||||
streamUrl = item.getUrl();
|
||||
|
||||
itemContentView.setLines(COMMENT_DEFAULT_LINES);
|
||||
commentText = item.getCommentText();
|
||||
itemContentView.setText(commentText);
|
||||
itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE);
|
||||
|
||||
if (itemContentView.getLineCount() == 0) {
|
||||
itemContentView.post(this::ellipsize);
|
||||
} else {
|
||||
ellipsize();
|
||||
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(
|
||||
@@ -153,14 +154,17 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
||||
if (DeviceUtils.isTv(itemBuilder.getContext())) {
|
||||
openCommentAuthor(item);
|
||||
} else {
|
||||
ShareUtils.copyToClipboard(itemBuilder.getContext(), commentText);
|
||||
final CharSequence text = itemContentView.getText();
|
||||
if (text != null) {
|
||||
ShareUtils.copyToClipboard(itemBuilder.getContext(), text.toString());
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private void openCommentAuthor(final CommentsInfoItem item) {
|
||||
if (TextUtils.isEmpty(item.getUploaderUrl())) {
|
||||
if (isEmpty(item.getUploaderUrl())) {
|
||||
return;
|
||||
}
|
||||
final AppCompatActivity activity = (AppCompatActivity) itemBuilder.getContext();
|
||||
@@ -193,7 +197,7 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
||||
return urls != null && urls.length != 0;
|
||||
}
|
||||
|
||||
private void determineLinkFocus() {
|
||||
private void determineMovementMethod() {
|
||||
if (shouldFocusLinks()) {
|
||||
allowLinkFocus();
|
||||
} else {
|
||||
@@ -202,55 +206,74 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
||||
}
|
||||
|
||||
private void ellipsize() {
|
||||
boolean hasEllipsis = false;
|
||||
itemContentView.setMaxLines(COMMENT_EXPANDED_LINES);
|
||||
linkifyCommentContentView(v -> {
|
||||
boolean hasEllipsis = false;
|
||||
|
||||
if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
|
||||
final int endOfLastLine
|
||||
= itemContentView.getLayout().getLineEnd(COMMENT_DEFAULT_LINES - 1);
|
||||
int end = itemContentView.getText().toString().lastIndexOf(' ', endOfLastLine - 2);
|
||||
if (end == -1) {
|
||||
end = Math.max(endOfLastLine - 2, 0);
|
||||
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;
|
||||
}
|
||||
final String newVal = itemContentView.getText().subSequence(0, end) + " …";
|
||||
itemContentView.setText(newVal);
|
||||
hasEllipsis = true;
|
||||
}
|
||||
|
||||
linkify();
|
||||
|
||||
if (hasEllipsis) {
|
||||
denyLinkFocus();
|
||||
} else {
|
||||
determineLinkFocus();
|
||||
}
|
||||
itemContentView.setMaxLines(COMMENT_DEFAULT_LINES);
|
||||
if (hasEllipsis) {
|
||||
denyLinkFocus();
|
||||
} else {
|
||||
determineMovementMethod();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void toggleEllipsize() {
|
||||
if (itemContentView.getText().toString().equals(commentText)) {
|
||||
if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
|
||||
ellipsize();
|
||||
}
|
||||
} else {
|
||||
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);
|
||||
itemContentView.setText(commentText);
|
||||
linkify();
|
||||
determineLinkFocus();
|
||||
linkifyCommentContentView(v -> determineMovementMethod());
|
||||
}
|
||||
|
||||
private void linkify() {
|
||||
Linkify.addLinks(
|
||||
itemContentView,
|
||||
Linkify.WEB_URLS);
|
||||
Linkify.addLinks(
|
||||
itemContentView,
|
||||
TimestampExtractor.TIMESTAMPS_PATTERN,
|
||||
null,
|
||||
null,
|
||||
timestampLink);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.schabi.newpipe.info_list.holder;
|
||||
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||
|
||||
/**
|
||||
* Playlist card layout.
|
||||
*/
|
||||
public class PlaylistCardInfoItemHolder extends PlaylistMiniInfoItemHolder {
|
||||
|
||||
public PlaylistCardInfoItemHolder(final InfoItemBuilder infoItemBuilder,
|
||||
final ViewGroup parent) {
|
||||
super(infoItemBuilder, R.layout.list_playlist_card_item, parent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.schabi.newpipe.info_list.holder;
|
||||
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||
|
||||
/**
|
||||
* Card layout for stream.
|
||||
*/
|
||||
public class StreamCardInfoItemHolder extends StreamInfoItemHolder {
|
||||
|
||||
public StreamCardInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) {
|
||||
super(infoItemBuilder, R.layout.list_stream_card_item, parent);
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,13 @@ import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||
import org.schabi.newpipe.ktx.ViewUtils;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.util.DependentPreferenceHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.util.StreamTypeUtil;
|
||||
import org.schabi.newpipe.views.AnimatedProgressBar;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -60,8 +61,12 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
||||
R.color.duration_background_color));
|
||||
itemDurationView.setVisibility(View.VISIBLE);
|
||||
|
||||
final StreamStateEntity state2 = historyRecordManager.loadStreamState(infoItem)
|
||||
.blockingGet()[0];
|
||||
StreamStateEntity state2 = null;
|
||||
if (DependentPreferenceHelper
|
||||
.getPositionsInListsEnabled(itemProgressView.getContext())) {
|
||||
state2 = historyRecordManager.loadStreamState(infoItem)
|
||||
.blockingGet()[0];
|
||||
}
|
||||
if (state2 != null) {
|
||||
itemProgressView.setVisibility(View.VISIBLE);
|
||||
itemProgressView.setMax((int) item.getDuration());
|
||||
@@ -70,8 +75,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
||||
} else {
|
||||
itemProgressView.setVisibility(View.GONE);
|
||||
}
|
||||
} else if (item.getStreamType() == StreamType.LIVE_STREAM
|
||||
|| item.getStreamType() == StreamType.AUDIO_LIVE_STREAM) {
|
||||
} else if (StreamTypeUtil.isLiveStream(item.getStreamType())) {
|
||||
itemDurationView.setText(R.string.duration_live);
|
||||
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
|
||||
R.color.live_duration_background_color));
|
||||
@@ -96,9 +100,10 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
||||
case VIDEO_STREAM:
|
||||
case LIVE_STREAM:
|
||||
case AUDIO_LIVE_STREAM:
|
||||
case POST_LIVE_STREAM:
|
||||
case POST_LIVE_AUDIO_STREAM:
|
||||
enableLongClick(item);
|
||||
break;
|
||||
case FILE:
|
||||
case NONE:
|
||||
default:
|
||||
disableLongClick();
|
||||
@@ -111,10 +116,14 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
||||
final HistoryRecordManager historyRecordManager) {
|
||||
final StreamInfoItem item = (StreamInfoItem) infoItem;
|
||||
|
||||
final StreamStateEntity state
|
||||
= historyRecordManager.loadStreamState(infoItem).blockingGet()[0];
|
||||
StreamStateEntity state = null;
|
||||
if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())) {
|
||||
state = historyRecordManager
|
||||
.loadStreamState(infoItem)
|
||||
.blockingGet()[0];
|
||||
}
|
||||
if (state != null && item.getDuration() > 0
|
||||
&& item.getStreamType() != StreamType.LIVE_STREAM) {
|
||||
&& !StreamTypeUtil.isLiveStream(item.getStreamType())) {
|
||||
itemProgressView.setMax((int) item.getDuration());
|
||||
if (itemProgressView.getVisibility() == View.VISIBLE) {
|
||||
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
|
||||
|
||||
@@ -21,10 +21,6 @@ import org.schabi.newpipe.MainActivity
|
||||
|
||||
private const val TAG = "ViewUtils"
|
||||
|
||||
inline var View.backgroundTintListCompat: ColorStateList?
|
||||
get() = ViewCompat.getBackgroundTintList(this)
|
||||
set(value) = ViewCompat.setBackgroundTintList(this, value)
|
||||
|
||||
/**
|
||||
* Animate the view.
|
||||
*
|
||||
@@ -96,62 +92,43 @@ fun View.animateBackgroundColor(duration: Long, @ColorInt colorStart: Int, @Colo
|
||||
if (MainActivity.DEBUG) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"animateBackgroundColor() called with: " +
|
||||
"view = [" + this + "], duration = [" + duration + "], " +
|
||||
"colorStart = [" + colorStart + "], colorEnd = [" + colorEnd + "]"
|
||||
"animateBackgroundColor() called with: view = [$this], duration = [$duration], " +
|
||||
"colorStart = [$colorStart], colorEnd = [$colorEnd]"
|
||||
)
|
||||
}
|
||||
val empty = arrayOf(IntArray(0))
|
||||
val viewPropertyAnimator = ValueAnimator.ofObject(ArgbEvaluator(), colorStart, colorEnd)
|
||||
viewPropertyAnimator.interpolator = FastOutSlowInInterpolator()
|
||||
viewPropertyAnimator.duration = duration
|
||||
viewPropertyAnimator.addUpdateListener { animation: ValueAnimator ->
|
||||
backgroundTintListCompat = ColorStateList(empty, intArrayOf(animation.animatedValue as Int))
|
||||
|
||||
fun listenerAction(color: Int) {
|
||||
ViewCompat.setBackgroundTintList(this, ColorStateList.valueOf(color))
|
||||
}
|
||||
viewPropertyAnimator.addListener(
|
||||
onCancel = { backgroundTintListCompat = ColorStateList(empty, intArrayOf(colorEnd)) },
|
||||
onEnd = { backgroundTintListCompat = ColorStateList(empty, intArrayOf(colorEnd)) }
|
||||
)
|
||||
viewPropertyAnimator.addUpdateListener { listenerAction(it.animatedValue as Int) }
|
||||
viewPropertyAnimator.addListener(onCancel = { listenerAction(colorEnd) }, onEnd = { listenerAction(colorEnd) })
|
||||
viewPropertyAnimator.start()
|
||||
}
|
||||
|
||||
fun View.animateHeight(duration: Long, targetHeight: Int): ValueAnimator {
|
||||
if (MainActivity.DEBUG) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"animateHeight: duration = [" + duration + "], " +
|
||||
"from " + height + " to → " + targetHeight + " in: " + this
|
||||
)
|
||||
Log.d(TAG, "animateHeight: duration = [$duration], from $height to → $targetHeight in: $this")
|
||||
}
|
||||
val animator = ValueAnimator.ofFloat(height.toFloat(), targetHeight.toFloat())
|
||||
animator.interpolator = FastOutSlowInInterpolator()
|
||||
animator.duration = duration
|
||||
animator.addUpdateListener { animation: ValueAnimator ->
|
||||
val value = animation.animatedValue as Float
|
||||
layoutParams.height = value.toInt()
|
||||
|
||||
fun listenerAction(value: Int) {
|
||||
layoutParams.height = value
|
||||
requestLayout()
|
||||
}
|
||||
animator.addListener(
|
||||
onCancel = {
|
||||
layoutParams.height = targetHeight
|
||||
requestLayout()
|
||||
},
|
||||
onEnd = {
|
||||
layoutParams.height = targetHeight
|
||||
requestLayout()
|
||||
}
|
||||
)
|
||||
animator.addUpdateListener { listenerAction((it.animatedValue as Float).toInt()) }
|
||||
animator.addListener(onCancel = { listenerAction(targetHeight) }, onEnd = { listenerAction(targetHeight) })
|
||||
animator.start()
|
||||
return animator
|
||||
}
|
||||
|
||||
fun View.animateRotation(duration: Long, targetRotation: Int) {
|
||||
if (MainActivity.DEBUG) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"animateRotation: duration = [" + duration + "], " +
|
||||
"from " + rotation + " to → " + targetRotation + " in: " + this
|
||||
)
|
||||
Log.d(TAG, "animateRotation: duration = [$duration], from $rotation to → $targetRotation in: $this")
|
||||
}
|
||||
animate().setListener(null).cancel()
|
||||
animate()
|
||||
@@ -172,20 +149,13 @@ private fun View.animateAlpha(enterOrExit: Boolean, duration: Long, delay: Long,
|
||||
if (enterOrExit) {
|
||||
animate().setInterpolator(FastOutSlowInInterpolator()).alpha(1f)
|
||||
.setDuration(duration).setStartDelay(delay)
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
execOnEnd?.run()
|
||||
}
|
||||
}).start()
|
||||
.setListener(ExecOnEndListener(execOnEnd))
|
||||
.start()
|
||||
} else {
|
||||
animate().setInterpolator(FastOutSlowInInterpolator()).alpha(0f)
|
||||
.setDuration(duration).setStartDelay(delay)
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
isGone = true
|
||||
execOnEnd?.run()
|
||||
}
|
||||
}).start()
|
||||
.setListener(HideAndExecOnEndListener(this, execOnEnd))
|
||||
.start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,11 +167,8 @@ private fun View.animateScaleAndAlpha(enterOrExit: Boolean, duration: Long, dela
|
||||
.setInterpolator(FastOutSlowInInterpolator())
|
||||
.alpha(1f).scaleX(1f).scaleY(1f)
|
||||
.setDuration(duration).setStartDelay(delay)
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
execOnEnd?.run()
|
||||
}
|
||||
}).start()
|
||||
.setListener(ExecOnEndListener(execOnEnd))
|
||||
.start()
|
||||
} else {
|
||||
scaleX = 1f
|
||||
scaleY = 1f
|
||||
@@ -209,12 +176,8 @@ private fun View.animateScaleAndAlpha(enterOrExit: Boolean, duration: Long, dela
|
||||
.setInterpolator(FastOutSlowInInterpolator())
|
||||
.alpha(0f).scaleX(.8f).scaleY(.8f)
|
||||
.setDuration(duration).setStartDelay(delay)
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
isGone = true
|
||||
execOnEnd?.run()
|
||||
}
|
||||
}).start()
|
||||
.setListener(HideAndExecOnEndListener(this, execOnEnd))
|
||||
.start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,11 +190,8 @@ private fun View.animateLightScaleAndAlpha(enterOrExit: Boolean, duration: Long,
|
||||
.setInterpolator(FastOutSlowInInterpolator())
|
||||
.alpha(1f).scaleX(1f).scaleY(1f)
|
||||
.setDuration(duration).setStartDelay(delay)
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
execOnEnd?.run()
|
||||
}
|
||||
}).start()
|
||||
.setListener(ExecOnEndListener(execOnEnd))
|
||||
.start()
|
||||
} else {
|
||||
alpha = 1f
|
||||
scaleX = 1f
|
||||
@@ -240,12 +200,8 @@ private fun View.animateLightScaleAndAlpha(enterOrExit: Boolean, duration: Long,
|
||||
.setInterpolator(FastOutSlowInInterpolator())
|
||||
.alpha(0f).scaleX(.95f).scaleY(.95f)
|
||||
.setDuration(duration).setStartDelay(delay)
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
isGone = true
|
||||
execOnEnd?.run()
|
||||
}
|
||||
}).start()
|
||||
.setListener(HideAndExecOnEndListener(this, execOnEnd))
|
||||
.start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,22 +212,15 @@ private fun View.animateSlideAndAlpha(enterOrExit: Boolean, duration: Long, dela
|
||||
animate()
|
||||
.setInterpolator(FastOutSlowInInterpolator()).alpha(1f).translationY(0f)
|
||||
.setDuration(duration).setStartDelay(delay)
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
execOnEnd?.run()
|
||||
}
|
||||
}).start()
|
||||
.setListener(ExecOnEndListener(execOnEnd))
|
||||
.start()
|
||||
} else {
|
||||
animate()
|
||||
.setInterpolator(FastOutSlowInInterpolator())
|
||||
.alpha(0f).translationY(-height.toFloat())
|
||||
.setDuration(duration).setStartDelay(delay)
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
isGone = true
|
||||
execOnEnd?.run()
|
||||
}
|
||||
}).start()
|
||||
.setListener(HideAndExecOnEndListener(this, execOnEnd))
|
||||
.start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,32 +231,18 @@ private fun View.animateLightSlideAndAlpha(enterOrExit: Boolean, duration: Long,
|
||||
animate()
|
||||
.setInterpolator(FastOutSlowInInterpolator()).alpha(1f).translationY(0f)
|
||||
.setDuration(duration).setStartDelay(delay)
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
execOnEnd?.run()
|
||||
}
|
||||
}).start()
|
||||
.setListener(ExecOnEndListener(execOnEnd))
|
||||
.start()
|
||||
} else {
|
||||
animate().setInterpolator(FastOutSlowInInterpolator())
|
||||
.alpha(0f).translationY(-height / 2.0f)
|
||||
.setDuration(duration).setStartDelay(delay)
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
isGone = true
|
||||
execOnEnd?.run()
|
||||
}
|
||||
}).start()
|
||||
.setListener(HideAndExecOnEndListener(this, execOnEnd))
|
||||
.start()
|
||||
}
|
||||
}
|
||||
|
||||
fun View.slideUp(
|
||||
duration: Long,
|
||||
delay: Long,
|
||||
@FloatRange(from = 0.0, to = 1.0) translationPercent: Float
|
||||
) {
|
||||
slideUp(duration, delay, translationPercent, null)
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun View.slideUp(
|
||||
duration: Long,
|
||||
delay: Long = 0L,
|
||||
@@ -325,11 +260,7 @@ fun View.slideUp(
|
||||
.setStartDelay(delay)
|
||||
.setDuration(duration)
|
||||
.setInterpolator(FastOutSlowInInterpolator())
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
execOnEnd?.run()
|
||||
}
|
||||
})
|
||||
.setListener(ExecOnEndListener(execOnEnd))
|
||||
.start()
|
||||
}
|
||||
|
||||
@@ -343,6 +274,20 @@ fun View.animateHideRecyclerViewAllowingScrolling() {
|
||||
animate().alpha(0.0f).setDuration(200).start()
|
||||
}
|
||||
|
||||
private open class ExecOnEndListener(private val execOnEnd: Runnable?) : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
execOnEnd?.run()
|
||||
}
|
||||
}
|
||||
|
||||
private class HideAndExecOnEndListener(private val view: View, execOnEnd: Runnable?) :
|
||||
ExecOnEndListener(execOnEnd) {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
view.isGone = true
|
||||
super.onAnimationEnd(animation)
|
||||
}
|
||||
}
|
||||
|
||||
enum class AnimationType {
|
||||
ALPHA, SCALE_AND_ALPHA, LIGHT_SCALE_AND_ALPHA, SLIDE_AND_ALPHA, LIGHT_SLIDE_AND_ALPHA
|
||||
}
|
||||
|
||||
@@ -22,10 +22,11 @@ import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.PignateFooterBinding;
|
||||
import org.schabi.newpipe.fragments.BaseStateFragment;
|
||||
import org.schabi.newpipe.fragments.list.ListViewContract;
|
||||
import org.schabi.newpipe.info_list.ItemViewMode;
|
||||
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
|
||||
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
|
||||
import static org.schabi.newpipe.util.ThemeHelper.getItemViewMode;
|
||||
|
||||
/**
|
||||
* This fragment is design to be used with persistent data such as
|
||||
@@ -77,16 +78,23 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
|
||||
super.onResume();
|
||||
if (updateFlags != 0) {
|
||||
if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) {
|
||||
final boolean useGrid = shouldUseGridLayout(requireContext());
|
||||
itemsList.setLayoutManager(
|
||||
useGrid ? getGridLayoutManager() : getListLayoutManager());
|
||||
itemListAdapter.setUseGridVariant(useGrid);
|
||||
itemListAdapter.notifyDataSetChanged();
|
||||
refreshItemViewMode();
|
||||
}
|
||||
updateFlags = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the item view mode based on user preference.
|
||||
*/
|
||||
private void refreshItemViewMode() {
|
||||
final ItemViewMode itemViewMode = getItemViewMode(requireContext());
|
||||
itemsList.setLayoutManager((itemViewMode == ItemViewMode.GRID)
|
||||
? getGridLayoutManager() : getListLayoutManager());
|
||||
itemListAdapter.setItemViewMode(itemViewMode);
|
||||
itemListAdapter.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Lifecycle - View
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@@ -104,8 +112,7 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
|
||||
final Resources resources = activity.getResources();
|
||||
int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width);
|
||||
width += (24 * resources.getDisplayMetrics().density);
|
||||
final int spanCount = (int) Math.floor(resources.getDisplayMetrics().widthPixels
|
||||
/ (double) width);
|
||||
final int spanCount = Math.floorDiv(resources.getDisplayMetrics().widthPixels, width);
|
||||
final GridLayoutManager lm = new GridLayoutManager(activity, spanCount);
|
||||
lm.setSpanSizeLookup(itemListAdapter.getSpanSizeLookup(spanCount));
|
||||
return lm;
|
||||
@@ -121,11 +128,9 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
|
||||
|
||||
itemListAdapter = new LocalItemListAdapter(activity);
|
||||
|
||||
final boolean useGrid = shouldUseGridLayout(requireContext());
|
||||
itemsList = rootView.findViewById(R.id.items_list);
|
||||
itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
|
||||
refreshItemViewMode();
|
||||
|
||||
itemListAdapter.setUseGridVariant(useGrid);
|
||||
headerRootBinding = getListHeader();
|
||||
if (headerRootBinding != null) {
|
||||
itemListAdapter.setHeader(headerRootBinding.getRoot());
|
||||
@@ -256,7 +261,7 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
|
||||
@Override
|
||||
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
|
||||
final String key) {
|
||||
if (key.equals(getString(R.string.list_view_mode_key))) {
|
||||
if (getString(R.string.list_view_mode_key).equals(key)) {
|
||||
updateFlags |= LIST_MODE_UPDATE_FLAG;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,19 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
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.LocalItemHolder;
|
||||
import org.schabi.newpipe.local.holder.LocalPlaylistCardItemHolder;
|
||||
import org.schabi.newpipe.local.holder.LocalPlaylistGridItemHolder;
|
||||
import org.schabi.newpipe.local.holder.LocalPlaylistItemHolder;
|
||||
import org.schabi.newpipe.local.holder.LocalPlaylistStreamCardItemHolder;
|
||||
import org.schabi.newpipe.local.holder.LocalPlaylistStreamGridItemHolder;
|
||||
import org.schabi.newpipe.local.holder.LocalPlaylistStreamItemHolder;
|
||||
import org.schabi.newpipe.local.holder.LocalStatisticStreamCardItemHolder;
|
||||
import org.schabi.newpipe.local.holder.LocalStatisticStreamGridItemHolder;
|
||||
import org.schabi.newpipe.local.holder.LocalStatisticStreamItemHolder;
|
||||
import org.schabi.newpipe.local.holder.RemotePlaylistCardItemHolder;
|
||||
import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder;
|
||||
import org.schabi.newpipe.local.holder.RemotePlaylistItemHolder;
|
||||
import org.schabi.newpipe.util.FallbackViewHolder;
|
||||
@@ -61,11 +66,17 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||
private static final int STREAM_STATISTICS_HOLDER_TYPE = 0x1000;
|
||||
private static final int STREAM_PLAYLIST_HOLDER_TYPE = 0x1001;
|
||||
private static final int STREAM_STATISTICS_GRID_HOLDER_TYPE = 0x1002;
|
||||
private static final int STREAM_STATISTICS_CARD_HOLDER_TYPE = 0x1003;
|
||||
private static final int STREAM_PLAYLIST_GRID_HOLDER_TYPE = 0x1004;
|
||||
private static final int STREAM_PLAYLIST_CARD_HOLDER_TYPE = 0x1005;
|
||||
|
||||
private static final int LOCAL_PLAYLIST_HOLDER_TYPE = 0x2000;
|
||||
private static final int REMOTE_PLAYLIST_HOLDER_TYPE = 0x2001;
|
||||
private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2002;
|
||||
private static final int REMOTE_PLAYLIST_GRID_HOLDER_TYPE = 0x2004;
|
||||
private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2001;
|
||||
private static final int LOCAL_PLAYLIST_CARD_HOLDER_TYPE = 0x2002;
|
||||
|
||||
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 final LocalItemBuilder localItemBuilder;
|
||||
private final ArrayList<LocalItem> localItems;
|
||||
@@ -73,9 +84,9 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||
private final DateTimeFormatter dateTimeFormatter;
|
||||
|
||||
private boolean showFooter = false;
|
||||
private boolean useGridVariant = false;
|
||||
private View header = null;
|
||||
private View footer = null;
|
||||
private ItemViewMode itemViewMode = ItemViewMode.LIST;
|
||||
|
||||
public LocalItemListAdapter(final Context context) {
|
||||
recordManager = new HistoryRecordManager(context);
|
||||
@@ -165,8 +176,8 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void setUseGridVariant(final boolean useGridVariant) {
|
||||
this.useGridVariant = useGridVariant;
|
||||
public void setItemViewMode(final ItemViewMode itemViewMode) {
|
||||
this.itemViewMode = itemViewMode;
|
||||
}
|
||||
|
||||
public void setHeader(final View header) {
|
||||
@@ -228,6 +239,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||
return count;
|
||||
}
|
||||
|
||||
@SuppressWarnings("FinalParameters")
|
||||
@Override
|
||||
public int getItemViewType(int position) {
|
||||
if (DEBUG) {
|
||||
@@ -243,21 +255,39 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||
return FOOTER_TYPE;
|
||||
}
|
||||
final LocalItem item = localItems.get(position);
|
||||
|
||||
switch (item.getLocalItemType()) {
|
||||
case PLAYLIST_LOCAL_ITEM:
|
||||
return useGridVariant
|
||||
? LOCAL_PLAYLIST_GRID_HOLDER_TYPE : LOCAL_PLAYLIST_HOLDER_TYPE;
|
||||
if (itemViewMode == ItemViewMode.CARD) {
|
||||
return LOCAL_PLAYLIST_CARD_HOLDER_TYPE;
|
||||
} else if (itemViewMode == ItemViewMode.GRID) {
|
||||
return LOCAL_PLAYLIST_GRID_HOLDER_TYPE;
|
||||
} else {
|
||||
return LOCAL_PLAYLIST_HOLDER_TYPE;
|
||||
}
|
||||
case PLAYLIST_REMOTE_ITEM:
|
||||
return useGridVariant
|
||||
? REMOTE_PLAYLIST_GRID_HOLDER_TYPE : REMOTE_PLAYLIST_HOLDER_TYPE;
|
||||
|
||||
if (itemViewMode == ItemViewMode.CARD) {
|
||||
return REMOTE_PLAYLIST_CARD_HOLDER_TYPE;
|
||||
} else if (itemViewMode == ItemViewMode.GRID) {
|
||||
return REMOTE_PLAYLIST_GRID_HOLDER_TYPE;
|
||||
} else {
|
||||
return REMOTE_PLAYLIST_HOLDER_TYPE;
|
||||
}
|
||||
case PLAYLIST_STREAM_ITEM:
|
||||
return useGridVariant
|
||||
? STREAM_PLAYLIST_GRID_HOLDER_TYPE : STREAM_PLAYLIST_HOLDER_TYPE;
|
||||
if (itemViewMode == ItemViewMode.CARD) {
|
||||
return STREAM_PLAYLIST_CARD_HOLDER_TYPE;
|
||||
} else if (itemViewMode == ItemViewMode.GRID) {
|
||||
return STREAM_PLAYLIST_GRID_HOLDER_TYPE;
|
||||
} else {
|
||||
return STREAM_PLAYLIST_HOLDER_TYPE;
|
||||
}
|
||||
case STATISTIC_STREAM_ITEM:
|
||||
return useGridVariant
|
||||
? STREAM_STATISTICS_GRID_HOLDER_TYPE : STREAM_STATISTICS_HOLDER_TYPE;
|
||||
if (itemViewMode == ItemViewMode.CARD) {
|
||||
return STREAM_STATISTICS_CARD_HOLDER_TYPE;
|
||||
} else if (itemViewMode == ItemViewMode.GRID) {
|
||||
return STREAM_STATISTICS_GRID_HOLDER_TYPE;
|
||||
} else {
|
||||
return STREAM_STATISTICS_HOLDER_TYPE;
|
||||
}
|
||||
default:
|
||||
Log.e(TAG, "No holder type has been considered for item: ["
|
||||
+ item.getLocalItemType() + "]");
|
||||
@@ -282,24 +312,33 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||
return new LocalPlaylistItemHolder(localItemBuilder, parent);
|
||||
case LOCAL_PLAYLIST_GRID_HOLDER_TYPE:
|
||||
return new LocalPlaylistGridItemHolder(localItemBuilder, parent);
|
||||
case LOCAL_PLAYLIST_CARD_HOLDER_TYPE:
|
||||
return new LocalPlaylistCardItemHolder(localItemBuilder, parent);
|
||||
case 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 STREAM_PLAYLIST_HOLDER_TYPE:
|
||||
return new LocalPlaylistStreamItemHolder(localItemBuilder, parent);
|
||||
case STREAM_PLAYLIST_GRID_HOLDER_TYPE:
|
||||
return new LocalPlaylistStreamGridItemHolder(localItemBuilder, parent);
|
||||
case STREAM_PLAYLIST_CARD_HOLDER_TYPE:
|
||||
return new LocalPlaylistStreamCardItemHolder(localItemBuilder, parent);
|
||||
case STREAM_STATISTICS_HOLDER_TYPE:
|
||||
return new LocalStatisticStreamItemHolder(localItemBuilder, parent);
|
||||
case STREAM_STATISTICS_GRID_HOLDER_TYPE:
|
||||
return new LocalStatisticStreamGridItemHolder(localItemBuilder, parent);
|
||||
case STREAM_STATISTICS_CARD_HOLDER_TYPE:
|
||||
return new LocalStatisticStreamCardItemHolder(localItemBuilder, parent);
|
||||
default:
|
||||
Log.e(TAG, "No view type has been considered for holder: [" + type + "]");
|
||||
return new FallbackViewHolder(new View(parent.getContext()));
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("FinalParameters")
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, int position) {
|
||||
if (DEBUG) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.schabi.newpipe.local.bookmark;
|
||||
|
||||
import android.content.DialogInterface;
|
||||
import android.os.Bundle;
|
||||
import android.os.Parcelable;
|
||||
import android.text.InputType;
|
||||
@@ -31,6 +32,7 @@ import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.OnClickGesture;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import icepick.State;
|
||||
@@ -98,7 +100,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
protected void initListeners() {
|
||||
super.initListeners();
|
||||
|
||||
itemListAdapter.setSelectedListener(new OnClickGesture<LocalItem>() {
|
||||
itemListAdapter.setSelectedListener(new OnClickGesture<>() {
|
||||
@Override
|
||||
public void selected(final LocalItem selectedItem) {
|
||||
final FragmentManager fragmentManager = getFM();
|
||||
@@ -256,25 +258,54 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
}
|
||||
|
||||
private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
|
||||
final DialogEditTextBinding dialogBinding
|
||||
= DialogEditTextBinding.inflate(getLayoutInflater());
|
||||
final String rename = getString(R.string.rename);
|
||||
final String delete = getString(R.string.delete);
|
||||
final String unsetThumbnail = getString(R.string.unset_playlist_thumbnail);
|
||||
final boolean isThumbnailPermanent = localPlaylistManager
|
||||
.getIsPlaylistThumbnailPermanent(selectedItem.uid);
|
||||
|
||||
final ArrayList<String> items = new ArrayList<>();
|
||||
items.add(rename);
|
||||
items.add(delete);
|
||||
if (isThumbnailPermanent) {
|
||||
items.add(unsetThumbnail);
|
||||
}
|
||||
|
||||
final DialogInterface.OnClickListener action = (d, index) -> {
|
||||
if (items.get(index).equals(rename)) {
|
||||
showRenameDialog(selectedItem);
|
||||
} else if (items.get(index).equals(delete)) {
|
||||
showDeleteDialog(selectedItem.name,
|
||||
localPlaylistManager.deletePlaylist(selectedItem.uid));
|
||||
} else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) {
|
||||
final long thumbnailStreamId = localPlaylistManager
|
||||
.getAutomaticPlaylistThumbnailStreamId(selectedItem.uid);
|
||||
localPlaylistManager
|
||||
.changePlaylistThumbnail(selectedItem.uid, thumbnailStreamId, false)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe();
|
||||
}
|
||||
};
|
||||
|
||||
new AlertDialog.Builder(activity)
|
||||
.setItems(items.toArray(new String[0]), action)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void showRenameDialog(final PlaylistMetadataEntry selectedItem) {
|
||||
final DialogEditTextBinding dialogBinding =
|
||||
DialogEditTextBinding.inflate(getLayoutInflater());
|
||||
dialogBinding.dialogEditText.setHint(R.string.name);
|
||||
dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT);
|
||||
dialogBinding.dialogEditText.setText(selectedItem.name);
|
||||
|
||||
final AlertDialog.Builder builder = new AlertDialog.Builder(activity);
|
||||
builder.setView(dialogBinding.getRoot())
|
||||
new AlertDialog.Builder(activity)
|
||||
.setView(dialogBinding.getRoot())
|
||||
.setPositiveButton(R.string.rename_playlist, (dialog, which) ->
|
||||
changeLocalPlaylistName(
|
||||
selectedItem.uid,
|
||||
dialogBinding.dialogEditText.getText().toString()))
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setNeutralButton(R.string.delete, (dialog, which) -> {
|
||||
showDeleteDialog(selectedItem.name,
|
||||
localPlaylistManager.deletePlaylist(selectedItem.uid));
|
||||
dialog.dismiss();
|
||||
})
|
||||
.create()
|
||||
.show();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
@@ -13,12 +14,11 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.LocalItem;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.local.LocalItemListAdapter;
|
||||
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
|
||||
import org.schabi.newpipe.util.OnClickGesture;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -30,11 +30,20 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
|
||||
|
||||
private RecyclerView playlistRecyclerView;
|
||||
private LocalItemListAdapter playlistAdapter;
|
||||
private TextView playlistDuplicateIndicator;
|
||||
|
||||
private final CompositeDisposable playlistDisposables = new CompositeDisposable();
|
||||
|
||||
public PlaylistAppendDialog(final List<StreamEntity> streamEntities) {
|
||||
super(streamEntities);
|
||||
/**
|
||||
* Create a new instance of {@link PlaylistAppendDialog}.
|
||||
*
|
||||
* @param streamEntities a list of {@link StreamEntity} to be added to playlists
|
||||
* @return a new instance of {@link PlaylistAppendDialog}
|
||||
*/
|
||||
public static PlaylistAppendDialog newInstance(final List<StreamEntity> streamEntities) {
|
||||
final PlaylistAppendDialog dialog = new PlaylistAppendDialog();
|
||||
dialog.setStreamEntities(streamEntities);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
@@ -55,18 +64,11 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
|
||||
new LocalPlaylistManager(NewPipeDatabase.getInstance(requireContext()));
|
||||
|
||||
playlistAdapter = new LocalItemListAdapter(getActivity());
|
||||
playlistAdapter.setSelectedListener(new OnClickGesture<LocalItem>() {
|
||||
@Override
|
||||
public void selected(final LocalItem selectedItem) {
|
||||
if (!(selectedItem instanceof PlaylistMetadataEntry)
|
||||
|| getStreamEntities() == null) {
|
||||
return;
|
||||
}
|
||||
onPlaylistSelected(
|
||||
playlistManager,
|
||||
(PlaylistMetadataEntry) selectedItem,
|
||||
getStreamEntities()
|
||||
);
|
||||
playlistAdapter.setSelectedListener(selectedItem -> {
|
||||
final List<StreamEntity> entities = getStreamEntities();
|
||||
if (selectedItem instanceof PlaylistDuplicatesEntry && entities != null) {
|
||||
onPlaylistSelected(playlistManager,
|
||||
(PlaylistDuplicatesEntry) selectedItem, entities);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -74,10 +76,13 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
|
||||
playlistRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
|
||||
playlistRecyclerView.setAdapter(playlistAdapter);
|
||||
|
||||
playlistDuplicateIndicator = view.findViewById(R.id.playlist_duplicate);
|
||||
|
||||
final View newPlaylistButton = view.findViewById(R.id.newPlaylist);
|
||||
newPlaylistButton.setOnClickListener(ignored -> openCreatePlaylistDialog());
|
||||
|
||||
playlistDisposables.add(playlistManager.getPlaylists()
|
||||
playlistDisposables.add(playlistManager
|
||||
.getPlaylistDuplicates(getStreamEntities().get(0).getUrl())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(this::onPlaylistsReceived));
|
||||
}
|
||||
@@ -103,13 +108,14 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
|
||||
// Helper
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
/** Display create playlist dialog. */
|
||||
public void openCreatePlaylistDialog() {
|
||||
if (getStreamEntities() == null || !isAdded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final PlaylistCreationDialog playlistCreationDialog =
|
||||
new PlaylistCreationDialog(getStreamEntities());
|
||||
PlaylistCreationDialog.newInstance(getStreamEntities());
|
||||
// Move the dismissListener to the new dialog.
|
||||
playlistCreationDialog.setOnDismissListener(this.getOnDismissListener());
|
||||
this.setOnDismissListener(null);
|
||||
@@ -118,34 +124,50 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
|
||||
requireDialog().dismiss();
|
||||
}
|
||||
|
||||
private void onPlaylistsReceived(@NonNull final List<PlaylistMetadataEntry> playlists) {
|
||||
if (playlistAdapter != null && playlistRecyclerView != null) {
|
||||
private void onPlaylistsReceived(@NonNull final List<PlaylistDuplicatesEntry> playlists) {
|
||||
if (playlistAdapter != null
|
||||
&& playlistRecyclerView != null
|
||||
&& playlistDuplicateIndicator != null) {
|
||||
playlistAdapter.clearStreamItemList();
|
||||
playlistAdapter.addItems(playlists);
|
||||
playlistRecyclerView.setVisibility(View.VISIBLE);
|
||||
playlistDuplicateIndicator.setVisibility(
|
||||
anyPlaylistContainsDuplicates(playlists) ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean anyPlaylistContainsDuplicates(final List<PlaylistDuplicatesEntry> playlists) {
|
||||
return playlists.stream()
|
||||
.anyMatch(playlist -> playlist.timesStreamIsContained > 0);
|
||||
}
|
||||
|
||||
private void onPlaylistSelected(@NonNull final LocalPlaylistManager manager,
|
||||
@NonNull final PlaylistMetadataEntry playlist,
|
||||
@NonNull final PlaylistDuplicatesEntry playlist,
|
||||
@NonNull final List<StreamEntity> streams) {
|
||||
if (getStreamEntities() == null) {
|
||||
return;
|
||||
|
||||
final String toastText;
|
||||
if (playlist.timesStreamIsContained > 0) {
|
||||
toastText = getString(R.string.playlist_add_stream_success_duplicate,
|
||||
playlist.timesStreamIsContained);
|
||||
} else {
|
||||
toastText = getString(R.string.playlist_add_stream_success);
|
||||
}
|
||||
|
||||
final Toast successToast = Toast.makeText(getContext(),
|
||||
R.string.playlist_add_stream_success, Toast.LENGTH_SHORT);
|
||||
|
||||
if (playlist.thumbnailUrl.equals("drawable://" + R.drawable.dummy_thumbnail_playlist)) {
|
||||
playlistDisposables.add(manager
|
||||
.changePlaylistThumbnail(playlist.uid, streams.get(0).getThumbnailUrl())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(ignored -> successToast.show()));
|
||||
}
|
||||
final Toast successToast = Toast.makeText(getContext(), toastText, Toast.LENGTH_SHORT);
|
||||
|
||||
playlistDisposables.add(manager.appendToPlaylist(playlist.uid, streams)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(ignored -> successToast.show()));
|
||||
.subscribe(ignored -> {
|
||||
successToast.show();
|
||||
|
||||
if (playlist.thumbnailUrl.equals(PlaylistEntity.DEFAULT_THUMBNAIL)) {
|
||||
playlistDisposables.add(manager
|
||||
.changePlaylistThumbnail(playlist.uid, streams.get(0).getUid(),
|
||||
false)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(ignore -> successToast.show()));
|
||||
}
|
||||
}));
|
||||
|
||||
requireDialog().dismiss();
|
||||
}
|
||||
|
||||
@@ -21,8 +21,17 @@ import java.util.List;
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
|
||||
public final class PlaylistCreationDialog extends PlaylistDialog {
|
||||
public PlaylistCreationDialog(final List<StreamEntity> streamEntities) {
|
||||
super(streamEntities);
|
||||
|
||||
/**
|
||||
* Create a new instance of {@link PlaylistCreationDialog}.
|
||||
*
|
||||
* @param streamEntities a list of {@link StreamEntity} to be added to playlists
|
||||
* @return a new instance of {@link PlaylistCreationDialog}
|
||||
*/
|
||||
public static PlaylistCreationDialog newInstance(final List<StreamEntity> streamEntities) {
|
||||
final PlaylistCreationDialog dialog = new PlaylistCreationDialog();
|
||||
dialog.setStreamEntities(streamEntities);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
@@ -36,8 +45,8 @@ public final class PlaylistCreationDialog extends PlaylistDialog {
|
||||
return super.onCreateDialog(savedInstanceState);
|
||||
}
|
||||
|
||||
final DialogEditTextBinding dialogBinding
|
||||
= DialogEditTextBinding.inflate(getLayoutInflater());
|
||||
final DialogEditTextBinding dialogBinding =
|
||||
DialogEditTextBinding.inflate(getLayoutInflater());
|
||||
dialogBinding.getRoot().getContext().setTheme(ThemeHelper.getDialogTheme(requireContext()));
|
||||
dialogBinding.dialogEditText.setHint(R.string.name);
|
||||
dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT);
|
||||
|
||||
@@ -9,15 +9,20 @@ import android.view.Window;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
|
||||
import org.schabi.newpipe.player.Player;
|
||||
import org.schabi.newpipe.util.StateSaver;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Queue;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
@@ -31,10 +36,6 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
|
||||
|
||||
private org.schabi.newpipe.util.SavedState savedState;
|
||||
|
||||
public PlaylistDialog(final List<StreamEntity> streamEntities) {
|
||||
this.streamEntities = streamEntities;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// LifeCycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@@ -97,7 +98,7 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(final Bundle outState) {
|
||||
public void onSaveInstanceState(@NonNull final Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
if (getActivity() != null) {
|
||||
savedState = StateSaver.tryToSave(getActivity().isChangingConfigurations(),
|
||||
@@ -120,6 +121,10 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
|
||||
this.onDismissListener = onDismissListener;
|
||||
}
|
||||
|
||||
protected void setStreamEntities(final List<StreamEntity> streamEntities) {
|
||||
this.streamEntities = streamEntities;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Dialog creation
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@@ -131,20 +136,46 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
|
||||
* @param context context used for accessing the database
|
||||
* @param streamEntities used for crating the dialog
|
||||
* @param onExec execution that should occur after a dialog got created, e.g. showing it
|
||||
* @return Disposable
|
||||
* @return the disposable that was created
|
||||
*/
|
||||
public static Disposable createCorrespondingDialog(
|
||||
final Context context,
|
||||
final List<StreamEntity> streamEntities,
|
||||
final Consumer<PlaylistDialog> onExec
|
||||
) {
|
||||
final Consumer<PlaylistDialog> onExec) {
|
||||
|
||||
return new LocalPlaylistManager(NewPipeDatabase.getInstance(context))
|
||||
.hasPlaylists()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(hasPlaylists ->
|
||||
onExec.accept(hasPlaylists
|
||||
? new PlaylistAppendDialog(streamEntities)
|
||||
: new PlaylistCreationDialog(streamEntities))
|
||||
? PlaylistAppendDialog.newInstance(streamEntities)
|
||||
: PlaylistCreationDialog.newInstance(streamEntities))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link PlaylistAppendDialog} when playlists exists,
|
||||
* otherwise a {@link PlaylistCreationDialog}. If the player's play queue is null or empty, no
|
||||
* dialog will be created.
|
||||
*
|
||||
* @param player the player from which to extract the context and the play queue
|
||||
* @param fragmentManager the fragment manager to use to show the dialog
|
||||
* @return the disposable that was created
|
||||
*/
|
||||
public static Disposable showForPlayQueue(
|
||||
final Player player,
|
||||
@NonNull final FragmentManager fragmentManager) {
|
||||
|
||||
final List<StreamEntity> streamEntities = Stream.of(player.getPlayQueue())
|
||||
.filter(Objects::nonNull)
|
||||
.flatMap(playQueue -> playQueue.getStreams().stream())
|
||||
.map(StreamEntity::new)
|
||||
.collect(Collectors.toList());
|
||||
if (streamEntities.isEmpty()) {
|
||||
return Disposable.empty();
|
||||
}
|
||||
|
||||
return PlaylistDialog.createCorrespondingDialog(player.getContext(), streamEntities,
|
||||
dialog -> dialog.show(fragmentManager, "PlaylistDialog"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
|
||||
import org.schabi.newpipe.database.stream.StreamWithState
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||
@@ -40,23 +41,26 @@ class FeedDatabaseManager(context: Context) {
|
||||
fun database() = database
|
||||
|
||||
fun getStreams(
|
||||
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
|
||||
getPlayedStreams: Boolean = true
|
||||
groupId: Long,
|
||||
includePlayedStreams: Boolean,
|
||||
includePartiallyPlayedStreams: Boolean,
|
||||
includeFutureStreams: Boolean
|
||||
): Maybe<List<StreamWithState>> {
|
||||
return when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> {
|
||||
if (getPlayedStreams) feedTable.getAllStreams()
|
||||
else feedTable.getLiveOrNotPlayedStreams()
|
||||
}
|
||||
else -> {
|
||||
if (getPlayedStreams) feedTable.getAllStreamsForGroup(groupId)
|
||||
else feedTable.getLiveOrNotPlayedStreamsForGroup(groupId)
|
||||
}
|
||||
}
|
||||
return feedTable.getStreams(
|
||||
groupId,
|
||||
includePlayedStreams,
|
||||
includePartiallyPlayedStreams,
|
||||
if (includeFutureStreams) null else OffsetDateTime.now()
|
||||
)
|
||||
}
|
||||
|
||||
fun outdatedSubscriptions(outdatedThreshold: OffsetDateTime) = feedTable.getAllOutdated(outdatedThreshold)
|
||||
|
||||
fun outdatedSubscriptionsWithNotificationMode(
|
||||
outdatedThreshold: OffsetDateTime,
|
||||
@NotificationMode notificationMode: Int
|
||||
) = feedTable.getOutdatedWithNotificationMode(outdatedThreshold, notificationMode)
|
||||
|
||||
fun notLoadedCount(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable<Long> {
|
||||
return when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> feedTable.notLoadedCount()
|
||||
@@ -72,6 +76,10 @@ class FeedDatabaseManager(context: Context) {
|
||||
fun markAsOutdated(subscriptionId: Long) = feedTable
|
||||
.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null))
|
||||
|
||||
fun doesStreamExist(stream: StreamInfoItem): Boolean {
|
||||
return streamTable.exists(stream.serviceId, stream.url)
|
||||
}
|
||||
|
||||
fun upsertAll(
|
||||
subscriptionId: Long,
|
||||
items: List<StreamInfoItem>,
|
||||
|
||||
@@ -25,7 +25,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
@@ -37,11 +36,9 @@ import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.Nullable
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.math.MathUtils
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
@@ -50,7 +47,6 @@ import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.xwray.groupie.GroupieAdapter
|
||||
import com.xwray.groupie.Item
|
||||
import com.xwray.groupie.OnAsyncUpdateListener
|
||||
import com.xwray.groupie.OnItemClickListener
|
||||
import com.xwray.groupie.OnItemLongClickListener
|
||||
import icepick.State
|
||||
@@ -68,25 +64,24 @@ import org.schabi.newpipe.error.UserAction
|
||||
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
|
||||
import org.schabi.newpipe.fragments.BaseStateFragment
|
||||
import org.schabi.newpipe.info_list.InfoItemDialog
|
||||
import org.schabi.newpipe.info_list.ItemViewMode
|
||||
import org.schabi.newpipe.info_list.dialog.InfoItemDialog
|
||||
import org.schabi.newpipe.ktx.animate
|
||||
import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling
|
||||
import org.schabi.newpipe.ktx.slideUp
|
||||
import org.schabi.newpipe.local.feed.item.StreamItem
|
||||
import org.schabi.newpipe.local.feed.service.FeedLoadService
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder
|
||||
import org.schabi.newpipe.util.DeviceUtils
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.NavigationHelper
|
||||
import org.schabi.newpipe.util.StreamDialogEntry
|
||||
import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams
|
||||
import org.schabi.newpipe.util.ThemeHelper.getItemViewMode
|
||||
import org.schabi.newpipe.util.ThemeHelper.resolveDrawable
|
||||
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.ArrayList
|
||||
import java.util.function.Consumer
|
||||
|
||||
class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
@@ -103,7 +98,6 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
private var oldestSubscriptionUpdate: OffsetDateTime? = null
|
||||
|
||||
private lateinit var groupAdapter: GroupieAdapter
|
||||
@State @JvmField var showPlayedItems: Boolean = true
|
||||
|
||||
private var onSettingsChangeListener: SharedPreferences.OnSharedPreferenceChangeListener? = null
|
||||
private var updateListViewModeOnResume = false
|
||||
@@ -123,7 +117,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
groupName = arguments?.getString(KEY_GROUP_NAME) ?: ""
|
||||
|
||||
onSettingsChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key.equals(getString(R.string.list_view_mode_key))) {
|
||||
if (getString(R.string.list_view_mode_key).equals(key)) {
|
||||
updateListViewModeOnResume = true
|
||||
}
|
||||
}
|
||||
@@ -140,10 +134,9 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
_feedBinding = FragmentFeedBinding.bind(rootView)
|
||||
super.onViewCreated(rootView, savedInstanceState)
|
||||
|
||||
val factory = FeedViewModel.Factory(requireContext(), groupId)
|
||||
viewModel = ViewModelProvider(this, factory).get(FeedViewModel::class.java)
|
||||
showPlayedItems = viewModel.getShowPlayedItemsFromPreferences()
|
||||
viewModel.stateLiveData.observe(viewLifecycleOwner, { it?.let(::handleResult) })
|
||||
val factory = FeedViewModel.getFactory(requireContext(), groupId)
|
||||
viewModel = ViewModelProvider(this, factory)[FeedViewModel::class.java]
|
||||
viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) }
|
||||
|
||||
groupAdapter = GroupieAdapter().apply {
|
||||
setOnItemClickListener(listenerStreamItem)
|
||||
@@ -217,7 +210,6 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
activity.supportActionBar?.subtitle = groupName
|
||||
|
||||
inflater.inflate(R.menu.menu_feed_fragment, menu)
|
||||
updateTogglePlayedItemsButton(menu.findItem(R.id.menu_item_feed_toggle_played_items))
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
@@ -239,19 +231,42 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
}
|
||||
}
|
||||
.setPositiveButton(resources.getString(R.string.ok), null)
|
||||
.create()
|
||||
.show()
|
||||
return true
|
||||
} else if (item.itemId == R.id.menu_item_feed_toggle_played_items) {
|
||||
showPlayedItems = !item.isChecked
|
||||
updateTogglePlayedItemsButton(item)
|
||||
viewModel.togglePlayedItems(showPlayedItems)
|
||||
viewModel.saveShowPlayedItemsToPreferences(showPlayedItems)
|
||||
showStreamVisibilityDialog()
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun showStreamVisibilityDialog() {
|
||||
val dialogItems = arrayOf(
|
||||
getString(R.string.feed_show_watched),
|
||||
getString(R.string.feed_show_partially_watched),
|
||||
getString(R.string.feed_show_upcoming)
|
||||
)
|
||||
|
||||
val checkedDialogItems = booleanArrayOf(
|
||||
viewModel.getShowPlayedItemsFromPreferences(),
|
||||
viewModel.getShowPartiallyPlayedItemsFromPreferences(),
|
||||
viewModel.getShowFutureItemsFromPreferences()
|
||||
)
|
||||
|
||||
AlertDialog.Builder(context!!)
|
||||
.setTitle(R.string.feed_hide_streams_title)
|
||||
.setMultiChoiceItems(dialogItems, checkedDialogItems) { _, which, isChecked ->
|
||||
checkedDialogItems[which] = isChecked
|
||||
}
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
viewModel.setSaveShowPlayedItems(checkedDialogItems[0])
|
||||
viewModel.setSaveShowPartiallyPlayedItems(checkedDialogItems[1])
|
||||
viewModel.setSaveShowFutureItems(checkedDialogItems[2])
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onDestroyOptionsMenu() {
|
||||
super.onDestroyOptionsMenu()
|
||||
activity?.supportActionBar?.subtitle = null
|
||||
@@ -278,14 +293,6 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun updateTogglePlayedItemsButton(menuItem: MenuItem) {
|
||||
menuItem.isChecked = showPlayedItems
|
||||
menuItem.icon = AppCompatResources.getDrawable(
|
||||
requireContext(),
|
||||
if (showPlayedItems) R.drawable.ic_visibility_on else R.drawable.ic_visibility_off
|
||||
)
|
||||
}
|
||||
|
||||
// //////////////////////////////////////////////////////////////////////////
|
||||
// Handling
|
||||
// //////////////////////////////////////////////////////////////////////////
|
||||
@@ -356,53 +363,12 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
feedBinding.loadingProgressBar.max = progressState.maxProgress
|
||||
}
|
||||
|
||||
private fun showStreamDialog(item: StreamInfoItem) {
|
||||
private fun showInfoItemDialog(item: StreamInfoItem) {
|
||||
val context = context
|
||||
val activity: Activity? = getActivity()
|
||||
if (context == null || context.resources == null || activity == null) return
|
||||
|
||||
val entries = ArrayList<StreamDialogEntry>()
|
||||
if (PlayerHolder.getInstance().isPlayQueueReady) {
|
||||
entries.add(StreamDialogEntry.enqueue)
|
||||
|
||||
if (PlayerHolder.getInstance().queueSize > 1) {
|
||||
entries.add(StreamDialogEntry.enqueue_next)
|
||||
}
|
||||
}
|
||||
|
||||
if (item.streamType == StreamType.AUDIO_STREAM) {
|
||||
entries.addAll(
|
||||
listOf(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share,
|
||||
StreamDialogEntry.open_in_browser
|
||||
)
|
||||
)
|
||||
} else {
|
||||
entries.addAll(
|
||||
listOf(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.start_here_on_popup,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share,
|
||||
StreamDialogEntry.open_in_browser
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// show "mark as watched" only when watch history is enabled
|
||||
if (StreamDialogEntry.shouldAddMarkAsWatched(item.streamType, context)) {
|
||||
entries.add(
|
||||
StreamDialogEntry.mark_as_watched
|
||||
)
|
||||
}
|
||||
entries.add(StreamDialogEntry.show_channel_details)
|
||||
|
||||
StreamDialogEntry.setEnabledEntries(entries)
|
||||
InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context)) { _, which ->
|
||||
StreamDialogEntry.clickOn(which, this, item)
|
||||
}.show()
|
||||
InfoItemDialog.Builder(activity, context, this, item).create().show()
|
||||
}
|
||||
|
||||
private val listenerStreamItem = object : OnItemClickListener, OnItemLongClickListener {
|
||||
@@ -418,7 +384,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
|
||||
override fun onItemLongClick(item: Item<*>, view: View): Boolean {
|
||||
if (item is StreamItem && !isRefreshing) {
|
||||
showStreamDialog(item.streamWithState.stream.toStreamInfoItem())
|
||||
showInfoItemDialog(item.streamWithState.stream.toStreamInfoItem())
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -427,25 +393,21 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
|
||||
@SuppressLint("StringFormatMatches")
|
||||
private fun handleLoadedState(loadedState: FeedState.LoadedState) {
|
||||
|
||||
val itemVersion = if (shouldUseGridLayout(context)) {
|
||||
StreamItem.ItemVersion.GRID
|
||||
} else {
|
||||
StreamItem.ItemVersion.NORMAL
|
||||
val itemVersion = when (getItemViewMode(requireContext())) {
|
||||
ItemViewMode.GRID -> StreamItem.ItemVersion.GRID
|
||||
ItemViewMode.CARD -> StreamItem.ItemVersion.CARD
|
||||
else -> StreamItem.ItemVersion.NORMAL
|
||||
}
|
||||
loadedState.items.forEach { it.itemVersion = itemVersion }
|
||||
|
||||
// This need to be saved in a variable as the update occurs async
|
||||
val oldOldestSubscriptionUpdate = oldestSubscriptionUpdate
|
||||
|
||||
groupAdapter.updateAsync(
|
||||
loadedState.items, false,
|
||||
OnAsyncUpdateListener {
|
||||
oldOldestSubscriptionUpdate?.run {
|
||||
highlightNewItemsAfter(oldOldestSubscriptionUpdate)
|
||||
}
|
||||
groupAdapter.updateAsync(loadedState.items, false) {
|
||||
oldOldestSubscriptionUpdate?.run {
|
||||
highlightNewItemsAfter(oldOldestSubscriptionUpdate)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
listState?.run {
|
||||
feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState)
|
||||
@@ -497,8 +459,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
}.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
subscriptionEntity ->
|
||||
{ subscriptionEntity ->
|
||||
handleFeedNotAvailable(
|
||||
subscriptionEntity,
|
||||
t.cause,
|
||||
@@ -514,7 +475,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
|
||||
private fun handleFeedNotAvailable(
|
||||
subscriptionEntity: SubscriptionEntity,
|
||||
@Nullable cause: Throwable?,
|
||||
cause: Throwable?,
|
||||
nextItemsErrors: List<Throwable>
|
||||
) {
|
||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
@@ -524,15 +485,13 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
|
||||
val builder = AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.feed_load_error)
|
||||
.setPositiveButton(
|
||||
R.string.unsubscribe
|
||||
) { _, _ ->
|
||||
SubscriptionManager(requireContext()).deleteSubscription(
|
||||
subscriptionEntity.serviceId, subscriptionEntity.url
|
||||
).subscribe()
|
||||
.setPositiveButton(R.string.unsubscribe) { _, _ ->
|
||||
SubscriptionManager(requireContext())
|
||||
.deleteSubscription(subscriptionEntity.serviceId, subscriptionEntity.url)
|
||||
.subscribe()
|
||||
handleItemsErrors(nextItemsErrors)
|
||||
}
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> }
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
|
||||
var message = getString(R.string.feed_load_error_account_info, subscriptionEntity.name)
|
||||
if (cause is AccountTerminatedException) {
|
||||
@@ -549,7 +508,8 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
message += "\n" + cause.message
|
||||
}
|
||||
}
|
||||
builder.setMessage(message).create().show()
|
||||
builder.setMessage(message)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun updateRelativeTimeViews() {
|
||||
@@ -619,7 +579,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,
|
||||
minOf(groupAdapter.itemCount, maxOf(highlightCount, lastNewItemsCount))
|
||||
MathUtils.clamp(highlightCount, lastNewItemsCount, groupAdapter.itemCount)
|
||||
)
|
||||
|
||||
if (highlightCount > 0) {
|
||||
@@ -629,19 +589,6 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
lastNewItemsCount = highlightCount
|
||||
}
|
||||
|
||||
private fun resolveDrawable(context: Context, @AttrRes attrResId: Int): Drawable? {
|
||||
return androidx.core.content.ContextCompat.getDrawable(
|
||||
context,
|
||||
android.util.TypedValue().apply {
|
||||
context.theme.resolveAttribute(
|
||||
attrResId,
|
||||
this,
|
||||
true
|
||||
)
|
||||
}.resourceId
|
||||
)
|
||||
}
|
||||
|
||||
private fun showNewItemsLoaded() {
|
||||
tryGetNewItemsLoadedButton()?.clearAnimation()
|
||||
tryGetNewItemsLoadedButton()
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
package org.schabi.newpipe.local.feed
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import androidx.preference.PreferenceManager
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.functions.Function4
|
||||
import io.reactivex.rxjava3.functions.Function6
|
||||
import io.reactivex.rxjava3.processors.BehaviorProcessor
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.App
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.stream.StreamWithState
|
||||
@@ -26,39 +29,53 @@ import java.time.OffsetDateTime
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FeedViewModel(
|
||||
private val applicationContext: Context,
|
||||
private val application: Application,
|
||||
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
|
||||
initialShowPlayedItems: Boolean = true
|
||||
initialShowPlayedItems: Boolean,
|
||||
initialShowPartiallyPlayedItems: Boolean,
|
||||
initialShowFutureItems: Boolean
|
||||
) : ViewModel() {
|
||||
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
|
||||
private val feedDatabaseManager = FeedDatabaseManager(application)
|
||||
|
||||
private val toggleShowPlayedItems = BehaviorProcessor.create<Boolean>()
|
||||
private val toggleShowPlayedItemsFlowable = toggleShowPlayedItems
|
||||
private val showPlayedItems = BehaviorProcessor.create<Boolean>()
|
||||
private val showPlayedItemsFlowable = showPlayedItems
|
||||
.startWithItem(initialShowPlayedItems)
|
||||
.distinctUntilChanged()
|
||||
|
||||
private val showPartiallyPlayedItems = BehaviorProcessor.create<Boolean>()
|
||||
private val showPartiallyPlayedItemsFlowable = showPartiallyPlayedItems
|
||||
.startWithItem(initialShowPartiallyPlayedItems)
|
||||
.distinctUntilChanged()
|
||||
|
||||
private val showFutureItems = BehaviorProcessor.create<Boolean>()
|
||||
private val showFutureItemsFlowable = showFutureItems
|
||||
.startWithItem(initialShowFutureItems)
|
||||
.distinctUntilChanged()
|
||||
|
||||
private val mutableStateLiveData = MutableLiveData<FeedState>()
|
||||
val stateLiveData: LiveData<FeedState> = mutableStateLiveData
|
||||
|
||||
private var combineDisposable = Flowable
|
||||
.combineLatest(
|
||||
FeedEventManager.events(),
|
||||
toggleShowPlayedItemsFlowable,
|
||||
showPlayedItemsFlowable,
|
||||
showPartiallyPlayedItemsFlowable,
|
||||
showFutureItemsFlowable,
|
||||
feedDatabaseManager.notLoadedCount(groupId),
|
||||
feedDatabaseManager.oldestSubscriptionUpdate(groupId),
|
||||
|
||||
Function4 { t1: FeedEventManager.Event, t2: Boolean,
|
||||
t3: Long, t4: List<OffsetDateTime> ->
|
||||
return@Function4 CombineResultEventHolder(t1, t2, t3, t4.firstOrNull())
|
||||
Function6 { t1: FeedEventManager.Event, t2: Boolean, t3: Boolean, t4: Boolean,
|
||||
t5: Long, t6: List<OffsetDateTime> ->
|
||||
return@Function6 CombineResultEventHolder(t1, t2, t3, t4, t5, t6.firstOrNull())
|
||||
}
|
||||
)
|
||||
.throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(Schedulers.io())
|
||||
.map { (event, showPlayedItems, notLoadedCount, oldestUpdate) ->
|
||||
var streamItems = if (event is SuccessResultEvent || event is IdleEvent)
|
||||
.map { (event, showPlayedItems, showPartiallyPlayedItems, showFutureItems, notLoadedCount, oldestUpdate) ->
|
||||
val streamItems = if (event is SuccessResultEvent || event is IdleEvent)
|
||||
feedDatabaseManager
|
||||
.getStreams(groupId, showPlayedItems)
|
||||
.getStreams(groupId, showPlayedItems, showPartiallyPlayedItems, showFutureItems)
|
||||
.blockingGet(arrayListOf())
|
||||
else
|
||||
arrayListOf()
|
||||
@@ -89,8 +106,10 @@ class FeedViewModel(
|
||||
private data class CombineResultEventHolder(
|
||||
val t1: FeedEventManager.Event,
|
||||
val t2: Boolean,
|
||||
val t3: Long,
|
||||
val t4: OffsetDateTime?
|
||||
val t3: Boolean,
|
||||
val t4: Boolean,
|
||||
val t5: Long,
|
||||
val t6: OffsetDateTime?
|
||||
)
|
||||
|
||||
private data class CombineResultDataHolder(
|
||||
@@ -100,36 +119,60 @@ class FeedViewModel(
|
||||
val t4: OffsetDateTime?
|
||||
)
|
||||
|
||||
fun togglePlayedItems(showPlayedItems: Boolean) {
|
||||
toggleShowPlayedItems.onNext(showPlayedItems)
|
||||
}
|
||||
|
||||
fun saveShowPlayedItemsToPreferences(showPlayedItems: Boolean) =
|
||||
PreferenceManager.getDefaultSharedPreferences(applicationContext).edit {
|
||||
this.putBoolean(applicationContext.getString(R.string.feed_show_played_items_key), showPlayedItems)
|
||||
fun setSaveShowPlayedItems(showPlayedItems: Boolean) {
|
||||
this.showPlayedItems.onNext(showPlayedItems)
|
||||
PreferenceManager.getDefaultSharedPreferences(application).edit {
|
||||
this.putBoolean(application.getString(R.string.feed_show_watched_items_key), showPlayedItems)
|
||||
this.apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun getShowPlayedItemsFromPreferences() = getShowPlayedItemsFromPreferences(applicationContext)
|
||||
fun getShowPlayedItemsFromPreferences() = getShowPlayedItemsFromPreferences(application)
|
||||
|
||||
fun setSaveShowPartiallyPlayedItems(showPartiallyPlayedItems: Boolean) {
|
||||
this.showPartiallyPlayedItems.onNext(showPartiallyPlayedItems)
|
||||
PreferenceManager.getDefaultSharedPreferences(application).edit {
|
||||
this.putBoolean(application.getString(R.string.feed_show_partially_watched_items_key), showPartiallyPlayedItems)
|
||||
this.apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun getShowPartiallyPlayedItemsFromPreferences() = getShowPartiallyPlayedItemsFromPreferences(application)
|
||||
|
||||
fun setSaveShowFutureItems(showFutureItems: Boolean) {
|
||||
this.showFutureItems.onNext(showFutureItems)
|
||||
PreferenceManager.getDefaultSharedPreferences(application).edit {
|
||||
this.putBoolean(application.getString(R.string.feed_show_future_items_key), showFutureItems)
|
||||
this.apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun getShowFutureItemsFromPreferences() = getShowFutureItemsFromPreferences(application)
|
||||
|
||||
companion object {
|
||||
private fun getShowPlayedItemsFromPreferences(context: Context) =
|
||||
PreferenceManager.getDefaultSharedPreferences(context)
|
||||
.getBoolean(context.getString(R.string.feed_show_played_items_key), true)
|
||||
}
|
||||
.getBoolean(context.getString(R.string.feed_show_watched_items_key), true)
|
||||
|
||||
class Factory(
|
||||
private val context: Context,
|
||||
private val groupId: Long = FeedGroupEntity.GROUP_ALL_ID
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return FeedViewModel(
|
||||
context.applicationContext,
|
||||
groupId,
|
||||
// Read initial value from preferences
|
||||
getShowPlayedItemsFromPreferences(context.applicationContext)
|
||||
) as T
|
||||
private fun getShowPartiallyPlayedItemsFromPreferences(context: Context) =
|
||||
PreferenceManager.getDefaultSharedPreferences(context)
|
||||
.getBoolean(context.getString(R.string.feed_show_partially_watched_items_key), true)
|
||||
|
||||
private fun getShowFutureItemsFromPreferences(context: Context) =
|
||||
PreferenceManager.getDefaultSharedPreferences(context)
|
||||
.getBoolean(context.getString(R.string.feed_show_future_items_key), true)
|
||||
|
||||
fun getFactory(context: Context, groupId: Long) = viewModelFactory {
|
||||
initializer {
|
||||
FeedViewModel(
|
||||
App.getApp(),
|
||||
groupId,
|
||||
// Read initial value from preferences
|
||||
getShowPlayedItemsFromPreferences(context.applicationContext),
|
||||
getShowPartiallyPlayedItemsFromPreferences(context.applicationContext),
|
||||
getShowFutureItemsFromPreferences(context.applicationContext)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user