Compare commits
738 Commits
v0.18.3
...
v0.19.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6a5a79c99 | ||
|
|
b0403351a1 | ||
|
|
163d7b164c | ||
|
|
8dc4e2c359 | ||
|
|
8418be01a0 | ||
|
|
4ad6daeec0 | ||
|
|
ac9ade8041 | ||
|
|
4d532f8060 | ||
|
|
233ae5df64 | ||
|
|
910cf301c3 | ||
|
|
75e269526d | ||
|
|
9a0402bb69 | ||
|
|
97ed08044b | ||
|
|
dac1d35250 | ||
|
|
04794b8313 | ||
|
|
54a2a06301 | ||
|
|
f7fb767a48 | ||
|
|
cb589a8e73 | ||
|
|
59d93c321e | ||
|
|
c775603df4 | ||
|
|
55c806ff16 | ||
|
|
4994a26986 | ||
|
|
987022cb6a | ||
|
|
bc785e58d2 | ||
|
|
b9b0d8c127 | ||
|
|
2dbef26bcf | ||
|
|
ce6251e1a7 | ||
|
|
816a0eb778 | ||
|
|
e17611170a | ||
|
|
5502434285 | ||
|
|
983e2888cf | ||
|
|
2695e606cc | ||
|
|
7bb1096da6 | ||
|
|
0abeca3020 | ||
|
|
ba6f54936a | ||
|
|
f7beea91af | ||
|
|
f0bdf54338 | ||
|
|
f2d4d99ecf | ||
|
|
ee806deddb | ||
|
|
36ff60380f | ||
|
|
724867acff | ||
|
|
01a40dc620 | ||
|
|
e3a2f8fb93 | ||
|
|
038ae65123 | ||
|
|
8bfb70fad1 | ||
|
|
c626cb9f15 | ||
|
|
f4a11790e0 | ||
|
|
bd5f55a374 | ||
|
|
dde1ecfb29 | ||
|
|
0b5525f94b | ||
|
|
d3d8586990 | ||
|
|
3fc8338e33 | ||
|
|
1d206177a3 | ||
|
|
4b6450ba10 | ||
|
|
b864857c18 | ||
|
|
f0043526d8 | ||
|
|
6b0d74efed | ||
|
|
3eb8f14c89 | ||
|
|
fa64bd487a | ||
|
|
b38f31a947 | ||
|
|
bd92af2f46 | ||
|
|
91d25f8ab6 | ||
|
|
481851041d | ||
|
|
06beb7f3f9 | ||
|
|
601bbf7e98 | ||
|
|
299c2cab90 | ||
|
|
110e953947 | ||
|
|
be6e7c73de | ||
|
|
85b3898a50 | ||
|
|
fddfeed271 | ||
|
|
9c663a90f9 | ||
|
|
0087fb6a02 | ||
|
|
0490dc48d5 | ||
|
|
679a211c85 | ||
|
|
cee87dbc99 | ||
|
|
a825b2dac5 | ||
|
|
c95041b7ee | ||
|
|
c69652e120 | ||
|
|
0b70ae1fa2 | ||
|
|
4bce5f4ad7 | ||
|
|
380a5ed51b | ||
|
|
b15d200774 | ||
|
|
0601036148 | ||
|
|
baed90aa2f | ||
|
|
619757e1c8 | ||
|
|
43bac2841d | ||
|
|
b1cb69821d | ||
|
|
a305e5eb78 | ||
|
|
5724ec718c | ||
|
|
1e1a268340 | ||
|
|
1f349a9fb6 | ||
|
|
5a47a9beaa | ||
|
|
613f241547 | ||
|
|
0bd4e3db89 | ||
|
|
e72b8ea413 | ||
|
|
3f0ea9c83b | ||
|
|
6a16bb1063 | ||
|
|
61990e09ca | ||
|
|
1a80b8e264 | ||
|
|
d04d3b9388 | ||
|
|
ff46f2e7d6 | ||
|
|
49cfa77d0b | ||
|
|
28743ddff2 | ||
|
|
324605c39e | ||
|
|
81c8af6585 | ||
|
|
bc607f0f82 | ||
|
|
880dd1701b | ||
|
|
c619c469e0 | ||
|
|
65995edc5a | ||
|
|
44539c59f3 | ||
|
|
cb7d7341fa | ||
|
|
8c2d22a965 | ||
|
|
48dff8ee2b | ||
|
|
66982ca241 | ||
|
|
0206159536 | ||
|
|
5391bcf1bd | ||
|
|
a0879c20a1 | ||
|
|
d9be6bcde4 | ||
|
|
ffdd28ee39 | ||
|
|
f22bd7cca4 | ||
|
|
f0c874778a | ||
|
|
d960fd5ff4 | ||
|
|
7b7847b43d | ||
|
|
8ae56a42f1 | ||
|
|
110082573f | ||
|
|
243e40633d | ||
|
|
19f48287b4 | ||
|
|
2eab6be7c8 | ||
|
|
f5ca880b93 | ||
|
|
a8b060e498 | ||
|
|
8e25f28287 | ||
|
|
8300eb218d | ||
|
|
3255a8ba8b | ||
|
|
5a4b7308ec | ||
|
|
0641ba827c | ||
|
|
0296131026 | ||
|
|
cb3ba76876 | ||
|
|
e7d6b7bd70 | ||
|
|
14a6ba6002 | ||
|
|
8df9200bb0 | ||
|
|
9a28bfb7ae | ||
|
|
e797272c5c | ||
|
|
cf4413ddb0 | ||
|
|
fb7275e0e6 | ||
|
|
3bd6187c21 | ||
|
|
61309e0c51 | ||
|
|
c3080d7bf2 | ||
|
|
671d6974b6 | ||
|
|
13e859f01b | ||
|
|
96e34b019b | ||
|
|
323b412dca | ||
|
|
870f027715 | ||
|
|
e4aec294e3 | ||
|
|
03179b134c | ||
|
|
2579090d61 | ||
|
|
b4982babec | ||
|
|
0d8d30055e | ||
|
|
83f95c9c74 | ||
|
|
e30e9928d1 | ||
|
|
f0094f265d | ||
|
|
aa48a90dd3 | ||
|
|
451a2e0558 | ||
|
|
8987c16d7c | ||
|
|
4b7e21268d | ||
|
|
e92dbb15b7 | ||
|
|
7212ad40d8 | ||
|
|
9029795c74 | ||
|
|
d7b60b057e | ||
|
|
3a495efeae | ||
|
|
e41886ce60 | ||
|
|
8d28a22337 | ||
|
|
d5d1954aa6 | ||
|
|
8282cba0f1 | ||
|
|
e116e467f6 | ||
|
|
98bf2151cd | ||
|
|
e859986075 | ||
|
|
f7d96f7be1 | ||
|
|
378acb9571 | ||
|
|
b6d114f55c | ||
|
|
9baf348fa7 | ||
|
|
52b15b2572 | ||
|
|
180325060a | ||
|
|
fe528df011 | ||
|
|
d76fbb8f85 | ||
|
|
53ad0bc032 | ||
|
|
d193945d37 | ||
|
|
784df130a1 | ||
|
|
f8d0c1274c | ||
|
|
df2e2fab01 | ||
|
|
0c9f08286c | ||
|
|
d542095c36 | ||
|
|
184564c807 | ||
|
|
2fc45565f1 | ||
|
|
cf572a16d8 | ||
|
|
e774a39075 | ||
|
|
61440368e2 | ||
|
|
dee024b88a | ||
|
|
9712f0cc44 | ||
|
|
f64b704abc | ||
|
|
d5923f4f23 | ||
|
|
597b723885 | ||
|
|
052c96a4b5 | ||
|
|
5404b697c6 | ||
|
|
4d8074d350 | ||
|
|
8d54e56d55 | ||
|
|
0e3bba9853 | ||
|
|
e1f5e47bff | ||
|
|
21985945a2 | ||
|
|
6e9480235a | ||
|
|
bd2934f725 | ||
|
|
6c47a6879c | ||
|
|
c1e144da32 | ||
|
|
13710e13fa | ||
|
|
7e984d7cce | ||
|
|
7de5031bff | ||
|
|
67e6cfe40a | ||
|
|
38d9fe9f21 | ||
|
|
3afaa13126 | ||
|
|
01c0910560 | ||
|
|
d7e98d4d33 | ||
|
|
3d0aa28032 | ||
|
|
72b9d06ed2 | ||
|
|
34261fbf69 | ||
|
|
d8199c952a | ||
|
|
e836b62f2b | ||
|
|
c958b98fdd | ||
|
|
c9f645bb84 | ||
|
|
56e0138f22 | ||
|
|
8295531efe | ||
|
|
c73382176d | ||
|
|
8d9058c9e3 | ||
|
|
c7628e5115 | ||
|
|
dd09da9877 | ||
|
|
8c7f6b6bff | ||
|
|
246317043e | ||
|
|
9458654174 | ||
|
|
c92f89d8de | ||
|
|
499e21d240 | ||
|
|
4c86b6f9b2 | ||
|
|
44ab75f62c | ||
|
|
1e9c8a6a5c | ||
|
|
2e5aff3191 | ||
|
|
66aa9c4247 | ||
|
|
6c9769bf52 | ||
|
|
4193cebf16 | ||
|
|
3b728dd2f5 | ||
|
|
6eb786c00c | ||
|
|
9a4b55801a | ||
|
|
28610750a4 | ||
|
|
2135dd68ec | ||
|
|
1743ab62c0 | ||
|
|
9aedbd258d | ||
|
|
e592ad1460 | ||
|
|
a6a0b76a2b | ||
|
|
01ce3d09e1 | ||
|
|
344a26ecf7 | ||
|
|
d344ef48e8 | ||
|
|
cad1014460 | ||
|
|
fa3baef039 | ||
|
|
9562ef9d79 | ||
|
|
3c8de8969a | ||
|
|
ec148f3188 | ||
|
|
f2fd441c9d | ||
|
|
81a6401de5 | ||
|
|
586b68fce7 | ||
|
|
d32d9eb827 | ||
|
|
0675ff208f | ||
|
|
15e9d9fb93 | ||
|
|
a6cfb5df4c | ||
|
|
006f171024 | ||
|
|
d0ae35a8f3 | ||
|
|
847c930dba | ||
|
|
c8f570d89a | ||
|
|
e766d6f80b | ||
|
|
6cb57a6de6 | ||
|
|
0a517917ff | ||
|
|
bc76b289cd | ||
|
|
f6a92d50f2 | ||
|
|
8f3d351691 | ||
|
|
586b78f180 | ||
|
|
5521348057 | ||
|
|
d0608b5f58 | ||
|
|
588e844941 | ||
|
|
0e4c60c80d | ||
|
|
798567c843 | ||
|
|
35793e2544 | ||
|
|
1da2eaa97a | ||
|
|
2ee64ba613 | ||
|
|
46ce0561ae | ||
|
|
1a3fd1f4a0 | ||
|
|
6c5a765382 | ||
|
|
3ce2de5481 | ||
|
|
86e3d6cf8f | ||
|
|
87b1e365a0 | ||
|
|
d7dea2ce89 | ||
|
|
012de52125 | ||
|
|
17e7aacf31 | ||
|
|
798515f339 | ||
|
|
3a999683f5 | ||
|
|
5eee5361da | ||
|
|
d4df35e045 | ||
|
|
80cfc9e876 | ||
|
|
fb9368f52b | ||
|
|
602451d887 | ||
|
|
b4655156fa | ||
|
|
9afe6f5b00 | ||
|
|
e024341fdf | ||
|
|
c0d7d03b19 | ||
|
|
dcf88b5026 | ||
|
|
acb5fb367a | ||
|
|
bc3666558c | ||
|
|
36acfecade | ||
|
|
0952230083 | ||
|
|
3d370d618b | ||
|
|
672593da02 | ||
|
|
0f95c6f090 | ||
|
|
0f6993d5ce | ||
|
|
959760a21f | ||
|
|
08a26aed67 | ||
|
|
239517ee9d | ||
|
|
eebcb898d4 | ||
|
|
e0e9001144 | ||
|
|
193ce07455 | ||
|
|
d53c4bc810 | ||
|
|
f20c5957c5 | ||
|
|
6f06639137 | ||
|
|
428d68a356 | ||
|
|
d96dd0a687 | ||
|
|
fdd079d32b | ||
|
|
f726d978c1 | ||
|
|
fd9e2e3fb5 | ||
|
|
c94f88ffbd | ||
|
|
35b68474b0 | ||
|
|
ed206858dd | ||
|
|
a0e510e0a9 | ||
|
|
a1d06388d3 | ||
|
|
a77bd8a448 | ||
|
|
3e3e1386ca | ||
|
|
a3555869cb | ||
|
|
1c5fd6e3ee | ||
|
|
1b43a742c3 | ||
|
|
99336e51b2 | ||
|
|
7bd87ac87d | ||
|
|
8f4309360b | ||
|
|
3f29622948 | ||
|
|
95f8e4db22 | ||
|
|
9a689cc662 | ||
|
|
f8a0fd9399 | ||
|
|
2e4ea83c0a | ||
|
|
6e986f7f27 | ||
|
|
71182d1f81 | ||
|
|
c1df2e6327 | ||
|
|
f3aa788b37 | ||
|
|
afd229b169 | ||
|
|
059587f2b3 | ||
|
|
01fba046a1 | ||
|
|
63b295b7e1 | ||
|
|
ed268d2cbe | ||
|
|
0ecec926e8 | ||
|
|
bb9a44f485 | ||
|
|
8345594219 | ||
|
|
28c6e26912 | ||
|
|
80ae0dd206 | ||
|
|
abc9155b5b | ||
|
|
4d676c80e9 | ||
|
|
a7bdc9ae04 | ||
|
|
454a129e93 | ||
|
|
18b86bcd11 | ||
|
|
477dd692c5 | ||
|
|
52ffbd4186 | ||
|
|
e32eeb0e49 | ||
|
|
5253cae13d | ||
|
|
528c8e5ce3 | ||
|
|
30e9c6e6e1 | ||
|
|
0efb45cea4 | ||
|
|
c4412cfac2 | ||
|
|
6a64d351f0 | ||
|
|
694ed15dde | ||
|
|
3ff7fc058c | ||
|
|
7bc2af7ff7 | ||
|
|
5de8626e9a | ||
|
|
60ad989eba | ||
|
|
90a4d40e2c | ||
|
|
49f9735830 | ||
|
|
d0c7c8647e | ||
|
|
716125ad56 | ||
|
|
064c70677e | ||
|
|
bea76b96e5 | ||
|
|
96eebba125 | ||
|
|
23a58644ff | ||
|
|
7048868f3f | ||
|
|
8d3c557626 | ||
|
|
f428d54881 | ||
|
|
89805d3a5d | ||
|
|
680063665e | ||
|
|
e55e999db0 | ||
|
|
a3f6020ab3 | ||
|
|
9035aac014 | ||
|
|
72ee17db16 | ||
|
|
41a2bc6890 | ||
|
|
231455887c | ||
|
|
5242c6dd74 | ||
|
|
9c94d3e7c9 | ||
|
|
454cd2548f | ||
|
|
0f82dbfb33 | ||
|
|
5b470bf6e5 | ||
|
|
7c29539a75 | ||
|
|
96ea906fa8 | ||
|
|
a5c30b56e8 | ||
|
|
213debfab0 | ||
|
|
a78db2701a | ||
|
|
aaf26ec4bf | ||
|
|
598dec5bb0 | ||
|
|
2b04aa3a51 | ||
|
|
ecb9047162 | ||
|
|
4af3efcf93 | ||
|
|
1e8a546aca | ||
|
|
a713cf5af3 | ||
|
|
8b49e8a957 | ||
|
|
a2f7f0b20b | ||
|
|
f5a77a66ec | ||
|
|
1ba81d30da | ||
|
|
d34a8a5dbe | ||
|
|
e28c58f3f4 | ||
|
|
26c584b368 | ||
|
|
68bdf5a28c | ||
|
|
fddbb95a90 | ||
|
|
62c37ce906 | ||
|
|
d2dbeb594b | ||
|
|
8e66b9e587 | ||
|
|
d93a841e9f | ||
|
|
cd71dbb164 | ||
|
|
9e11eeb0d4 | ||
|
|
e5a4463867 | ||
|
|
4a55e9c947 | ||
|
|
ee95f7266f | ||
|
|
16aa1d11b5 | ||
|
|
d173ad4b14 | ||
|
|
b2e877d21f | ||
|
|
9503155a43 | ||
|
|
5ab4dd3048 | ||
|
|
04fa220634 | ||
|
|
579cb1274c | ||
|
|
99d4dffe3b | ||
|
|
f39adeec1a | ||
|
|
f38903f1f3 | ||
|
|
31106bdb28 | ||
|
|
1403b256ca | ||
|
|
67a2d1af1e | ||
|
|
87583243dc | ||
|
|
7d58908a44 | ||
|
|
05dc1a72f5 | ||
|
|
1eb17a5098 | ||
|
|
d04f052afc | ||
|
|
26e8bc227b | ||
|
|
904d5c1a31 | ||
|
|
f9bcd4ba54 | ||
|
|
16d8640c01 | ||
|
|
287f2297c9 | ||
|
|
5191966abd | ||
|
|
b22d45816e | ||
|
|
fa83fc1b30 | ||
|
|
2a32babc8e | ||
|
|
6bcc7db97b | ||
|
|
1318b9fe7d | ||
|
|
ceb7888907 | ||
|
|
055d003625 | ||
|
|
e075cd17e9 | ||
|
|
b6a903f94c | ||
|
|
a3666d24d1 | ||
|
|
10ec88a3df | ||
|
|
a12666be45 | ||
|
|
a669fee6ab | ||
|
|
00f4798a4a | ||
|
|
86893151ac | ||
|
|
5276259c6c | ||
|
|
233174394e | ||
|
|
62ef55d49a | ||
|
|
35de3cc8fe | ||
|
|
eff7d2cf61 | ||
|
|
f7abba8378 | ||
|
|
45a62c9315 | ||
|
|
cf64134114 | ||
|
|
6c7aaab3bc | ||
|
|
8105daa925 | ||
|
|
1d414e892c | ||
|
|
795bbbaf63 | ||
|
|
cfa95459db | ||
|
|
014c49f269 | ||
|
|
a7006a132d | ||
|
|
e201d3f3aa | ||
|
|
a0d363f0f2 | ||
|
|
ee91a0e92e | ||
|
|
5194e2e088 | ||
|
|
35ee90cdd3 | ||
|
|
1359200d15 | ||
|
|
b9b694d7e2 | ||
|
|
901b933477 | ||
|
|
6b96536181 | ||
|
|
fcbd4c8c4e | ||
|
|
5638d876b5 | ||
|
|
920ce10880 | ||
|
|
cf40474c4d | ||
|
|
9e028cc37c | ||
|
|
5c04ea78a1 | ||
|
|
2fe239f2b2 | ||
|
|
913ea219e9 | ||
|
|
17deb349b5 | ||
|
|
2f88c8d978 | ||
|
|
3a3c1a2fa1 | ||
|
|
af936b90a5 | ||
|
|
0310f0bbfe | ||
|
|
87d3fcabd8 | ||
|
|
a6bb3e99b3 | ||
|
|
31fdacf544 | ||
|
|
02ac630dc6 | ||
|
|
fe2f4a42ae | ||
|
|
662b2e0db5 | ||
|
|
1bfe03bac7 | ||
|
|
99fd361bcf | ||
|
|
cae9af69b8 | ||
|
|
a2ab9c3663 | ||
|
|
29028fac31 | ||
|
|
3632c9ddbb | ||
|
|
f6c0804d82 | ||
|
|
14f5f5c9b3 | ||
|
|
084061df65 | ||
|
|
3067f07b2e | ||
|
|
dcc8c18db6 | ||
|
|
7a8c2c2bc3 | ||
|
|
14db765b06 | ||
|
|
d11340fa4e | ||
|
|
77214ff064 | ||
|
|
9d87030c1b | ||
|
|
1574220fb4 | ||
|
|
b5957625dd | ||
|
|
24e29be72c | ||
|
|
f9009b1a7c | ||
|
|
32017d1cd0 | ||
|
|
f380de5d87 | ||
|
|
b4a9b978db | ||
|
|
da8f1e1321 | ||
|
|
cd57de5bf4 | ||
|
|
7636916166 | ||
|
|
545aeed706 | ||
|
|
8ec1fd0650 | ||
|
|
719de0f787 | ||
|
|
98a96ca635 | ||
|
|
170d14a107 | ||
|
|
11e9d8ceda | ||
|
|
7d4eed6989 | ||
|
|
2809d98c13 | ||
|
|
1e67e589e0 | ||
|
|
db06ff4561 | ||
|
|
885b1e99cc | ||
|
|
e5f27ce6fc | ||
|
|
21fe0048b1 | ||
|
|
8fae049a74 | ||
|
|
f4bbacec2f | ||
|
|
9aa4159d0b | ||
|
|
968a7317db | ||
|
|
2aec7b32fd | ||
|
|
e224e84197 | ||
|
|
a8b5209b5a | ||
|
|
9c02a48152 | ||
|
|
77d03192b2 | ||
|
|
6daeadeb68 | ||
|
|
6354fc3bcd | ||
|
|
2960b96e21 | ||
|
|
70b8a126db | ||
|
|
c1267d0356 | ||
|
|
0522e3d03b | ||
|
|
8bb163f676 | ||
|
|
5444cad8bd | ||
|
|
9f7ab6c26e | ||
|
|
f26c997d7d | ||
|
|
6d2bcabee3 | ||
|
|
a0f965e78c | ||
|
|
1f55126008 | ||
|
|
160965f50a | ||
|
|
417114c71e | ||
|
|
64bde6b788 | ||
|
|
99a8f78623 | ||
|
|
aeb2554734 | ||
|
|
6678292fc8 | ||
|
|
f0886fccc3 | ||
|
|
59187429cf | ||
|
|
fe5ff01160 | ||
|
|
ae911e6bd5 | ||
|
|
eb884c9498 | ||
|
|
e7800bebe2 | ||
|
|
981d4fc8df | ||
|
|
317bae9eb3 | ||
|
|
376ced9d0b | ||
|
|
abfa3cfe06 | ||
|
|
e9d69faf74 | ||
|
|
1bded7a41c | ||
|
|
088461598b | ||
|
|
cdafe5e086 | ||
|
|
8014a8c646 | ||
|
|
479433093a | ||
|
|
7719148355 | ||
|
|
6ca9aedcc8 | ||
|
|
83a2e22a2c | ||
|
|
822102e351 | ||
|
|
828d34dc15 | ||
|
|
377c7c200f | ||
|
|
a4d8295ecd | ||
|
|
71a205dc5a | ||
|
|
a925f4196e | ||
|
|
da967f312d | ||
|
|
a1e3609af8 | ||
|
|
24bca378f6 | ||
|
|
390e9f85a2 | ||
|
|
4702f2f1a4 | ||
|
|
8b60168c85 | ||
|
|
e404f93b20 | ||
|
|
86595aa792 | ||
|
|
62c4047487 | ||
|
|
612f074bb5 | ||
|
|
27c430e864 | ||
|
|
611954fc6d | ||
|
|
a2cf0b61ec | ||
|
|
989110c78b | ||
|
|
05f6582f11 | ||
|
|
4ac482db9a | ||
|
|
42efa9d780 | ||
|
|
04adefc15e | ||
|
|
6bc2b9621c | ||
|
|
6355cd8815 | ||
|
|
029255c735 | ||
|
|
69567f4d79 | ||
|
|
c7b1280516 | ||
|
|
39b31478a3 | ||
|
|
18e7aae9df | ||
|
|
47e90c8a60 | ||
|
|
87bfd65f58 | ||
|
|
1d982994ee | ||
|
|
3aabf11bb0 | ||
|
|
5ae05c1270 | ||
|
|
1fdbce3d69 | ||
|
|
c12d01b1ac | ||
|
|
2a49b8ba50 | ||
|
|
f855c1ae8d | ||
|
|
a70662d89f | ||
|
|
1a711efc81 | ||
|
|
68d8b1163c | ||
|
|
c8ebcd2407 | ||
|
|
03b577d228 | ||
|
|
6d4a342fac | ||
|
|
a994f8ab66 | ||
|
|
e2ac09b168 | ||
|
|
9643aa93ae | ||
|
|
3c62490471 | ||
|
|
aa64fc504f | ||
|
|
4aa54f8e1f | ||
|
|
a874991831 | ||
|
|
f0e4ad050f | ||
|
|
4dc36c506d | ||
|
|
b18a642fd6 | ||
|
|
51604826ff | ||
|
|
ea0c55811b | ||
|
|
848adb3e9b | ||
|
|
55565901d1 | ||
|
|
734dc04f35 | ||
|
|
e696f3f944 | ||
|
|
6eeddba9d6 | ||
|
|
fda68f4aab | ||
|
|
5826984ff9 | ||
|
|
bc6336c376 | ||
|
|
1ed6611b7d | ||
|
|
a4d2f0a030 | ||
|
|
d189178d46 | ||
|
|
38372a5f6b | ||
|
|
e33d85c4ff | ||
|
|
40e0b98964 | ||
|
|
47304e3f96 | ||
|
|
6160d5c789 | ||
|
|
9fc1efb35d | ||
|
|
6418140797 | ||
|
|
8346adba34 | ||
|
|
75d8b3e2c7 | ||
|
|
0d5369dc4c | ||
|
|
28864231c6 | ||
|
|
c0422547d3 | ||
|
|
4c51b935cc | ||
|
|
abe7225079 | ||
|
|
9882ffb7b7 | ||
|
|
5d551f3cfa | ||
|
|
e4cbe2ae3a | ||
|
|
7de76927ee | ||
|
|
1a3b9bfb34 | ||
|
|
80bbe8f6ff | ||
|
|
b6fc1210dd | ||
|
|
db7d7f9993 | ||
|
|
b540f29197 | ||
|
|
bbb82ddc12 | ||
|
|
6d2778cd7a | ||
|
|
2ff7f1f0ac | ||
|
|
556b215920 | ||
|
|
f6f324cfa9 | ||
|
|
fe8399a36d | ||
|
|
35b17af75b | ||
|
|
1a35df92eb | ||
|
|
1457e9c089 | ||
|
|
67b80043e5 | ||
|
|
63d30ebf20 | ||
|
|
ba39c2307b | ||
|
|
fb5b4d78ba | ||
|
|
54a70e9e69 | ||
|
|
3c315be080 | ||
|
|
e3c47b8500 | ||
|
|
38aa2f1cb3 | ||
|
|
d91d873c61 | ||
|
|
b6b2c0335f | ||
|
|
eb8d7f8392 | ||
|
|
9ebbaa5bb4 | ||
|
|
e1e6ffdfcb | ||
|
|
1a9bb5d061 | ||
|
|
67c545152b | ||
|
|
da78cf4a2f | ||
|
|
c2cbd1c925 | ||
|
|
f2b6bda258 | ||
|
|
c86573b671 | ||
|
|
3385058775 | ||
|
|
abf32626ac | ||
|
|
40f4682f17 | ||
|
|
a0e9bd73c4 | ||
|
|
0f00a8fad9 | ||
|
|
d86b9c177a | ||
|
|
0970c88338 | ||
|
|
72c888cf21 | ||
|
|
0533d54bbd | ||
|
|
f26a1e49e4 | ||
|
|
832ab68777 | ||
|
|
7e6f88e33f | ||
|
|
518200028f | ||
|
|
2fa7753e91 |
75
.github/ISSUE_TEMPLATE/bug-report-playback.yaml
vendored
75
.github/ISSUE_TEMPLATE/bug-report-playback.yaml
vendored
@@ -4,6 +4,22 @@ labels:
|
||||
- bug
|
||||
- playback
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: before-posting
|
||||
attributes:
|
||||
label: "This issue respects the following points:"
|
||||
description: All conditions are **required**.
|
||||
options:
|
||||
- label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin-androidtv/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
|
||||
required: true
|
||||
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
|
||||
required: true
|
||||
- label: This report addresses only a single issue; If you encounter multiple issues, kindly create separate reports for each one.
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Bug information
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
@@ -20,10 +36,24 @@ body:
|
||||
Instead, I expect …
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: mediainfo
|
||||
attributes:
|
||||
label: Media info of the file
|
||||
description: |
|
||||
Please share the media information for the file causing issues. You can use a variety of tools to retrieve this information.
|
||||
- Use ffprobe (`ffprobe ./file.mp4`)
|
||||
- Copy the media info from the web interface
|
||||
placeholder: Paste media info…
|
||||
render: shell
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Logs
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
label: Client logs
|
||||
description: |
|
||||
Please paste your crash logs here if applicable. You can find these in your servers dashboard under "logs".
|
||||
The file name should start with "upload_org.jellyfin.androidtv".
|
||||
@@ -37,31 +67,34 @@ body:
|
||||
Please paste your FFmpeg logs here. You can find these in your servers dashboard under "logs".
|
||||
placeholder: Paste logs…
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: mediainfo
|
||||
- type: markdown
|
||||
attributes:
|
||||
label: Media info of the file
|
||||
description: |
|
||||
Please share the media information for the file causing issues. You can use a variety of tools to retrieve this information.
|
||||
- Use ffprobe (`ffprobe ./file.mp4`)
|
||||
- Use the Media Info tool (set to text format, download here: https://mediaarea.net/en/MediaInfo)
|
||||
- Copy the media info from the web interface
|
||||
placeholder: Paste media info…
|
||||
render: shell
|
||||
value: |
|
||||
## Environment
|
||||
- type: input
|
||||
id: app-version
|
||||
attributes:
|
||||
label: Application version
|
||||
description: The version of the installed Jellyfin Android TV app.
|
||||
placeholder: 0.14.0
|
||||
placeholder: 0.18.2
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: installation-source
|
||||
attributes:
|
||||
label: Where did you install the app from?
|
||||
description: Choose the appropriate app store or installation method.
|
||||
options:
|
||||
- Google Play Store
|
||||
- Amazon Appstore
|
||||
- F-Droid
|
||||
- Sideloaded APK
|
||||
- type: input
|
||||
id: device-info
|
||||
attributes:
|
||||
label: Device information
|
||||
description: Manufacturer and model
|
||||
placeholder: Nvidia Shield Pro (2017), Amazon Fire TV Stick v1 (2014)
|
||||
placeholder: Nvidia Shield TV Pro (2019), Fire TV Stick HD (2020)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
@@ -69,7 +102,7 @@ body:
|
||||
attributes:
|
||||
label: Android version
|
||||
description: Version of the OS and other information (e.g. custom ROM / OEM skin)
|
||||
placeholder: Android 9, Fire OS 7
|
||||
placeholder: Android 13, Fire OS 7
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
@@ -77,6 +110,18 @@ body:
|
||||
attributes:
|
||||
label: Jellyfin server version
|
||||
description: If on unstable, please specify the commit hash.
|
||||
placeholder: 10.8.1
|
||||
placeholder: 10.10.2
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Additional
|
||||
- type: input
|
||||
id: other-sources
|
||||
attributes:
|
||||
label: Other sources
|
||||
description: If this topic has been discussed outside of GitHub, please link it.
|
||||
placeholder: https://forum.jellyfin.org/…
|
||||
validations:
|
||||
required: false
|
||||
|
||||
43
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
43
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -3,6 +3,22 @@ description: Create a bug report
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: before-posting
|
||||
attributes:
|
||||
label: "This issue respects the following points:"
|
||||
description: All conditions are **required**.
|
||||
options:
|
||||
- label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin-androidtv/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
|
||||
required: true
|
||||
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
|
||||
required: true
|
||||
- label: This report addresses only a single issue; If you encounter multiple issues, kindly create separate reports for each one.
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Bug information
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
@@ -28,12 +44,16 @@ body:
|
||||
The file name should start with "upload_org.jellyfin.androidtv".
|
||||
Make sure that they don't contain any sensitive information like server URL, auth tokens or passwords.
|
||||
placeholder: Paste logs…
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Environment
|
||||
- type: input
|
||||
id: app-version
|
||||
attributes:
|
||||
label: Application version
|
||||
description: The version of the installed Jellyfin Android TV app.
|
||||
placeholder: 0.14.0
|
||||
placeholder: 0.18.2
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
@@ -42,15 +62,16 @@ body:
|
||||
label: Where did you install the app from?
|
||||
description: Choose the appropriate app store or installation method.
|
||||
options:
|
||||
- Google Play
|
||||
- Google Play Store
|
||||
- Amazon Appstore
|
||||
- F-Droid
|
||||
- Sideloaded APK
|
||||
- type: input
|
||||
id: device-info
|
||||
attributes:
|
||||
label: Device information
|
||||
description: Manufacturer and model
|
||||
placeholder: Nvidia Shield Pro (2017), Amazon Fire TV Stick v1 (2014)
|
||||
placeholder: Nvidia Shield TV Pro (2019), Fire TV Stick HD (2020)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
@@ -58,7 +79,7 @@ body:
|
||||
attributes:
|
||||
label: Android version
|
||||
description: Version of the OS and other information (e.g. custom ROM / OEM skin)
|
||||
placeholder: Android 9, Fire OS 7
|
||||
placeholder: Android 13, Fire OS 7
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
@@ -66,6 +87,18 @@ body:
|
||||
attributes:
|
||||
label: Jellyfin server version
|
||||
description: If on unstable, please specify the commit hash.
|
||||
placeholder: 10.8.1
|
||||
placeholder: 10.10.2
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Additional
|
||||
- type: input
|
||||
id: other-sources
|
||||
attributes:
|
||||
label: Other sources
|
||||
description: If this topic has been discussed outside of GitHub, please link it.
|
||||
placeholder: https://forum.jellyfin.org/…
|
||||
validations:
|
||||
required: false
|
||||
|
||||
54
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
54
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
@@ -3,12 +3,54 @@ description: Request a new feature
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
- type: checkboxes
|
||||
id: before-posting
|
||||
attributes:
|
||||
label: Describe the feature you'd like
|
||||
description: |
|
||||
A clear and concise description of what you want to request.
|
||||
You can also attach screenshots or screen recordings to help explain your request.
|
||||
label: "This request respects the following points:"
|
||||
description: All conditions are **required**.
|
||||
options:
|
||||
- label: This request is **not** already on [GitHub](https://github.com/jellyfin/jellyfin-androidtv/labels/enhancement) _(I've searched it)_.
|
||||
required: true
|
||||
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Problem description
|
||||
description: Describe the issue you are experiencing or the gap you are trying to address.
|
||||
placeholder: |
|
||||
The Android TV app does not support displaying lyrics, while the Jellyfin web interface does.
|
||||
This creates an inconsistent user experience across platforms.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Provide a detailed description of what you would like to see implemented or changed.
|
||||
placeholder: |
|
||||
The app could display lyrics in the screensaver. Additionally, a lyrics section could be integrated into the page that opens when music is played.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Outline any other approaches you have thought about or explored to solve the problem.
|
||||
placeholder: |
|
||||
An alternative approach could involve adding a dedicated "Lyrics" button in the app, similar to the Jellyfin web interface.
|
||||
Users could press this button to open a full-screen lyrics view.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Include any relevant details, resources, or screenshots that might help in understanding or implementing the request.
|
||||
placeholder: Add any additional context here.
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: other-sources
|
||||
attributes:
|
||||
label: Other sources
|
||||
description: If this topic has been discussed outside of GitHub, please link it.
|
||||
placeholder: https://forum.jellyfin.org/…
|
||||
validations:
|
||||
required: false
|
||||
|
||||
8
.github/workflows/app-build.yaml
vendored
8
.github/workflows/app-build.yaml
vendored
@@ -15,20 +15,20 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
- name: Assemble debug APKs
|
||||
run: ./gradlew assembleDebug
|
||||
- name: Create publish bundle
|
||||
run: mkdir -p build/gh-app-publish/; find app/build/ -iname "*.apk" -exec mv "{}" build/gh-app-publish/ \;
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: build-artifacts
|
||||
retention-days: 14
|
||||
|
||||
8
.github/workflows/app-lint.yaml
vendored
8
.github/workflows/app-lint.yaml
vendored
@@ -17,18 +17,18 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
- name: Run detekt and lint tasks
|
||||
run: ./gradlew detekt lint
|
||||
- name: Upload SARIF files
|
||||
uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
|
||||
uses: github/codeql-action/upload-sarif@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
sarif_file: .
|
||||
|
||||
8
.github/workflows/app-publish.yaml
vendored
8
.github/workflows/app-publish.yaml
vendored
@@ -12,14 +12,14 @@ jobs:
|
||||
if: ${{ contains(github.repository_owner, 'jellyfin') }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
- name: Set JELLYFIN_VERSION
|
||||
run: echo "JELLYFIN_VERSION=$(echo ${GITHUB_REF#refs/tags/v} | tr / -)" >> $GITHUB_ENV
|
||||
- name: Assemble release files
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
remote_user: ${{ secrets.REPO_USER }}
|
||||
remote_key: ${{ secrets.REPO_KEY }}
|
||||
- name: Update repo.jellyfin.org symlinks
|
||||
uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0
|
||||
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
|
||||
with:
|
||||
host: ${{ secrets.REPO_HOST }}
|
||||
username: ${{ secrets.REPO_USER }}
|
||||
|
||||
6
.github/workflows/app-test.yaml
vendored
6
.github/workflows/app-test.yaml
vendored
@@ -16,13 +16,13 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
- name: Run test task
|
||||
run: ./gradlew test
|
||||
|
||||
4
.github/workflows/gradlew-validate.yaml
vendored
4
.github/workflows/gradlew-validate.yaml
vendored
@@ -17,6 +17,6 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
2
.github/workflows/repo-merge-conflict.yaml
vendored
2
.github/workflows/repo-merge-conflict.yaml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
if: ${{ contains(github.repository_owner, 'jellyfin') }}
|
||||
steps:
|
||||
- uses: eps1lon/actions-label-merge-conflict@1b1b1fcde06a9b3d089f3464c96417961dde1168 # v3.0.2
|
||||
- uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||
with:
|
||||
dirtyLabel: merge conflict
|
||||
repoToken: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
2
.github/workflows/repo-stale.yaml
vendored
2
.github/workflows/repo-stale.yaml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
if: ${{ contains(github.repository_owner, 'jellyfin') }}
|
||||
steps:
|
||||
- uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
|
||||
- uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
days-before-stale: 120
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
- [mohd-akram](https://github.com/mohd-akram)
|
||||
- [3l0w](https://github.com/3l0w)
|
||||
- [MajMongoose](https://github.com/majmongoose)
|
||||
- [Olaren15](https://github.com/Olaren15)
|
||||
- [dtrexler](https://github.com/dtrexler)
|
||||
|
||||
# Emby Contributors
|
||||
|
||||
|
||||
@@ -13,12 +13,13 @@
|
||||
<issue id="MangledCRLF" severity="warning" />
|
||||
<issue id="NegativeMargin" severity="warning" />
|
||||
<issue id="NoHardKeywords" severity="warning" />
|
||||
<issue id="NotificationPermission">
|
||||
<ignore regexp="com.bumptech.glide.request.target.NotificationTarget" />
|
||||
</issue>
|
||||
<issue id="Registered" severity="warning" />
|
||||
<issue id="RequiredSize" severity="error" />
|
||||
<issue id="Typos" severity="ignore" />
|
||||
<issue id="UnknownNullness" severity="informational" />
|
||||
<issue id="UnusedIds" severity="ignore" />
|
||||
<issue id="UnusedAttribute">
|
||||
<ignore regexp="usesCleartextTraffic" />
|
||||
<ignore regexp="networkSecurityConfig" />
|
||||
</issue>
|
||||
</lint>
|
||||
|
||||
@@ -18,7 +18,6 @@ android {
|
||||
applicationId = namespace
|
||||
versionName = project.getVersionName()
|
||||
versionCode = getVersionCode(versionName!!)
|
||||
setProperty("archivesBaseName", "jellyfin-androidtv-v$versionName")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
@@ -32,7 +31,7 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
val release by getting {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
|
||||
// Set package names used in various XML files
|
||||
@@ -46,7 +45,7 @@ android {
|
||||
buildConfigField("boolean", "DEVELOPMENT", "false")
|
||||
}
|
||||
|
||||
val debug by getting {
|
||||
debug {
|
||||
// Use different application id to run release and debug at the same time
|
||||
applicationIdSuffix = ".debug"
|
||||
|
||||
@@ -74,12 +73,9 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
// Remove the "generated" timestamp to allow for reproducible builds
|
||||
excludeFields = arrayOf("generated")
|
||||
}
|
||||
base.archivesName.set("jellyfin-androidtv-v${project.getVersionName()}")
|
||||
|
||||
val versionTxt by tasks.registering {
|
||||
tasks.register("versionTxt") {
|
||||
val path = layout.buildDirectory.asFile.get().resolve("version.txt")
|
||||
|
||||
doLast {
|
||||
@@ -96,7 +92,6 @@ dependencies {
|
||||
implementation(projects.playback.media3.exoplayer)
|
||||
implementation(projects.playback.media3.session)
|
||||
implementation(projects.preference)
|
||||
implementation(libs.jellyfin.apiclient)
|
||||
implementation(libs.jellyfin.sdk) {
|
||||
// Change version if desired
|
||||
val sdkVersion = findProperty("sdk.version")?.toString()
|
||||
@@ -114,6 +109,7 @@ dependencies {
|
||||
// Android(x)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.fragment)
|
||||
implementation(libs.androidx.fragment.compose)
|
||||
implementation(libs.androidx.leanback.core)
|
||||
@@ -129,16 +125,18 @@ dependencies {
|
||||
implementation(libs.androidx.cardview)
|
||||
implementation(libs.androidx.startup)
|
||||
implementation(libs.bundles.androidx.compose)
|
||||
implementation(libs.androidx.tv.material)
|
||||
implementation(libs.accompanist.permissions)
|
||||
|
||||
// Dependency Injection
|
||||
implementation(libs.bundles.koin)
|
||||
|
||||
// Media players
|
||||
implementation(libs.androidx.media3.exoplayer)
|
||||
implementation(libs.androidx.media3.datasource.okhttp)
|
||||
implementation(libs.androidx.media3.exoplayer.hls)
|
||||
implementation(libs.androidx.media3.ui)
|
||||
implementation(libs.jellyfin.androidx.media3.ffmpeg.decoder)
|
||||
implementation(libs.libass.media3)
|
||||
|
||||
// Markdown
|
||||
implementation(libs.bundles.markwon)
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
android:largeHeap="true"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Jellyfin"
|
||||
android:usesCleartextTraffic="true">
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<!-- Legacy service required for AccountManagerMigration -->
|
||||
<service
|
||||
@@ -110,7 +111,6 @@
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:noHistory="true"
|
||||
android:screenOrientation="landscape"
|
||||
android:windowSoftInputMode="adjustNothing">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -133,12 +133,10 @@
|
||||
<activity
|
||||
android:name=".ui.browsing.MainActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="landscape"
|
||||
android:windowSoftInputMode="adjustNothing" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.preference.PreferencesActivity"
|
||||
android:screenOrientation="behind"
|
||||
android:theme="@style/Theme.Jellyfin.Preferences" />
|
||||
|
||||
<activity android:name=".ui.playback.ExternalPlayerActivity" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.startup.AppInitializer
|
||||
import androidx.startup.Initializer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.auth.repository.SessionRepository
|
||||
import org.jellyfin.androidtv.di.KoinInitializer
|
||||
@@ -17,7 +18,7 @@ class SessionInitializer : Initializer<Unit> {
|
||||
.koin
|
||||
|
||||
// Restore system session
|
||||
ProcessLifecycleOwner.get().lifecycleScope.launch {
|
||||
ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.IO) {
|
||||
koin.get<SessionRepository>().restoreSession(destroyOnly = false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package org.jellyfin.androidtv.auth.apiclient
|
||||
|
||||
import org.jellyfin.androidtv.auth.repository.Session
|
||||
import org.jellyfin.androidtv.auth.store.AuthenticationStore
|
||||
import org.jellyfin.androidtv.util.sdk.legacy
|
||||
import org.jellyfin.apiclient.interaction.ApiClient
|
||||
import org.jellyfin.apiclient.model.apiclient.ServerInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import timber.log.Timber
|
||||
|
||||
class ApiBinder(
|
||||
private val api: ApiClient,
|
||||
private val authenticationStore: AuthenticationStore,
|
||||
) {
|
||||
fun updateSession(session: Session?, deviceInfo: DeviceInfo): Boolean {
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
val success = try {
|
||||
updateSessionInternal(session, deviceInfo)
|
||||
} catch (throwable: Throwable) {
|
||||
Timber.e(throwable, "Unable to update legacy API session.")
|
||||
false
|
||||
}
|
||||
|
||||
return success
|
||||
}
|
||||
|
||||
private fun updateSessionInternal(session: Session?, deviceInfo: DeviceInfo): Boolean {
|
||||
if (session == null) return true
|
||||
|
||||
val server = authenticationStore.getServer(session.serverId)
|
||||
if (server == null) {
|
||||
Timber.e("Could not bind API because server ${session.serverId} was not found in the store.")
|
||||
return false
|
||||
}
|
||||
|
||||
api.device = deviceInfo.legacy()
|
||||
api.SetAuthenticationInfo(session.accessToken, session.userId.toString())
|
||||
api.EnableAutomaticNetworking(ServerInfo().apply {
|
||||
id = session.serverId.toString()
|
||||
name = server.name
|
||||
address = server.address.removeSuffix("/")
|
||||
userId = session.userId.toString()
|
||||
accessToken = session.accessToken
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ data class Server(
|
||||
val setupCompleted: Boolean = true,
|
||||
var dateLastAccessed: Instant = Instant.MIN,
|
||||
) {
|
||||
private val serverVersion = version?.let(ServerVersion::fromString)
|
||||
val serverVersion = version?.let(ServerVersion::fromString)
|
||||
val versionSupported = serverVersion != null && serverVersion >= ServerRepository.minimumServerVersion
|
||||
|
||||
operator fun compareTo(other: ServerVersion): Int = serverVersion?.compareTo(other) ?: -1
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package org.jellyfin.androidtv.auth.repository
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import org.jellyfin.androidtv.auth.model.ApiClientErrorLoginState
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticateMethod
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticatedState
|
||||
@@ -21,7 +23,10 @@ import org.jellyfin.androidtv.auth.model.ServerVersionNotSupported
|
||||
import org.jellyfin.androidtv.auth.model.User
|
||||
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
|
||||
import org.jellyfin.androidtv.auth.store.AuthenticationStore
|
||||
import org.jellyfin.androidtv.util.ImageHelper
|
||||
import org.jellyfin.androidtv.util.apiclient.JellyfinImage
|
||||
import org.jellyfin.androidtv.util.apiclient.JellyfinImageSource
|
||||
import org.jellyfin.androidtv.util.apiclient.getUrl
|
||||
import org.jellyfin.androidtv.util.apiclient.primaryImage
|
||||
import org.jellyfin.androidtv.util.sdk.forUser
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
@@ -29,10 +34,10 @@ import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.exception.TimeoutException
|
||||
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
||||
import org.jellyfin.sdk.api.client.extensions.authenticateWithQuickConnect
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import org.jellyfin.sdk.model.api.AuthenticationResult
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
import timber.log.Timber
|
||||
import java.time.Instant
|
||||
@@ -55,9 +60,6 @@ class AuthenticationRepositoryImpl(
|
||||
private val defaultDeviceInfo: DeviceInfo,
|
||||
) : AuthenticationRepository {
|
||||
override fun authenticate(server: Server, method: AuthenticateMethod): Flow<LoginState> {
|
||||
// Check server version first
|
||||
if (!server.versionSupported) return flowOf(ServerVersionNotSupported(server))
|
||||
|
||||
return when (method) {
|
||||
is AutomaticAuthenticateMethod -> authenticateAutomatic(server, method.user)
|
||||
is CredentialAuthenticateMethod -> authenticateCredential(server, method.username, method.password)
|
||||
@@ -94,7 +96,7 @@ class AuthenticationRepositoryImpl(
|
||||
}
|
||||
|
||||
emitAll(authenticateAuthenticationResult(server, result))
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
private fun authenticateQuickConnect(server: Server, secret: String) = flow {
|
||||
val api = jellyfin.createApi(server.address, deviceInfo = defaultDeviceInfo)
|
||||
@@ -112,7 +114,7 @@ class AuthenticationRepositoryImpl(
|
||||
}
|
||||
|
||||
emitAll(authenticateAuthenticationResult(server, result))
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
private fun authenticateAuthenticationResult(server: Server, result: AuthenticationResult) = flow {
|
||||
val accessToken = result.accessToken ?: return@flow emit(RequireSignInState)
|
||||
@@ -122,7 +124,7 @@ class AuthenticationRepositoryImpl(
|
||||
serverId = server.id,
|
||||
name = userInfo.name!!,
|
||||
accessToken = result.accessToken,
|
||||
imageTag = userInfo.primaryImageTag,
|
||||
imageTag = userInfo.primaryImage?.tag,
|
||||
lastUsed = Instant.now().toEpochMilli(),
|
||||
)
|
||||
|
||||
@@ -132,16 +134,18 @@ class AuthenticationRepositoryImpl(
|
||||
emit(AuthenticatedState)
|
||||
} else {
|
||||
Timber.w("Failed to set active session after authenticating")
|
||||
emit(RequireSignInState)
|
||||
if (!server.versionSupported) emit(ServerVersionNotSupported(server))
|
||||
else emit(RequireSignInState)
|
||||
}
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
private fun authenticateToken(server: Server, user: User) = flow {
|
||||
emit(AuthenticatingState)
|
||||
|
||||
val success = setActiveSession(user, server)
|
||||
if (!success) {
|
||||
emit(RequireSignInState)
|
||||
if (!server.versionSupported) emit(ServerVersionNotSupported(server))
|
||||
else emit(RequireSignInState)
|
||||
} else try {
|
||||
// Update user info
|
||||
val userInfo by userApiClient.userApi.getCurrentUser()
|
||||
@@ -155,7 +159,7 @@ class AuthenticationRepositoryImpl(
|
||||
Timber.e(err, "Unable to get current user data")
|
||||
emit(ApiClientErrorLoginState(err))
|
||||
}
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
private suspend fun authenticateFinish(server: Server, userInfo: UserDto, accessToken: String) {
|
||||
val currentUser = authenticationStore.getUser(server.id, userInfo.id)
|
||||
@@ -163,11 +167,11 @@ class AuthenticationRepositoryImpl(
|
||||
val updatedUser = currentUser?.copy(
|
||||
name = userInfo.name!!,
|
||||
lastUsed = Instant.now().toEpochMilli(),
|
||||
imageTag = userInfo.primaryImageTag,
|
||||
imageTag = userInfo.primaryImage?.tag,
|
||||
accessToken = accessToken,
|
||||
) ?: AuthenticationStoreUser(
|
||||
name = userInfo.name!!,
|
||||
imageTag = userInfo.primaryImageTag,
|
||||
imageTag = userInfo.primaryImage?.tag,
|
||||
accessToken = accessToken,
|
||||
)
|
||||
authenticationStore.putUser(server.id, userInfo.id, updatedUser)
|
||||
@@ -199,11 +203,15 @@ class AuthenticationRepositoryImpl(
|
||||
else false
|
||||
}
|
||||
|
||||
override fun getUserImageUrl(server: Server, user: User): String? = user.imageTag?.let { tag ->
|
||||
jellyfin.createApi(server.address).imageApi.getUserImageUrl(
|
||||
userId = user.id,
|
||||
tag = tag,
|
||||
maxHeight = ImageHelper.MAX_PRIMARY_IMAGE_HEIGHT
|
||||
override fun getUserImageUrl(server: Server, user: User): String? = user.imageTag?.let { primaryImageTag ->
|
||||
JellyfinImage(
|
||||
item = user.id,
|
||||
source = JellyfinImageSource.USER,
|
||||
type = ImageType.PRIMARY,
|
||||
tag = primaryImageTag,
|
||||
blurHash = null,
|
||||
aspectRatio = null,
|
||||
index = null
|
||||
)
|
||||
}
|
||||
}?.getUrl(jellyfin.createApi(server.address))
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package org.jellyfin.androidtv.auth.repository
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
|
||||
import org.jellyfin.androidtv.auth.model.ConnectedState
|
||||
import org.jellyfin.androidtv.auth.model.ConnectingState
|
||||
@@ -23,12 +26,12 @@ import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||
import org.jellyfin.sdk.discovery.RecommendedServerInfo
|
||||
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
||||
import org.jellyfin.sdk.model.ServerVersion
|
||||
import org.jellyfin.sdk.model.api.BrandingOptions
|
||||
import org.jellyfin.sdk.model.api.ServerDiscoveryInfo
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
import timber.log.Timber
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import org.jellyfin.sdk.model.api.BrandingOptions as BrandingOptionsDto
|
||||
|
||||
/**
|
||||
* Repository to maintain servers.
|
||||
@@ -36,13 +39,16 @@ import java.util.UUID
|
||||
interface ServerRepository {
|
||||
val storedServers: StateFlow<List<Server>>
|
||||
val discoveredServers: StateFlow<List<Server>>
|
||||
val currentServer: StateFlow<Server?>
|
||||
|
||||
suspend fun loadStoredServers()
|
||||
suspend fun loadDiscoveryServers()
|
||||
|
||||
fun setCurrentServer(server: Server?)
|
||||
|
||||
fun addServer(address: String): Flow<ServerAdditionState>
|
||||
suspend fun getServer(id: UUID): Server?
|
||||
suspend fun updateServer(server: Server): Boolean
|
||||
suspend fun getServer(id: UUID, eagerUpdate: Boolean = false): Server?
|
||||
suspend fun updateServer(server: Server, force: Boolean = false): Boolean
|
||||
suspend fun deleteServer(server: UUID): Boolean
|
||||
|
||||
companion object {
|
||||
@@ -64,6 +70,9 @@ class ServerRepositoryImpl(
|
||||
private val _discoveredServers = MutableStateFlow(emptyList<Server>())
|
||||
override val discoveredServers = _discoveredServers.asStateFlow()
|
||||
|
||||
private val _currentServer = MutableStateFlow<Server?>(null)
|
||||
override val currentServer = _currentServer.asStateFlow()
|
||||
|
||||
// Loading data
|
||||
override suspend fun loadStoredServers() {
|
||||
authenticationStore.getServers()
|
||||
@@ -84,6 +93,10 @@ class ServerRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override fun setCurrentServer(server: Server?) {
|
||||
_currentServer.value = server
|
||||
}
|
||||
|
||||
// Mutating data
|
||||
override fun addServer(address: String): Flow<ServerAdditionState> = flow {
|
||||
Timber.i("Adding server %s", address)
|
||||
@@ -102,6 +115,7 @@ class ServerRepositoryImpl(
|
||||
goodRecommendations += recommendedServer
|
||||
false
|
||||
}
|
||||
|
||||
else -> {
|
||||
badRecommendations += recommendedServer
|
||||
false
|
||||
@@ -159,13 +173,17 @@ class ServerRepositoryImpl(
|
||||
.mapValues { (_, entry) -> entry.flatMap { server -> server.issues } }
|
||||
emit(UnableToConnectState(addressCandidatesWithIssues))
|
||||
}
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
override suspend fun getServer(id: UUID): Server? {
|
||||
override suspend fun getServer(id: UUID, eagerUpdate: Boolean): Server? {
|
||||
val server = authenticationStore.getServer(id) ?: return null
|
||||
|
||||
val updatedServer = try {
|
||||
updateServerInternal(id, server)
|
||||
val forceUpdate = eagerUpdate && server.version
|
||||
?.let(ServerVersion::fromString)
|
||||
?.let { version -> version < ServerRepository.minimumServerVersion } == true
|
||||
|
||||
updateServerInternal(id, server, forceUpdate)
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to update server")
|
||||
null
|
||||
@@ -174,12 +192,12 @@ class ServerRepositoryImpl(
|
||||
return (updatedServer ?: server).asServer(id)
|
||||
}
|
||||
|
||||
override suspend fun updateServer(server: Server): Boolean {
|
||||
override suspend fun updateServer(server: Server, force: Boolean): Boolean {
|
||||
// Only update existing servers
|
||||
val serverInfo = authenticationStore.getServer(server.id) ?: return false
|
||||
|
||||
return try {
|
||||
updateServerInternal(server.id, serverInfo) != null
|
||||
updateServerInternal(server.id, serverInfo, force) != null
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to update server")
|
||||
|
||||
@@ -187,25 +205,32 @@ class ServerRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateServerInternal(id: UUID, server: AuthenticationStoreServer): AuthenticationStoreServer? {
|
||||
private suspend fun updateServerInternal(
|
||||
id: UUID,
|
||||
server: AuthenticationStoreServer,
|
||||
forceUpdate: Boolean
|
||||
): AuthenticationStoreServer? {
|
||||
val now = Instant.now().toEpochMilli()
|
||||
|
||||
// Only update every 10 minutes
|
||||
if (now - server.lastRefreshed < 600000 && server.version != null) return null
|
||||
if (now - server.lastRefreshed < 600000 && server.version != null && !forceUpdate) return null
|
||||
|
||||
val api = jellyfin.createApi(server.address)
|
||||
// Get login disclaimer
|
||||
val branding = api.getBrandingOptionsOrDefault()
|
||||
val systemInfo by api.systemApi.getPublicSystemInfo()
|
||||
val newServer = withContext(Dispatchers.IO) {
|
||||
val api = jellyfin.createApi(server.address)
|
||||
|
||||
val newServer = server.copy(
|
||||
name = systemInfo.serverName ?: server.name,
|
||||
version = systemInfo.version ?: server.version,
|
||||
loginDisclaimer = branding.loginDisclaimer ?: server.loginDisclaimer,
|
||||
splashscreenEnabled = branding.splashscreenEnabled,
|
||||
setupCompleted = systemInfo.startupWizardCompleted ?: server.setupCompleted,
|
||||
lastRefreshed = now
|
||||
)
|
||||
// Get login disclaimer
|
||||
val branding = api.getBrandingOptionsOrDefault()
|
||||
val systemInfo by api.systemApi.getPublicSystemInfo()
|
||||
|
||||
server.copy(
|
||||
name = systemInfo.serverName ?: server.name,
|
||||
version = systemInfo.version ?: server.version,
|
||||
loginDisclaimer = branding.loginDisclaimer ?: server.loginDisclaimer,
|
||||
splashscreenEnabled = branding.splashscreenEnabled,
|
||||
setupCompleted = systemInfo.startupWizardCompleted ?: server.setupCompleted,
|
||||
lastRefreshed = now
|
||||
)
|
||||
}
|
||||
authenticationStore.putServer(id, newServer)
|
||||
|
||||
return newServer
|
||||
@@ -237,7 +262,7 @@ class ServerRepositoryImpl(
|
||||
brandingApi.getBrandingOptions().content
|
||||
} catch (exception: InvalidContentException) {
|
||||
Timber.w(exception, "Invalid branding options response, using default value")
|
||||
BrandingOptions(
|
||||
BrandingOptionsDto(
|
||||
loginDisclaimer = null,
|
||||
customCss = null,
|
||||
splashscreenEnabled = false,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.jellyfin.androidtv.auth.repository
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.auth.model.PrivateUser
|
||||
import org.jellyfin.androidtv.auth.model.PublicUser
|
||||
import org.jellyfin.androidtv.auth.model.Server
|
||||
@@ -47,7 +49,9 @@ class ServerUserRepositoryImpl(
|
||||
val api = jellyfin.createApi(server.address)
|
||||
|
||||
return try {
|
||||
val users by api.userApi.getPublicUsers()
|
||||
val users = withContext(Dispatchers.IO) {
|
||||
api.userApi.getPublicUsers().content
|
||||
}
|
||||
users.mapNotNull(UserDto::toPublicUser)
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to retrieve public users")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.jellyfin.androidtv.auth.repository
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -7,7 +8,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.auth.apiclient.ApiBinder
|
||||
import org.jellyfin.androidtv.auth.model.Server
|
||||
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
|
||||
import org.jellyfin.androidtv.auth.store.AuthenticationStore
|
||||
import org.jellyfin.androidtv.preference.PreferencesRepository
|
||||
@@ -48,7 +49,6 @@ interface SessionRepository {
|
||||
|
||||
class SessionRepositoryImpl(
|
||||
private val authenticationPreferences: AuthenticationPreferences,
|
||||
private val apiBinder: ApiBinder,
|
||||
private val authenticationStore: AuthenticationStore,
|
||||
private val userApiClient: ApiClient,
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
@@ -112,13 +112,15 @@ class SessionRepositoryImpl(
|
||||
override fun destroyCurrentSession() {
|
||||
Timber.i("Destroying current session")
|
||||
|
||||
userRepository.updateCurrentUser(null)
|
||||
userRepository.setCurrentUser(null)
|
||||
serverRepository.setCurrentServer(null)
|
||||
_currentSession.value = null
|
||||
apiBinder.updateSession(null, userApiClient.deviceInfo)
|
||||
_state.value = SessionRepositoryState.READY
|
||||
}
|
||||
|
||||
private suspend fun setCurrentSession(session: Session?): Boolean {
|
||||
var server: Server? = null
|
||||
|
||||
if (session != null) {
|
||||
// No change in session - don't switch
|
||||
if (currentSession.value?.userId == session.userId) return true
|
||||
@@ -128,40 +130,40 @@ class SessionRepositoryImpl(
|
||||
authenticationPreferences[AuthenticationPreferences.lastUserId] = session.userId.toString()
|
||||
|
||||
// Check if server version is supported
|
||||
val server = serverRepository.getServer(session.serverId)
|
||||
server = serverRepository.getServer(session.serverId, true)
|
||||
if (server == null || !server.versionSupported) return false
|
||||
}
|
||||
|
||||
// Update session after binding the apiclient settings
|
||||
val deviceInfo = session?.let { defaultDeviceInfo.forUser(it.userId) } ?: defaultDeviceInfo
|
||||
val success = apiBinder.updateSession(session, deviceInfo)
|
||||
Timber.i("Updating current session. userId=${session?.userId} apiBindingSuccess=${success}")
|
||||
Timber.i("Updating current session. userId=${session?.userId} server=${server?.serverVersion}")
|
||||
|
||||
if (success) {
|
||||
val applied = userApiClient.applySession(session, deviceInfo)
|
||||
|
||||
if (applied && session != null) {
|
||||
try {
|
||||
val user by userApiClient.userApi.getCurrentUser()
|
||||
userRepository.updateCurrentUser(user)
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to authenticate: bad response when getting user info")
|
||||
destroyCurrentSession()
|
||||
return false
|
||||
val applied = userApiClient.applySession(session, deviceInfo)
|
||||
if (applied && session != null) {
|
||||
try {
|
||||
val user = withContext(Dispatchers.IO) {
|
||||
userApiClient.userApi.getCurrentUser().content
|
||||
}
|
||||
|
||||
// Update crash reporting URL
|
||||
val crashReportUrl = userApiClient.clientLogApi.logFileUrl()
|
||||
telemetryPreferences[TelemetryPreferences.crashReportUrl] = crashReportUrl
|
||||
telemetryPreferences[TelemetryPreferences.crashReportToken] = session.accessToken
|
||||
} else {
|
||||
userRepository.updateCurrentUser(null)
|
||||
userRepository.setCurrentUser(user)
|
||||
serverRepository.setCurrentServer(server)
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to authenticate: bad response when getting user info")
|
||||
destroyCurrentSession()
|
||||
return false
|
||||
}
|
||||
preferencesRepository.onSessionChanged()
|
||||
_currentSession.value = session
|
||||
} else destroyCurrentSession()
|
||||
|
||||
return success
|
||||
// Update crash reporting URL
|
||||
val crashReportUrl = userApiClient.clientLogApi.logFileUrl()
|
||||
telemetryPreferences[TelemetryPreferences.crashReportUrl] = crashReportUrl
|
||||
telemetryPreferences[TelemetryPreferences.crashReportToken] = session.accessToken
|
||||
} else {
|
||||
userRepository.setCurrentUser(null)
|
||||
serverRepository.setCurrentServer(null)
|
||||
}
|
||||
preferencesRepository.onSessionChanged()
|
||||
_currentSession.value = session
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun createLastUserSession(): Session? {
|
||||
|
||||
@@ -10,13 +10,13 @@ import org.jellyfin.sdk.model.api.UserDto
|
||||
interface UserRepository {
|
||||
val currentUser: StateFlow<UserDto?>
|
||||
|
||||
fun updateCurrentUser(user: UserDto?)
|
||||
fun setCurrentUser(user: UserDto?)
|
||||
}
|
||||
|
||||
class UserRepositoryImpl : UserRepository {
|
||||
override val currentUser = MutableStateFlow<UserDto?>(null)
|
||||
|
||||
override fun updateCurrentUser(user: UserDto?) {
|
||||
override fun setCurrentUser(user: UserDto?) {
|
||||
currentUser.value = user
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ enum class QueryType {
|
||||
LiveTvProgram,
|
||||
LiveTvRecording,
|
||||
StaticItems,
|
||||
StaticAudioQueueItems,
|
||||
Artists,
|
||||
AlbumArtists,
|
||||
AudioPlaylists,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.jellyfin.androidtv.data.compat
|
||||
|
||||
import org.jellyfin.apiclient.model.dlna.DeviceProfile
|
||||
import org.jellyfin.apiclient.model.dlna.EncodingContext
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import java.util.UUID
|
||||
|
||||
@@ -22,11 +21,4 @@ open class AudioOptions {
|
||||
* Example: DeviceProfile supports five channel, but user only has stereo speakers
|
||||
*/
|
||||
var maxAudioChannels: Int? = null
|
||||
|
||||
/**
|
||||
* The application's configured quality setting
|
||||
*/
|
||||
var maxBitrate: Int? = null
|
||||
|
||||
var context = EncodingContext.Streaming
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.jellyfin.androidtv.data.compat
|
||||
|
||||
import org.jellyfin.apiclient.model.dlna.PlaybackErrorCode
|
||||
import org.jellyfin.sdk.model.api.PlaybackErrorCode
|
||||
|
||||
class PlaybackException : RuntimeException() {
|
||||
var errorCode = PlaybackErrorCode.NotAllowed
|
||||
var errorCode = PlaybackErrorCode.NOT_ALLOWED
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod;
|
||||
import org.jellyfin.apiclient.model.dlna.SubtitleProfile;
|
||||
import org.jellyfin.apiclient.model.entities.MediaStream;
|
||||
import org.jellyfin.apiclient.model.session.PlayMethod;
|
||||
|
||||
public class StreamBuilder
|
||||
{
|
||||
public static SubtitleProfile getSubtitleProfile(org.jellyfin.sdk.model.api.MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod)
|
||||
{
|
||||
if (playMethod != PlayMethod.Transcode && !subtitleStream.isExternal())
|
||||
{
|
||||
// Look for supported embedded subs
|
||||
for (SubtitleProfile profile : subtitleProfiles)
|
||||
{
|
||||
if (!profile.SupportsLanguage(subtitleStream.getLanguage()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (profile.getMethod() != SubtitleDeliveryMethod.Embed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (subtitleStream.isTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat()) && Utils.equalsIgnoreCase(profile.getFormat(), subtitleStream.getCodec()))
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for an external or hls profile that matches the stream type (text/graphical) and doesn't require conversion
|
||||
SubtitleProfile tempVar = new SubtitleProfile();
|
||||
tempVar.setMethod(SubtitleDeliveryMethod.Encode);
|
||||
tempVar.setFormat(subtitleStream.getCodec());
|
||||
SubtitleProfile tempVar2 = getExternalSubtitleProfile(subtitleStream, subtitleProfiles, playMethod, false);
|
||||
SubtitleProfile tempVar3 = getExternalSubtitleProfile(subtitleStream, subtitleProfiles, playMethod, true);
|
||||
return (tempVar2 != null) ? tempVar2 : (tempVar3 != null) ? tempVar3 : tempVar;
|
||||
}
|
||||
|
||||
private static SubtitleProfile getExternalSubtitleProfile(org.jellyfin.sdk.model.api.MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod, boolean allowConversion)
|
||||
{
|
||||
for (SubtitleProfile profile : subtitleProfiles)
|
||||
{
|
||||
if (profile.getMethod() != SubtitleDeliveryMethod.External && profile.getMethod() != SubtitleDeliveryMethod.Hls)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (profile.getMethod() == SubtitleDeliveryMethod.Hls && playMethod != PlayMethod.Transcode)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!profile.SupportsLanguage(subtitleStream.getLanguage()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((profile.getMethod() == SubtitleDeliveryMethod.External && subtitleStream.isTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat())) || (profile.getMethod() == SubtitleDeliveryMethod.Hls && subtitleStream.isTextSubtitleStream()))
|
||||
{
|
||||
boolean requiresConversion = !Utils.equalsIgnoreCase(subtitleStream.getCodec(), profile.getFormat());
|
||||
|
||||
if (!requiresConversion)
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
|
||||
if (!allowConversion)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (subtitleStream.isTextSubtitleStream() && subtitleStream.getSupportsExternalStream() && supportsSubtitleConversionTo(subtitleStream, profile.getFormat()))
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static boolean supportsSubtitleConversionTo(org.jellyfin.sdk.model.api.MediaStream mediaStream, String codec)
|
||||
{
|
||||
if (!mediaStream.isTextSubtitleStream())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Can't convert from this
|
||||
return !("ass".equalsIgnoreCase(mediaStream.getCodec()) || "ssa".equalsIgnoreCase(mediaStream.getCodec()) || "ass".equalsIgnoreCase(codec) || "ssa".equalsIgnoreCase(codec));
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,12 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
import org.jellyfin.apiclient.model.dlna.DeviceProfile;
|
||||
import org.jellyfin.apiclient.model.dlna.EncodingContext;
|
||||
import org.jellyfin.apiclient.model.dlna.SubtitleProfile;
|
||||
import org.jellyfin.apiclient.model.dlna.TranscodeSeekInfo;
|
||||
import org.jellyfin.apiclient.model.session.PlayMethod;
|
||||
import org.jellyfin.sdk.api.client.ApiClient;
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo;
|
||||
import org.jellyfin.sdk.model.api.MediaStream;
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType;
|
||||
import org.jellyfin.sdk.model.api.PlayMethod;
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.UUID;
|
||||
|
||||
public class StreamInfo {
|
||||
@@ -36,7 +30,7 @@ public class StreamInfo {
|
||||
MediaUrl = value;
|
||||
}
|
||||
|
||||
private PlayMethod playMethod = PlayMethod.DirectPlay;
|
||||
private PlayMethod playMethod = PlayMethod.DIRECT_PLAY;
|
||||
|
||||
public final PlayMethod getPlayMethod() {
|
||||
return playMethod;
|
||||
@@ -46,16 +40,6 @@ public class StreamInfo {
|
||||
playMethod = value;
|
||||
}
|
||||
|
||||
private EncodingContext Context = EncodingContext.values()[0];
|
||||
|
||||
public final EncodingContext getContext() {
|
||||
return Context;
|
||||
}
|
||||
|
||||
public final void setContext(EncodingContext value) {
|
||||
Context = value;
|
||||
}
|
||||
|
||||
private String Container;
|
||||
|
||||
public final String getContainer() {
|
||||
@@ -66,16 +50,6 @@ public class StreamInfo {
|
||||
Container = value;
|
||||
}
|
||||
|
||||
private DeviceProfile DeviceProfile;
|
||||
|
||||
public final DeviceProfile getDeviceProfile() {
|
||||
return DeviceProfile;
|
||||
}
|
||||
|
||||
public final void setDeviceProfile(DeviceProfile value) {
|
||||
DeviceProfile = value;
|
||||
}
|
||||
|
||||
private Long RunTimeTicks = null;
|
||||
|
||||
public final Long getRunTimeTicks() {
|
||||
@@ -86,12 +60,6 @@ public class StreamInfo {
|
||||
RunTimeTicks = value;
|
||||
}
|
||||
|
||||
private TranscodeSeekInfo TranscodeSeekInfo = getTranscodeSeekInfo().values()[0];
|
||||
|
||||
public final TranscodeSeekInfo getTranscodeSeekInfo() {
|
||||
return TranscodeSeekInfo;
|
||||
}
|
||||
|
||||
private MediaSourceInfo MediaSource;
|
||||
|
||||
public final MediaSourceInfo getMediaSource() {
|
||||
@@ -122,36 +90,6 @@ public class StreamInfo {
|
||||
return getMediaSource() == null ? null : getMediaSource().getId();
|
||||
}
|
||||
|
||||
public final ArrayList<SubtitleStreamInfo> getSubtitleProfiles(ApiClient api) {
|
||||
ArrayList<SubtitleStreamInfo> list = new ArrayList<SubtitleStreamInfo>();
|
||||
|
||||
if (getMediaSource() == null) return list;
|
||||
|
||||
for (org.jellyfin.sdk.model.api.MediaStream stream : getMediaSource().getMediaStreams()) {
|
||||
if (stream.getType() == org.jellyfin.sdk.model.api.MediaStreamType.SUBTITLE) {
|
||||
SubtitleStreamInfo info = getSubtitleStreamInfo(api, stream, getDeviceProfile().getSubtitleProfiles());
|
||||
list.add(info);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private SubtitleStreamInfo getSubtitleStreamInfo(ApiClient api, org.jellyfin.sdk.model.api.MediaStream stream, SubtitleProfile[] subtitleProfiles) {
|
||||
SubtitleProfile subtitleProfile = StreamBuilder.getSubtitleProfile(stream, subtitleProfiles, getPlayMethod());
|
||||
SubtitleStreamInfo info = new SubtitleStreamInfo();
|
||||
String tempVar2 = stream.getLanguage();
|
||||
info.setName((tempVar2 != null) ? tempVar2 : "Unknown");
|
||||
info.setFormat(subtitleProfile.getFormat());
|
||||
info.setIndex(stream.getIndex());
|
||||
info.setDeliveryMethod(subtitleProfile.getMethod());
|
||||
info.setDisplayTitle(stream.getDisplayTitle());
|
||||
if (stream.getDeliveryUrl() != null) {
|
||||
info.setUrl(api.createUrl(stream.getDeliveryUrl(), new HashMap<>(), new HashMap<>(), true));
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
public final ArrayList<MediaStream> getSelectableAudioStreams() {
|
||||
return getSelectableStreams(MediaStreamType.AUDIO);
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.compat
|
||||
|
||||
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod
|
||||
|
||||
class SubtitleStreamInfo {
|
||||
var url: String? = null
|
||||
var name: String? = null
|
||||
var format: String? = null
|
||||
var displayTitle: String? = null
|
||||
var index = 0
|
||||
var deliveryMethod = SubtitleDeliveryMethod.Encode
|
||||
}
|
||||
@@ -4,10 +4,14 @@ import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.data.model.DataRefreshService
|
||||
@@ -50,105 +54,111 @@ class SocketHandler(
|
||||
private val audioManager: AudioManager,
|
||||
private val itemLauncher: ItemLauncher,
|
||||
private val playbackHelper: PlaybackHelper,
|
||||
private val lifecycle: Lifecycle,
|
||||
) {
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
suspend fun updateSession() {
|
||||
try {
|
||||
api.sessionApi.postCapabilities(
|
||||
playableMediaTypes = listOf(MediaType.VIDEO, MediaType.AUDIO),
|
||||
supportsMediaControl = true,
|
||||
supportedCommands = buildList {
|
||||
add(GeneralCommandType.DISPLAY_CONTENT)
|
||||
add(GeneralCommandType.SET_SUBTITLE_STREAM_INDEX)
|
||||
add(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
|
||||
|
||||
add(GeneralCommandType.DISPLAY_MESSAGE)
|
||||
add(GeneralCommandType.SEND_STRING)
|
||||
|
||||
// Note: These are used in the PlaySessionSocketService
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !audioManager.isVolumeFixed) {
|
||||
add(GeneralCommandType.VOLUME_UP)
|
||||
add(GeneralCommandType.VOLUME_DOWN)
|
||||
add(GeneralCommandType.SET_VOLUME)
|
||||
|
||||
add(GeneralCommandType.MUTE)
|
||||
add(GeneralCommandType.UNMUTE)
|
||||
add(GeneralCommandType.TOGGLE_MUTE)
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to update capabilities")
|
||||
return
|
||||
init {
|
||||
lifecycle.coroutineScope.launch(Dispatchers.IO) {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
subscribe(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
api.webSocket.apply {
|
||||
// Library
|
||||
subscribe<LibraryChangedMessage>()
|
||||
.onEach { message -> message.data?.let(::onLibraryChanged) }
|
||||
.launchIn(coroutineScope)
|
||||
suspend fun updateSession() {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
api.sessionApi.postCapabilities(
|
||||
playableMediaTypes = listOf(MediaType.VIDEO, MediaType.AUDIO),
|
||||
supportsMediaControl = true,
|
||||
supportedCommands = buildList {
|
||||
add(GeneralCommandType.DISPLAY_CONTENT)
|
||||
add(GeneralCommandType.SET_SUBTITLE_STREAM_INDEX)
|
||||
add(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
|
||||
|
||||
// Media playback
|
||||
subscribe<PlayMessage>()
|
||||
.onEach { message -> onPlayMessage(message) }
|
||||
.launchIn(coroutineScope)
|
||||
add(GeneralCommandType.DISPLAY_MESSAGE)
|
||||
add(GeneralCommandType.SEND_STRING)
|
||||
|
||||
subscribe<PlaystateMessage>()
|
||||
.onEach { message -> onPlayStateMessage(message) }
|
||||
.launchIn(coroutineScope)
|
||||
// Note: These are used in the PlaySessionSocketService
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !audioManager.isVolumeFixed) {
|
||||
add(GeneralCommandType.VOLUME_UP)
|
||||
add(GeneralCommandType.VOLUME_DOWN)
|
||||
add(GeneralCommandType.SET_VOLUME)
|
||||
|
||||
subscribeGeneralCommand(GeneralCommandType.SET_SUBTITLE_STREAM_INDEX)
|
||||
.onEach { message ->
|
||||
val index = message["index"]?.toIntOrNull() ?: return@onEach
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
playbackControllerContainer.playbackController?.setSubtitleIndex(index)
|
||||
}
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
subscribeGeneralCommand(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
|
||||
.onEach { message ->
|
||||
val index = message["index"]?.toIntOrNull() ?: return@onEach
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
playbackControllerContainer.playbackController?.switchAudioStream(index)
|
||||
}
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
// General commands
|
||||
subscribeGeneralCommand(GeneralCommandType.DISPLAY_CONTENT)
|
||||
.onEach { message ->
|
||||
val itemId by message
|
||||
val itemType by message
|
||||
|
||||
val itemUuid = itemId?.toUUIDOrNull()
|
||||
val itemKind = itemType?.let { type ->
|
||||
BaseItemKind.entries.find { value ->
|
||||
value.serialName.equals(type, true)
|
||||
add(GeneralCommandType.MUTE)
|
||||
add(GeneralCommandType.UNMUTE)
|
||||
add(GeneralCommandType.TOGGLE_MUTE)
|
||||
}
|
||||
}
|
||||
|
||||
if (itemUuid != null && itemKind != null) onDisplayContent(itemUuid, itemKind)
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
subscribeGeneralCommands(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING))
|
||||
.onEach { message ->
|
||||
val header by message
|
||||
val text by message
|
||||
val string by message
|
||||
|
||||
onDisplayMessage(header, text ?: string)
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
},
|
||||
)
|
||||
}
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to update capabilities")
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribe(coroutineScope: CoroutineScope) = api.webSocket.apply {
|
||||
// Library
|
||||
subscribe<LibraryChangedMessage>()
|
||||
.onEach { message -> message.data?.let(::onLibraryChanged) }
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
// Media playback
|
||||
subscribe<PlayMessage>()
|
||||
.onEach { message -> onPlayMessage(message) }
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
subscribe<PlaystateMessage>()
|
||||
.onEach { message -> onPlayStateMessage(message) }
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
subscribeGeneralCommand(GeneralCommandType.SET_SUBTITLE_STREAM_INDEX)
|
||||
.onEach { message ->
|
||||
val index = message["index"]?.toIntOrNull() ?: return@onEach
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
playbackControllerContainer.playbackController?.setSubtitleIndex(index)
|
||||
}
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
subscribeGeneralCommand(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
|
||||
.onEach { message ->
|
||||
val index = message["index"]?.toIntOrNull() ?: return@onEach
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
playbackControllerContainer.playbackController?.switchAudioStream(index)
|
||||
}
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
// General commands
|
||||
subscribeGeneralCommand(GeneralCommandType.DISPLAY_CONTENT)
|
||||
.onEach { message ->
|
||||
val itemId by message
|
||||
val itemType by message
|
||||
|
||||
val itemUuid = itemId?.toUUIDOrNull()
|
||||
val itemKind = itemType?.let { type ->
|
||||
BaseItemKind.entries.find { value ->
|
||||
value.serialName.equals(type, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (itemUuid != null && itemKind != null) onDisplayContent(itemUuid, itemKind)
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
subscribeGeneralCommands(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING))
|
||||
.onEach { message ->
|
||||
val header by message
|
||||
val text by message
|
||||
val string by message
|
||||
|
||||
onDisplayMessage(header, text ?: string)
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
private fun onLibraryChanged(info: LibraryUpdateInfo) {
|
||||
Timber.d(buildString {
|
||||
appendLine("Library changed.")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.jellyfin.androidtv.data.repository
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.data.model.DataRefreshService
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||
@@ -19,8 +21,8 @@ class ItemMutationRepositoryImpl(
|
||||
) : ItemMutationRepository {
|
||||
override suspend fun setFavorite(item: UUID, favorite: Boolean): UserItemDataDto {
|
||||
val response by when {
|
||||
favorite -> api.userLibraryApi.markFavoriteItem(itemId = item)
|
||||
else -> api.userLibraryApi.unmarkFavoriteItem(itemId = item)
|
||||
favorite -> withContext(Dispatchers.IO) { api.userLibraryApi.markFavoriteItem(itemId = item) }
|
||||
else -> withContext(Dispatchers.IO) { api.userLibraryApi.unmarkFavoriteItem(itemId = item) }
|
||||
}
|
||||
|
||||
dataRefreshService.lastFavoriteUpdate = Instant.now()
|
||||
@@ -29,8 +31,8 @@ class ItemMutationRepositoryImpl(
|
||||
|
||||
override suspend fun setPlayed(item: UUID, played: Boolean): UserItemDataDto {
|
||||
val response by when {
|
||||
played -> api.playStateApi.markPlayedItem(itemId = item)
|
||||
else -> api.playStateApi.markUnplayedItem(itemId = item)
|
||||
played -> withContext(Dispatchers.IO) { api.playStateApi.markPlayedItem(itemId = item) }
|
||||
else -> withContext(Dispatchers.IO) { api.playStateApi.markUnplayedItem(itemId = item) }
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.jellyfin.androidtv.data.repository
|
||||
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
|
||||
object ItemRepository {
|
||||
val itemFields = setOf(
|
||||
ItemFields.CAN_DELETE,
|
||||
ItemFields.CHANNEL_INFO,
|
||||
ItemFields.CHAPTERS,
|
||||
ItemFields.CHILD_COUNT,
|
||||
ItemFields.CUMULATIVE_RUN_TIME_TICKS,
|
||||
ItemFields.DATE_CREATED,
|
||||
ItemFields.DISPLAY_PREFERENCES_ID,
|
||||
ItemFields.GENRES,
|
||||
ItemFields.ITEM_COUNTS,
|
||||
ItemFields.MEDIA_SOURCE_COUNT,
|
||||
ItemFields.MEDIA_SOURCES,
|
||||
ItemFields.MEDIA_STREAMS,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.PATH,
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.TAGLINES,
|
||||
ItemFields.TRICKPLAY,
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package org.jellyfin.androidtv.data.repository
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
@@ -23,7 +25,7 @@ class UserViewsRepositoryImpl(
|
||||
val filteredViews = views.items
|
||||
.filter { isSupported(it.collectionType) }
|
||||
emit(filteredViews)
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
override fun isSupported(collectionType: CollectionType?) = collectionType !in unsupportedCollectionTypes
|
||||
override fun allowViewSelection(collectionType: CollectionType?) = collectionType !in disallowViewSelectionCollectionTypes
|
||||
|
||||
@@ -15,13 +15,14 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.auth.model.Server
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.util.apiclient.getUrl
|
||||
import org.jellyfin.androidtv.util.apiclient.itemBackdropImages
|
||||
import org.jellyfin.androidtv.util.apiclient.parentBackdropImages
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -54,23 +55,6 @@ class BackgroundService(
|
||||
val blurBackground get() = _blurBackground.asStateFlow()
|
||||
val enabled get() = _enabled.asStateFlow()
|
||||
|
||||
// Helper function for [setBackground]
|
||||
private fun List<String>?.getUrls(itemId: UUID?): List<String> {
|
||||
// Check for nullability
|
||||
if (itemId == null || isNullOrEmpty()) return emptyList()
|
||||
|
||||
return mapIndexed { index, tag ->
|
||||
api.imageApi.getItemImageUrl(
|
||||
itemId = itemId,
|
||||
imageType = ImageType.BACKDROP,
|
||||
tag = tag,
|
||||
imageIndex = index,
|
||||
fillWidth = context.resources.displayMetrics.widthPixels,
|
||||
fillHeight = context.resources.displayMetrics.heightPixels,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use all available backdrops from [baseItem] as background.
|
||||
*/
|
||||
@@ -83,9 +67,9 @@ class BackgroundService(
|
||||
_blurBackground.value = true
|
||||
|
||||
// Get all backdrop urls
|
||||
val itemBackdropUrls = baseItem.backdropImageTags.getUrls(baseItem.id)
|
||||
val parentBackdropUrls = baseItem.parentBackdropImageTags.getUrls(baseItem.parentBackdropItemId)
|
||||
val backdropUrls = itemBackdropUrls.union(parentBackdropUrls)
|
||||
val backdropUrls = (baseItem.itemBackdropImages + baseItem.parentBackdropImages)
|
||||
.map { it.getUrl(api) }
|
||||
.toSet()
|
||||
|
||||
loadBackgrounds(backdropUrls)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,16 @@ package org.jellyfin.androidtv.di
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import coil3.ImageLoader
|
||||
import coil3.annotation.ExperimentalCoilApi
|
||||
import coil3.gif.AnimatedImageDecoder
|
||||
import coil3.gif.GifDecoder
|
||||
import coil3.network.NetworkFetcher
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.serviceLoaderEnabled
|
||||
import coil3.svg.SvgDecoder
|
||||
import coil3.util.Logger
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.auth.repository.ServerRepository
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
@@ -24,16 +28,17 @@ import org.jellyfin.androidtv.data.repository.UserViewsRepository
|
||||
import org.jellyfin.androidtv.data.repository.UserViewsRepositoryImpl
|
||||
import org.jellyfin.androidtv.data.service.BackgroundService
|
||||
import org.jellyfin.androidtv.integration.dream.DreamViewModel
|
||||
import org.jellyfin.androidtv.ui.ScreensaverViewModel
|
||||
import org.jellyfin.androidtv.ui.InteractionTrackerViewModel
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
|
||||
import org.jellyfin.androidtv.ui.navigation.Destinations
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepositoryImpl
|
||||
import org.jellyfin.androidtv.ui.picture.PictureViewerViewModel
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
|
||||
import org.jellyfin.androidtv.ui.playback.nextup.NextUpViewModel
|
||||
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepository
|
||||
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepositoryImpl
|
||||
import org.jellyfin.androidtv.ui.playback.stillwatching.StillWatchingViewModel
|
||||
import org.jellyfin.androidtv.ui.player.photo.PhotoPlayerViewModel
|
||||
import org.jellyfin.androidtv.ui.search.SearchFragmentDelegate
|
||||
import org.jellyfin.androidtv.ui.search.SearchRepository
|
||||
import org.jellyfin.androidtv.ui.search.SearchRepositoryImpl
|
||||
@@ -45,69 +50,75 @@ import org.jellyfin.androidtv.util.KeyProcessor
|
||||
import org.jellyfin.androidtv.util.MarkdownRenderer
|
||||
import org.jellyfin.androidtv.util.PlaybackHelper
|
||||
import org.jellyfin.androidtv.util.apiclient.ReportingHelper
|
||||
import org.jellyfin.androidtv.util.coil.CoilTimberLogger
|
||||
import org.jellyfin.androidtv.util.coil.createCoilConnectivityChecker
|
||||
import org.jellyfin.androidtv.util.sdk.SdkPlaybackHelper
|
||||
import org.jellyfin.androidtv.util.sdk.legacy
|
||||
import org.jellyfin.apiclient.AppInfo
|
||||
import org.jellyfin.apiclient.android
|
||||
import org.jellyfin.apiclient.logging.AndroidLogger
|
||||
import org.jellyfin.sdk.android.androidDevice
|
||||
import org.jellyfin.sdk.api.client.HttpClientOptions
|
||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||
import org.jellyfin.sdk.createJellyfin
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import org.koin.android.ext.koin.androidApplication
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
import org.jellyfin.apiclient.Jellyfin as JellyfinApiClient
|
||||
import org.jellyfin.sdk.Jellyfin as JellyfinSdk
|
||||
|
||||
val defaultDeviceInfo = named("defaultDeviceInfo")
|
||||
|
||||
val appModule = module {
|
||||
// New SDK
|
||||
// SDK
|
||||
single(defaultDeviceInfo) { androidDevice(get()) }
|
||||
single { OkHttpFactory() }
|
||||
single { HttpClientOptions() }
|
||||
single {
|
||||
createJellyfin {
|
||||
context = androidContext()
|
||||
|
||||
// Add client info
|
||||
clientInfo = ClientInfo("Android TV", BuildConfig.VERSION_NAME)
|
||||
val clientName = buildString {
|
||||
append("Jellyfin Android TV")
|
||||
if (BuildConfig.DEBUG) append(" (debug)")
|
||||
}
|
||||
clientInfo = ClientInfo(clientName, BuildConfig.VERSION_NAME)
|
||||
deviceInfo = get(defaultDeviceInfo)
|
||||
|
||||
// Change server version
|
||||
minimumServerVersion = ServerRepository.minimumServerVersion
|
||||
|
||||
// Use our own shared factory instance
|
||||
apiClientFactory = get<OkHttpFactory>()
|
||||
socketConnectionFactory = get<OkHttpFactory>()
|
||||
}
|
||||
}
|
||||
|
||||
single {
|
||||
// Create an empty API instance, the actual values are set by the SessionRepository
|
||||
get<JellyfinSdk>().createApi()
|
||||
get<JellyfinSdk>().createApi(httpClientOptions = get<HttpClientOptions>())
|
||||
}
|
||||
|
||||
single { SocketHandler(get(), get(), get(), get(), get(), get(), get(), get(), get()) }
|
||||
|
||||
// Old apiclient
|
||||
single {
|
||||
JellyfinApiClient {
|
||||
appInfo = AppInfo("Android TV", BuildConfig.VERSION_NAME)
|
||||
logger = AndroidLogger()
|
||||
android(androidApplication())
|
||||
}
|
||||
}
|
||||
|
||||
single {
|
||||
get<JellyfinApiClient>().createApi(
|
||||
device = get<DeviceInfo>(defaultDeviceInfo).legacy()
|
||||
)
|
||||
}
|
||||
single { SocketHandler(get(), get(), get(), get(), get(), get(), get(), get(), get(), ProcessLifecycleOwner.get().lifecycle) }
|
||||
|
||||
// Coil (images)
|
||||
single {
|
||||
val okHttpFactory = get<OkHttpFactory>()
|
||||
val httpClientOptions = get<HttpClientOptions>()
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
OkHttpNetworkFetcherFactory(
|
||||
callFactory = { okHttpFactory.createClient(httpClientOptions) },
|
||||
connectivityChecker = ::createCoilConnectivityChecker,
|
||||
)
|
||||
}
|
||||
|
||||
single {
|
||||
ImageLoader.Builder(androidContext()).apply {
|
||||
serviceLoaderEnabled(false)
|
||||
logger(CoilTimberLogger(if (BuildConfig.DEBUG) Logger.Level.Warn else Logger.Level.Error))
|
||||
|
||||
components {
|
||||
add(OkHttpNetworkFetcherFactory())
|
||||
add(get<NetworkFetcher.Factory>())
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(AnimatedImageDecoder.Factory())
|
||||
else add(GifDecoder.Factory())
|
||||
add(SvgDecoder.Factory())
|
||||
@@ -118,6 +129,7 @@ val appModule = module {
|
||||
// Non API related
|
||||
single { DataRefreshService() }
|
||||
single { PlaybackControllerContainer() }
|
||||
single { InteractionTrackerViewModel(get(), get()) }
|
||||
|
||||
single<UserRepository> { UserRepositoryImpl() }
|
||||
single<UserViewsRepository> { UserViewsRepositoryImpl(get()) }
|
||||
@@ -131,9 +143,9 @@ val appModule = module {
|
||||
viewModel { StartupViewModel(get(), get(), get(), get()) }
|
||||
viewModel { UserLoginViewModel(get(), get(), get(), get(defaultDeviceInfo)) }
|
||||
viewModel { ServerAddViewModel(get()) }
|
||||
viewModel { NextUpViewModel(get(), get(), get(), get()) }
|
||||
viewModel { PictureViewerViewModel(get()) }
|
||||
viewModel { ScreensaverViewModel(get()) }
|
||||
viewModel { NextUpViewModel(get(), get(), get()) }
|
||||
viewModel { StillWatchingViewModel(get(), get(), get()) }
|
||||
viewModel { PhotoPlayerViewModel(get()) }
|
||||
viewModel { SearchViewModel(get()) }
|
||||
viewModel { DreamViewModel(get(), get(), get(), get(), get()) }
|
||||
|
||||
@@ -143,7 +155,7 @@ val appModule = module {
|
||||
single { ItemLauncher() }
|
||||
single { KeyProcessor() }
|
||||
single { ReportingHelper(get(), get()) }
|
||||
single<PlaybackHelper> { SdkPlaybackHelper(get(), get(), get(), get(), get(), get(), get()) }
|
||||
single<PlaybackHelper> { SdkPlaybackHelper(get(), get(), get(), get()) }
|
||||
|
||||
factory { (context: Context) -> SearchFragmentDelegate(context, get(), get()) }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.jellyfin.androidtv.di
|
||||
|
||||
import org.jellyfin.androidtv.auth.AccountManagerMigration
|
||||
import org.jellyfin.androidtv.auth.apiclient.ApiBinder
|
||||
import org.jellyfin.androidtv.auth.repository.AuthenticationRepository
|
||||
import org.jellyfin.androidtv.auth.repository.AuthenticationRepositoryImpl
|
||||
import org.jellyfin.androidtv.auth.repository.ServerRepository
|
||||
@@ -25,8 +24,11 @@ val authModule = module {
|
||||
single<ServerRepository> { ServerRepositoryImpl(get(), get()) }
|
||||
single<ServerUserRepository> { ServerUserRepositoryImpl(get(), get()) }
|
||||
single<SessionRepository> {
|
||||
SessionRepositoryImpl(get(), get(), get(), get(), get(), get(defaultDeviceInfo), get(), get(), get())
|
||||
SessionRepositoryImpl(get(), get(), get(), get(), get(defaultDeviceInfo), get(), get(), get())
|
||||
}
|
||||
|
||||
single { ApiBinder(get(), get()) }
|
||||
factory {
|
||||
val serverRepository = get<ServerRepository>()
|
||||
serverRepository.currentServer.value?.serverVersion ?: ServerRepository.minimumServerVersion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,23 +6,26 @@ import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.media3.datasource.HttpDataSource
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.preference.UserSettingPreferences
|
||||
import org.jellyfin.androidtv.ui.browsing.MainActivity
|
||||
import org.jellyfin.androidtv.ui.playback.GarbagePlaybackLauncher
|
||||
import org.jellyfin.androidtv.ui.playback.MediaManager
|
||||
import org.jellyfin.androidtv.ui.playback.RewritePlaybackLauncher
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackLauncher
|
||||
import org.jellyfin.androidtv.ui.playback.VideoQueueManager
|
||||
import org.jellyfin.androidtv.ui.playback.rewrite.RewriteMediaManager
|
||||
import org.jellyfin.androidtv.util.profile.createDeviceProfile
|
||||
import org.jellyfin.playback.core.playbackManager
|
||||
import org.jellyfin.playback.jellyfin.jellyfinPlugin
|
||||
import org.jellyfin.playback.media3.exoplayer.ExoPlayerOptions
|
||||
import org.jellyfin.playback.media3.exoplayer.exoPlayerPlugin
|
||||
import org.jellyfin.playback.media3.session.MediaSessionOptions
|
||||
import org.jellyfin.playback.media3.session.media3SessionPlugin
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.HttpClientOptions
|
||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.scope.Scope
|
||||
import org.koin.dsl.module
|
||||
@@ -32,14 +35,16 @@ import org.jellyfin.androidtv.ui.playback.PlaybackManager as LegacyPlaybackManag
|
||||
val playbackModule = module {
|
||||
single { LegacyPlaybackManager(get()) }
|
||||
single { VideoQueueManager() }
|
||||
single<MediaManager> { RewriteMediaManager(get(), get(), get(), get()) }
|
||||
single<MediaManager> { RewriteMediaManager(get(), get()) }
|
||||
|
||||
factory {
|
||||
val preferences = get<UserPreferences>()
|
||||
val useRewrite = preferences[UserPreferences.playbackRewriteVideoEnabled] && BuildConfig.DEVELOPMENT
|
||||
single { PlaybackLauncher(get(), get(), get(), get()) }
|
||||
|
||||
if (useRewrite) RewritePlaybackLauncher()
|
||||
else GarbagePlaybackLauncher(get())
|
||||
// OkHttp data source using OkHttpFactory from SDK
|
||||
single<HttpDataSource.Factory> {
|
||||
val okHttpFactory = get<OkHttpFactory>()
|
||||
val httpClientOptions = get<HttpClientOptions>()
|
||||
|
||||
OkHttpDataSource.Factory(okHttpFactory.createClient(httpClientOptions))
|
||||
}
|
||||
|
||||
single { createPlaybackManager() }
|
||||
@@ -56,16 +61,16 @@ fun Scope.createPlaybackManager() = playbackManager(androidContext()) {
|
||||
notificationChannelId,
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
channel.setShowBadge(false)
|
||||
NotificationManagerCompat.from(get()).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
val userPreferences = get<UserPreferences>()
|
||||
val api = get<ApiClient>()
|
||||
val exoPlayerOptions = ExoPlayerOptions(
|
||||
httpConnectTimeout = api.httpClientOptions.connectTimeout,
|
||||
httpReadTimeout = api.httpClientOptions.requestTimeout,
|
||||
preferFfmpeg = userPreferences[UserPreferences.preferExoPlayerFfmpeg],
|
||||
enableLibass = userPreferences[UserPreferences.assDirectPlay],
|
||||
enableDebugLogging = userPreferences[UserPreferences.debuggingEnabled],
|
||||
baseDataSourceFactory = get<HttpDataSource.Factory>(),
|
||||
)
|
||||
install(exoPlayerPlugin(get(), exoPlayerOptions))
|
||||
|
||||
@@ -77,7 +82,8 @@ fun Scope.createPlaybackManager() = playbackManager(androidContext()) {
|
||||
)
|
||||
install(media3SessionPlugin(get(), mediaSessionOptions))
|
||||
|
||||
install(jellyfinPlugin(get()))
|
||||
val deviceProfileBuilder = { createDeviceProfile(androidContext(), userPreferences, get()) }
|
||||
install(jellyfinPlugin(get(), deviceProfileBuilder, ProcessLifecycleOwner.get().lifecycle))
|
||||
|
||||
// Options
|
||||
val userSettingPreferences = get<UserSettingPreferences>()
|
||||
|
||||
@@ -7,8 +7,10 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.net.toUri
|
||||
import androidx.tvprovider.media.tv.Channel
|
||||
import androidx.tvprovider.media.tv.ChannelLogoUtils
|
||||
import androidx.tvprovider.media.tv.PreviewProgram
|
||||
@@ -21,38 +23,38 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.data.repository.ItemRepository
|
||||
import org.jellyfin.androidtv.data.repository.UserViewsRepository
|
||||
import org.jellyfin.androidtv.integration.provider.ImageProvider
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.ui.startup.StartupActivity
|
||||
import org.jellyfin.androidtv.util.ImageHelper
|
||||
import org.jellyfin.androidtv.util.apiclient.getUrl
|
||||
import org.jellyfin.androidtv.util.apiclient.itemImages
|
||||
import org.jellyfin.androidtv.util.apiclient.parentImages
|
||||
import org.jellyfin.androidtv.util.dp
|
||||
import org.jellyfin.androidtv.util.sdk.isUsable
|
||||
import org.jellyfin.androidtv.util.stripHtml
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.exception.TimeoutException
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageFormat
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import timber.log.Timber
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.time.Duration
|
||||
|
||||
|
||||
/**
|
||||
* Manages channels on the android tv home screen.
|
||||
*
|
||||
@@ -100,39 +102,39 @@ class LeanbackChannelWorker(
|
||||
// Get channel URIs
|
||||
val latestMediaChannel = getChannelUri(
|
||||
"latest_media", Channel.Builder()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.home_section_latest_media))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build(),
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.home_section_latest_media))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build(),
|
||||
default = true
|
||||
)
|
||||
val myMediaChannel = getChannelUri(
|
||||
"my_media", Channel.Builder()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.lbl_my_media))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.lbl_my_media))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build()
|
||||
)
|
||||
val nextUpChannel = getChannelUri(
|
||||
"next_up", Channel.Builder()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.lbl_next_up))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.lbl_next_up))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build()
|
||||
)
|
||||
val latestMoviesChannel = getChannelUri(
|
||||
"latest_movies", Channel.Builder()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.lbl_movies))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.lbl_movies))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build()
|
||||
)
|
||||
val latestEpisodesChannel = getChannelUri(
|
||||
"latest_episodes", Channel.Builder()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.lbl_new_episodes))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.lbl_new_episodes))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build()
|
||||
)
|
||||
val preferParentThumb = userPreferences[UserPreferences.seriesThumbnailsEnabled]
|
||||
|
||||
@@ -144,18 +146,21 @@ class LeanbackChannelWorker(
|
||||
latestEpisodes to latestEpisodesChannel,
|
||||
myMedia to myMediaChannel,
|
||||
).forEach { (items, channel) ->
|
||||
Timber.d("Updating channel %s", channel)
|
||||
items.map { item ->
|
||||
createPreviewProgram(
|
||||
channel,
|
||||
item,
|
||||
preferParentThumb
|
||||
)
|
||||
}.let {
|
||||
context.contentResolver.bulkInsert(
|
||||
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
||||
it.toTypedArray()
|
||||
)
|
||||
if (channel == null) {
|
||||
Timber.e("Skipping channel because it was not available")
|
||||
} else {
|
||||
items.map { item ->
|
||||
createPreviewProgram(
|
||||
channel,
|
||||
item,
|
||||
preferParentThumb
|
||||
)
|
||||
}.let {
|
||||
context.contentResolver.bulkInsert(
|
||||
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
||||
it.toTypedArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
updateWatchNext(resumeItems + nextUpItems)
|
||||
@@ -178,35 +183,46 @@ class LeanbackChannelWorker(
|
||||
* update or create the channel. The [name] parameter is used to store the id and should be
|
||||
* unique.
|
||||
*/
|
||||
private fun getChannelUri(name: String, settings: Channel, default: Boolean = false): Uri {
|
||||
private fun getChannelUri(name: String, settings: Channel, default: Boolean = false): Uri? {
|
||||
val store = context.getSharedPreferences("leanback_channels", Context.MODE_PRIVATE)
|
||||
var uri: Uri? = null
|
||||
|
||||
val uri = if (store.contains(name)) {
|
||||
// Retrieve uri and update content resolver
|
||||
Uri.parse(store.getString(name, null)).also { uri ->
|
||||
context.contentResolver.update(uri, settings.toContentValues(), null, null)
|
||||
}
|
||||
} else {
|
||||
// Create new channel and save uri
|
||||
context.contentResolver.insert(
|
||||
TvContractCompat.Channels.CONTENT_URI,
|
||||
settings.toContentValues()
|
||||
)!!.also { uri ->
|
||||
store.edit().putString(name, uri.toString()).apply()
|
||||
if (default) {
|
||||
// Set as default row to display (we can request one row to automatically be added to the home screen)
|
||||
TvContractCompat.requestChannelBrowsable(context, ContentUris.parseId(uri))
|
||||
}
|
||||
// Try and re-use our existing channel definition
|
||||
if (store.contains(name)) {
|
||||
uri = store.getString(name, null)?.toUri()
|
||||
|
||||
if (uri != null) {
|
||||
val result = context.contentResolver.update(uri, settings.toContentValues(), null, null)
|
||||
// If we did not affect exactly 1 row there might be something wrong, so recreate it
|
||||
if (result != 1) uri = null
|
||||
}
|
||||
}
|
||||
|
||||
// Update logo
|
||||
ResourcesCompat.getDrawable(context.resources, R.mipmap.app_icon, context.theme)?.let {
|
||||
ChannelLogoUtils.storeChannelLogo(
|
||||
context,
|
||||
ContentUris.parseId(uri),
|
||||
it.toBitmap(80.dp(context), 80.dp(context))
|
||||
if (uri == null) {
|
||||
// Create new channel
|
||||
uri = context.contentResolver.insert(
|
||||
TvContractCompat.Channels.CONTENT_URI,
|
||||
settings.toContentValues()
|
||||
)
|
||||
|
||||
// Set as default row to display (we can request one row to automatically be added to the home screen)
|
||||
if (uri != null && default) {
|
||||
TvContractCompat.requestChannelBrowsable(context, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
// Save uri to shared preferences
|
||||
store.edit { putString(name, uri?.toString()) }
|
||||
}
|
||||
|
||||
// Update logo
|
||||
if (uri != null) {
|
||||
ResourcesCompat.getDrawable(context.resources, R.mipmap.app_icon, context.theme)?.let {
|
||||
ChannelLogoUtils.storeChannelLogo(
|
||||
context,
|
||||
ContentUris.parseId(uri),
|
||||
it.toBitmap(80.dp(context), 80.dp(context))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return uri
|
||||
@@ -231,35 +247,12 @@ class LeanbackChannelWorker(
|
||||
private fun BaseItemDto.getPosterArtImageUrl(
|
||||
preferParentThumb: Boolean
|
||||
): Uri = when {
|
||||
type == BaseItemKind.MOVIE || type == BaseItemKind.SERIES -> api.imageApi.getItemImageUrl(
|
||||
itemId = id,
|
||||
imageType = ImageType.PRIMARY,
|
||||
format = ImageFormat.WEBP,
|
||||
width = 106.dp(context),
|
||||
height = 153.dp(context),
|
||||
tag = imageTags?.get(ImageType.PRIMARY),
|
||||
)
|
||||
|
||||
(preferParentThumb || imageTags?.contains(ImageType.PRIMARY) != true) && parentThumbItemId != null -> api.imageApi.getItemImageUrl(
|
||||
itemId = parentThumbItemId!!,
|
||||
imageType = ImageType.THUMB,
|
||||
format = ImageFormat.WEBP,
|
||||
width = 272.dp(context),
|
||||
height = 153.dp(context),
|
||||
tag = imageTags?.get(ImageType.THUMB),
|
||||
)
|
||||
|
||||
imageTags?.containsKey(ImageType.PRIMARY) == true -> api.imageApi.getItemImageUrl(
|
||||
itemId = id,
|
||||
imageType = ImageType.PRIMARY,
|
||||
format = ImageFormat.WEBP,
|
||||
width = 272.dp(context),
|
||||
height = 153.dp(context),
|
||||
tag = imageTags?.get(ImageType.PRIMARY),
|
||||
)
|
||||
|
||||
else -> imageHelper.getResourceUrl(context, R.drawable.tile_land_tv)
|
||||
}.let(ImageProvider::getImageUri)
|
||||
type == BaseItemKind.MOVIE || type == BaseItemKind.SERIES -> itemImages[ImageType.PRIMARY]
|
||||
(preferParentThumb || !itemImages.contains(ImageType.PRIMARY)) && parentImages.contains(ImageType.THUMB) -> parentImages[ImageType.THUMB]
|
||||
else -> itemImages[ImageType.PRIMARY]
|
||||
}.let { image ->
|
||||
ImageProvider.getImageUri(image?.getUrl(api) ?: imageHelper.getResourceUrl(context, R.drawable.tile_land_tv))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the resume and next up episodes. The returned pair contains two lists:
|
||||
@@ -270,7 +263,7 @@ class LeanbackChannelWorker(
|
||||
withContext(Dispatchers.IO) {
|
||||
val resume = async {
|
||||
api.itemsApi.getResumeItems(
|
||||
fields = listOf(ItemFields.DATE_CREATED),
|
||||
fields = ItemRepository.itemFields,
|
||||
imageTypeLimit = 1,
|
||||
limit = 10,
|
||||
mediaTypes = listOf(MediaType.VIDEO),
|
||||
@@ -284,7 +277,7 @@ class LeanbackChannelWorker(
|
||||
imageTypeLimit = 1,
|
||||
limit = 10,
|
||||
enableResumable = false,
|
||||
fields = listOf(ItemFields.DATE_CREATED),
|
||||
fields = ItemRepository.itemFields,
|
||||
).content.items
|
||||
}
|
||||
|
||||
@@ -296,9 +289,7 @@ class LeanbackChannelWorker(
|
||||
withContext(Dispatchers.IO) {
|
||||
val latestEpisodes = async {
|
||||
api.userLibraryApi.getLatestMedia(
|
||||
fields = listOf(
|
||||
ItemFields.OVERVIEW,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
limit = 50,
|
||||
includeItemTypes = listOf(BaseItemKind.EPISODE),
|
||||
isPlayed = false
|
||||
@@ -307,9 +298,7 @@ class LeanbackChannelWorker(
|
||||
|
||||
val latestMovies = async {
|
||||
api.userLibraryApi.getLatestMedia(
|
||||
fields = listOf(
|
||||
ItemFields.OVERVIEW,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
limit = 50,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
||||
isPlayed = false
|
||||
@@ -318,9 +307,7 @@ class LeanbackChannelWorker(
|
||||
|
||||
val latestMedia = async {
|
||||
api.userLibraryApi.getLatestMedia(
|
||||
fields = listOf(
|
||||
ItemFields.OVERVIEW,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
limit = 50,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
isPlayed = false
|
||||
@@ -361,23 +348,11 @@ class LeanbackChannelWorker(
|
||||
)
|
||||
.setTitle(item.seriesName ?: item.name)
|
||||
.setEpisodeTitle(if (item.type == BaseItemKind.EPISODE) item.name else null)
|
||||
.setSeasonNumber(seasonString, item.parentIndexNumber ?: 0)
|
||||
.setEpisodeNumber(episodeString, item.indexNumber ?: 0)
|
||||
.setDescription(item.overview?.stripHtml())
|
||||
.setReleaseDate(
|
||||
if (item.premiereDate != null) DateTimeFormatter.ISO_DATE.format(item.premiereDate)
|
||||
else null
|
||||
)
|
||||
.setDurationMillis(
|
||||
if (item.runTimeTicks?.ticks != null) {
|
||||
// If we are resuming, we need to show remaining time, cause GoogleTV
|
||||
// ignores setLastPlaybackPositionMillis
|
||||
val duration = item.runTimeTicks?.ticks ?: Duration.ZERO
|
||||
val playbackPosition = item.userData?.playbackPositionTicks?.ticks
|
||||
?: Duration.ZERO
|
||||
(duration - playbackPosition).inWholeMilliseconds.toInt()
|
||||
} else 0
|
||||
)
|
||||
.setPosterArtUri(imageUri)
|
||||
.setPosterArtAspectRatio(
|
||||
when (item.type) {
|
||||
@@ -391,8 +366,22 @@ class LeanbackChannelWorker(
|
||||
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
|
||||
putExtra(StartupActivity.EXTRA_ITEM_IS_USER_VIEW, item.type == BaseItemKind.COLLECTION_FOLDER)
|
||||
})
|
||||
.build()
|
||||
.toContentValues()
|
||||
.setDurationMillis(
|
||||
if (item.runTimeTicks?.ticks != null) {
|
||||
// If we are resuming, we need to show remaining time, cause GoogleTV
|
||||
// ignores setLastPlaybackPositionMillis
|
||||
val duration = item.runTimeTicks?.ticks ?: Duration.ZERO
|
||||
val playbackPosition = item.userData?.playbackPositionTicks?.ticks
|
||||
?: Duration.ZERO
|
||||
(duration - playbackPosition).inWholeMilliseconds.toInt()
|
||||
} else 0
|
||||
)
|
||||
.apply {
|
||||
if ((item.parentIndexNumber ?: 0) > 0)
|
||||
setSeasonNumber(seasonString, item.parentIndexNumber!!)
|
||||
if ((item.indexNumber ?: 0) > 0)
|
||||
setEpisodeNumber(episodeString, item.indexNumber!!)
|
||||
}.build().toContentValues()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -400,16 +389,57 @@ class LeanbackChannelWorker(
|
||||
* or other types of media. Uses the [nextUpItems] parameter to store items returned by a
|
||||
* NextUpQuery().
|
||||
*/
|
||||
@SuppressLint("RestrictedApi")
|
||||
private fun updateWatchNext(nextUpItems: List<BaseItemDto>) {
|
||||
// Delete current items
|
||||
context.contentResolver.delete(WatchNextPrograms.CONTENT_URI, null, null)
|
||||
deletePrograms(nextUpItems)
|
||||
|
||||
// Add new items
|
||||
// Get current watch next state
|
||||
val currentWatchNextPrograms = getCurrentWatchNext()
|
||||
|
||||
// Create all programs in nextUpItems but not in watch next
|
||||
val programsToAdd = nextUpItems
|
||||
.filter { next -> currentWatchNextPrograms.none { it.internalProviderId == next.id.toString() } }
|
||||
context.contentResolver.bulkInsert(
|
||||
WatchNextPrograms.CONTENT_URI,
|
||||
nextUpItems.map { item -> getBaseItemAsWatchNextProgram(item).toContentValues() }
|
||||
.toTypedArray()
|
||||
)
|
||||
programsToAdd.map { item -> getBaseItemAsWatchNextProgram(item).toContentValues() }
|
||||
.toTypedArray())
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete stale programs from the watch next row. Items that don't need to be touched are
|
||||
* kept as is, so they keep their ordering in the watch next row.
|
||||
*/
|
||||
@SuppressLint("RestrictedApi")
|
||||
private fun deletePrograms(nextUpItems: List<BaseItemDto>) {
|
||||
// Retrieve current watch next row
|
||||
val currentWatchNextPrograms = getCurrentWatchNext()
|
||||
|
||||
// Find all stale programs to delete
|
||||
val deletedByUser = currentWatchNextPrograms.filter { !it.isBrowsable }
|
||||
val noLongerInWatchNext =
|
||||
currentWatchNextPrograms.filter { (nextUpItems).none { next -> it.internalProviderId == next.id.toString() } }
|
||||
val continueWatching = currentWatchNextPrograms.filter { it.watchNextType == WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE }
|
||||
|
||||
// Delete the programs
|
||||
(deletedByUser + noLongerInWatchNext + continueWatching)
|
||||
.forEach { context.contentResolver.delete(TvContractCompat.buildWatchNextProgramUri(it.id), null, null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current watch next row state.
|
||||
*/
|
||||
@SuppressLint("RestrictedApi")
|
||||
private fun getCurrentWatchNext(): MutableList<WatchNextProgram> {
|
||||
val currentWatchNextPrograms: MutableList<WatchNextProgram> = mutableListOf()
|
||||
context.contentResolver.query(WatchNextPrograms.CONTENT_URI, WatchNextProgram.PROJECTION, null, null, null)
|
||||
.use { cursor ->
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
do {
|
||||
currentWatchNextPrograms.add(WatchNextProgram.fromCursor(cursor))
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
return currentWatchNextPrograms
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -431,39 +461,51 @@ class LeanbackChannelWorker(
|
||||
setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_MOVIE_POSTER)
|
||||
}
|
||||
|
||||
// Name
|
||||
if (item.seriesName != null) setTitle("${item.seriesName} - ${item.name}")
|
||||
else setTitle(item.name)
|
||||
// Name and episode details
|
||||
if (item.seriesName != null) {
|
||||
setTitle(item.seriesName)
|
||||
setEpisodeTitle(item.name)
|
||||
|
||||
item.indexNumber?.takeIf { it > 0 }?.let { setEpisodeNumber(it) }
|
||||
item.parentIndexNumber?.takeIf { it > 0 }?.let { setSeasonNumber(it) }
|
||||
} else {
|
||||
setTitle(item.name)
|
||||
}
|
||||
|
||||
setDescription(item.overview?.stripHtml())
|
||||
|
||||
// Poster
|
||||
setPosterArtUri(item.getPosterArtImageUrl(preferParentThumb))
|
||||
|
||||
// Use date created or fallback to current time if unavailable
|
||||
var engagement = item.dateCreated
|
||||
|
||||
when {
|
||||
// User has started playing the episode
|
||||
(item.userData?.playbackPositionTicks ?: 0) > 0 -> {
|
||||
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE)
|
||||
setLastPlaybackPositionMillis(item.userData!!.playbackPositionTicks.ticks.inWholeMilliseconds.toInt())
|
||||
// Use last played date to prioritize
|
||||
engagement = item.userData?.lastPlayedDate
|
||||
|
||||
setLastEngagementTimeUtcMillis(
|
||||
item.userData?.lastPlayedDate?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
|
||||
?: Instant.now().toEpochMilli()
|
||||
)
|
||||
}
|
||||
// First episode of the season
|
||||
item.indexNumber == 1 -> setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEW)
|
||||
item.indexNumber == 1 -> {
|
||||
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEW)
|
||||
setLastEngagementTimeUtcMillis(
|
||||
item.dateCreated?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
|
||||
?: Instant.now().toEpochMilli()
|
||||
)
|
||||
}
|
||||
// Default
|
||||
else -> setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
|
||||
else -> {
|
||||
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
|
||||
setLastEngagementTimeUtcMillis(Instant.now().toEpochMilli())
|
||||
}
|
||||
}
|
||||
|
||||
setLastEngagementTimeUtcMillis(
|
||||
engagement?.toInstant(ZoneOffset.UTC)?.toEpochMilli()
|
||||
?: Instant.now().toEpochMilli()
|
||||
)
|
||||
|
||||
// Episode runtime has been determined
|
||||
item.runTimeTicks?.let { runTimeTicks ->
|
||||
setDurationMillis(runTimeTicks.ticks.inWholeMilliseconds.toInt())
|
||||
}
|
||||
// Runtime has been determined
|
||||
item.runTimeTicks?.ticks?.let { setDurationMillis(it.inWholeMilliseconds.toInt()) }
|
||||
|
||||
// Set intent to open the episode
|
||||
setIntent(Intent(context, StartupActivity::class.java).apply {
|
||||
|
||||
@@ -9,19 +9,22 @@ import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.provider.BaseColumns
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.data.repository.ItemRepository
|
||||
import org.jellyfin.androidtv.integration.provider.ImageProvider
|
||||
import org.jellyfin.androidtv.util.ImageHelper
|
||||
import org.jellyfin.androidtv.util.apiclient.getUrl
|
||||
import org.jellyfin.androidtv.util.apiclient.itemImages
|
||||
import org.jellyfin.androidtv.util.sdk.isUsable
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import timber.log.Timber
|
||||
@@ -61,8 +64,9 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
|
||||
|
||||
val limit = uri.getQueryParameter(SearchManager.SUGGEST_PARAMETER_LIMIT)?.toIntOrNull()
|
||||
?: DEFAULT_LIMIT
|
||||
return getSuggestions(query, limit)
|
||||
return runBlocking { getSuggestions(query, limit) }
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException("Unknown Uri: $uri")
|
||||
}
|
||||
}
|
||||
@@ -75,7 +79,7 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
|
||||
searchTerm = query,
|
||||
recursive = true,
|
||||
limit = limit,
|
||||
fields = setOf(ItemFields.TAGLINES)
|
||||
fields = ItemRepository.itemFields
|
||||
)
|
||||
|
||||
items
|
||||
@@ -84,7 +88,7 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
|
||||
null
|
||||
}
|
||||
|
||||
private fun getSuggestions(query: String, limit: Int) = runBlocking {
|
||||
private suspend fun getSuggestions(query: String, limit: Int) = withContext(Dispatchers.IO) {
|
||||
val searchResult = searchItems(query, limit)
|
||||
if (searchResult != null) Timber.d("Query resulted in %d items", searchResult.totalRecordCount)
|
||||
|
||||
@@ -104,10 +108,10 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
|
||||
|
||||
MatrixCursor(columns).also { cursor ->
|
||||
searchResult?.items?.forEach { item ->
|
||||
val imageUri = if (item.imageTags?.contains(ImageType.PRIMARY) == true)
|
||||
ImageProvider.getImageUri(api.imageApi.getItemImageUrl(item.id, ImageType.PRIMARY))
|
||||
else
|
||||
imageHelper.getResourceUrl(context!!, R.drawable.tile_land_tv)
|
||||
val imageUri = ImageProvider.getImageUri(
|
||||
item.itemImages[ImageType.PRIMARY]?.getUrl(api)
|
||||
?: imageHelper.getResourceUrl(context!!, R.drawable.tile_land_tv)
|
||||
)
|
||||
|
||||
cursor.newRow().apply {
|
||||
add(BaseColumns._ID, item.id)
|
||||
|
||||
@@ -10,29 +10,35 @@ import coil3.toBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.cancellable
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.integration.dream.model.DreamContent
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.util.apiclient.getUrl
|
||||
import org.jellyfin.androidtv.util.apiclient.itemBackdropImages
|
||||
import org.jellyfin.androidtv.util.apiclient.itemImages
|
||||
import org.jellyfin.playback.core.PlaybackManager
|
||||
import org.jellyfin.playback.core.queue.queue
|
||||
import org.jellyfin.playback.jellyfin.queue.baseItem
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageFormat
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
@@ -46,9 +52,10 @@ class DreamViewModel(
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val _mediaContent = playbackManager.queue.entry
|
||||
.map { entry ->
|
||||
entry?.baseItem?.let { baseItem ->
|
||||
DreamContent.NowPlaying(entry, baseItem)
|
||||
}
|
||||
entry
|
||||
?.takeIf { it.visibleInScreensaver }
|
||||
?.baseItem
|
||||
?.let { baseItem -> DreamContent.NowPlaying(entry, baseItem) }
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
|
||||
|
||||
@@ -58,15 +65,19 @@ class DreamViewModel(
|
||||
emit(null)
|
||||
delay(2.seconds)
|
||||
|
||||
while (true) {
|
||||
val next = getRandomLibraryShowcase()
|
||||
if (next != null) {
|
||||
emit(next)
|
||||
delay(30.seconds)
|
||||
} else {
|
||||
delay(3.seconds)
|
||||
}
|
||||
}
|
||||
val requireParentalRating = userPreferences[UserPreferences.screensaverAgeRatingRequired]
|
||||
val maxParentalRating = userPreferences[UserPreferences.screensaverAgeRatingMax]
|
||||
emitAll(
|
||||
getRandomLibraryShowcaseItems(
|
||||
requireParentalRating = requireParentalRating,
|
||||
maxParentalRating = maxParentalRating,
|
||||
// A batch size of 60 should be equal to 30 minutes of items
|
||||
batchSize = 60,
|
||||
emitDelay = 30.seconds,
|
||||
noItemsDelay = 2.minutes,
|
||||
errorDelay = 3.seconds,
|
||||
)
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
|
||||
@@ -79,70 +90,71 @@ class DreamViewModel(
|
||||
initialValue = _mediaContent.value ?: _libraryContent.value ?: DreamContent.Logo,
|
||||
)
|
||||
|
||||
private suspend fun getRandomLibraryShowcase(): DreamContent.LibraryShowcase? {
|
||||
val requireParentalRating = userPreferences[UserPreferences.screensaverAgeRatingRequired]
|
||||
val maxParentalRating = userPreferences[UserPreferences.screensaverAgeRatingMax]
|
||||
|
||||
try {
|
||||
val response by api.itemsApi.getItems(
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
recursive = true,
|
||||
sortBy = listOf(ItemSortBy.RANDOM),
|
||||
limit = 5,
|
||||
imageTypes = listOf(ImageType.BACKDROP),
|
||||
maxOfficialRating = if (maxParentalRating == -1) null else maxParentalRating.toString(),
|
||||
hasParentalRating = if (requireParentalRating) true else null,
|
||||
)
|
||||
|
||||
val item = response.items.firstOrNull { item ->
|
||||
!item.backdropImageTags.isNullOrEmpty()
|
||||
} ?: return null
|
||||
|
||||
Timber.i("Loading random library showcase item ${item.id}")
|
||||
|
||||
val backdropTag = item.backdropImageTags!!.randomOrNull()
|
||||
?: item.imageTags?.get(ImageType.BACKDROP)
|
||||
|
||||
val logoTag = item.imageTags?.get(ImageType.LOGO)
|
||||
|
||||
val backdropUrl = api.imageApi.getItemImageUrl(
|
||||
itemId = item.id,
|
||||
imageType = ImageType.BACKDROP,
|
||||
tag = backdropTag,
|
||||
format = ImageFormat.WEBP,
|
||||
)
|
||||
|
||||
val logoUrl = api.imageApi.getItemImageUrl(
|
||||
itemId = item.id,
|
||||
imageType = ImageType.LOGO,
|
||||
tag = logoTag,
|
||||
format = ImageFormat.WEBP,
|
||||
)
|
||||
|
||||
val (logo, backdrop) = withContext(Dispatchers.IO) {
|
||||
val logoDeferred = async {
|
||||
imageLoader.execute(
|
||||
request = ImageRequest.Builder(context).data(logoUrl).build()
|
||||
).image?.toBitmap()
|
||||
private fun getRandomLibraryShowcaseItems(
|
||||
requireParentalRating: Boolean,
|
||||
maxParentalRating: Int,
|
||||
batchSize: Int,
|
||||
emitDelay: Duration,
|
||||
noItemsDelay: Duration,
|
||||
errorDelay: Duration,
|
||||
): Flow<DreamContent.LibraryShowcase?> = flow {
|
||||
while (true) {
|
||||
val items = try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val response by api.itemsApi.getItems(
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
recursive = true,
|
||||
sortBy = listOf(ItemSortBy.RANDOM),
|
||||
limit = batchSize,
|
||||
imageTypes = listOf(ImageType.BACKDROP),
|
||||
maxOfficialRating = if (maxParentalRating == -1) null else maxParentalRating.toString(),
|
||||
hasParentalRating = if (requireParentalRating) true else null,
|
||||
)
|
||||
response.items
|
||||
}
|
||||
|
||||
val backdropDeferred = async {
|
||||
imageLoader.execute(
|
||||
request = ImageRequest.Builder(context).data(backdropUrl).build()
|
||||
).image?.toBitmap()
|
||||
}
|
||||
|
||||
awaitAll(logoDeferred, backdropDeferred)
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err)
|
||||
null
|
||||
}
|
||||
|
||||
if (backdrop == null) {
|
||||
return null
|
||||
if (items == null) {
|
||||
emit(null)
|
||||
delay(errorDelay)
|
||||
} else if (items.isEmpty()) {
|
||||
emit(null)
|
||||
delay(noItemsDelay)
|
||||
} else {
|
||||
for (item in items) {
|
||||
if (item.itemBackdropImages.isEmpty()) continue
|
||||
val showcase = item.asLibraryShowcase() ?: continue
|
||||
emit(showcase)
|
||||
delay(emitDelay)
|
||||
}
|
||||
}
|
||||
|
||||
return DreamContent.LibraryShowcase(item, backdrop, logo)
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err)
|
||||
return null
|
||||
}
|
||||
}.cancellable()
|
||||
|
||||
private suspend fun BaseItemDto.asLibraryShowcase(): DreamContent.LibraryShowcase? = withContext(Dispatchers.IO) {
|
||||
val logoUrl = itemImages[ImageType.LOGO]?.getUrl(api)
|
||||
val backdropUrl = itemBackdropImages.randomOrNull()?.getUrl(api)
|
||||
|
||||
// Require a backdrop
|
||||
if (backdropUrl == null) return@withContext null
|
||||
|
||||
// Only attempt to load logo if there is one, wrap in async {} to load it parallel with the backdrop
|
||||
val logo = logoUrl?.let { url ->
|
||||
async {
|
||||
imageLoader.execute(
|
||||
request = ImageRequest.Builder(context).data(url).build()
|
||||
).image?.toBitmap()
|
||||
}
|
||||
}
|
||||
|
||||
val backdrop = imageLoader.execute(
|
||||
request = ImageRequest.Builder(context).data(backdropUrl).build()
|
||||
).image?.toBitmap()
|
||||
|
||||
if (backdrop == null) null
|
||||
else DreamContent.LibraryShowcase(this@asLibraryShowcase, backdrop, logo?.await())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.jellyfin.androidtv.integration.dream
|
||||
|
||||
import org.jellyfin.playback.core.element.ElementKey
|
||||
import org.jellyfin.playback.core.element.requiredElement
|
||||
import org.jellyfin.playback.core.queue.QueueEntry
|
||||
|
||||
private val visibleInScreensaverKey = ElementKey<Boolean>("visibleInScreensaver")
|
||||
|
||||
/**
|
||||
* Get or set whether this [QueueEntry] is shown in the screensaver.
|
||||
*/
|
||||
var QueueEntry.visibleInScreensaver by requiredElement(visibleInScreensaverKey) { false }
|
||||
@@ -2,9 +2,10 @@ package org.jellyfin.androidtv.integration.dream.composable
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -14,8 +15,8 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import org.jellyfin.androidtv.integration.dream.model.DreamContent
|
||||
import org.jellyfin.androidtv.ui.base.Text
|
||||
import org.jellyfin.androidtv.ui.composable.ZoomBox
|
||||
import org.jellyfin.androidtv.ui.composable.modifier.overscan
|
||||
|
||||
@@ -50,12 +51,17 @@ fun DreamContentLibraryShowcase(
|
||||
.overscan(),
|
||||
) {
|
||||
if (content.logo != null) {
|
||||
Image(
|
||||
bitmap = content.logo.asImageBitmap(),
|
||||
contentDescription = content.item.name,
|
||||
modifier = Modifier
|
||||
.height(75.dp)
|
||||
)
|
||||
BoxWithConstraints {
|
||||
Image(
|
||||
bitmap = content.logo.asImageBitmap(),
|
||||
contentDescription = content.item.name,
|
||||
modifier = Modifier
|
||||
.sizeIn(
|
||||
maxWidth = maxWidth * 0.35f,
|
||||
maxHeight = 75.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = content.item.name.orEmpty(),
|
||||
|
||||
@@ -19,28 +19,30 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import org.jellyfin.androidtv.integration.dream.model.DreamContent
|
||||
import org.jellyfin.androidtv.ui.base.SeekbarDefaults
|
||||
import org.jellyfin.androidtv.ui.base.Text
|
||||
import org.jellyfin.androidtv.ui.composable.AsyncImage
|
||||
import org.jellyfin.androidtv.ui.composable.LyricsDtoBox
|
||||
import org.jellyfin.androidtv.ui.composable.blurHashPainter
|
||||
import org.jellyfin.androidtv.ui.composable.modifier.fadingEdges
|
||||
import org.jellyfin.androidtv.ui.composable.modifier.overscan
|
||||
import org.jellyfin.androidtv.ui.composable.rememberPlayerProgress
|
||||
import org.jellyfin.androidtv.ui.player.base.PlayerSeekbar
|
||||
import org.jellyfin.androidtv.util.apiclient.albumPrimaryImage
|
||||
import org.jellyfin.androidtv.util.apiclient.getUrl
|
||||
import org.jellyfin.androidtv.util.apiclient.itemImages
|
||||
import org.jellyfin.androidtv.util.apiclient.parentImages
|
||||
import org.jellyfin.playback.core.PlaybackManager
|
||||
import org.jellyfin.playback.core.model.PlayState
|
||||
import org.jellyfin.playback.jellyfin.lyrics
|
||||
import org.jellyfin.playback.jellyfin.lyricsFlow
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.model.api.ImageFormat
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@@ -53,22 +55,15 @@ fun DreamContentNowPlaying(
|
||||
val api = koinInject<ApiClient>()
|
||||
val playbackManager = koinInject<PlaybackManager>()
|
||||
val lyrics = content.entry.run { lyricsFlow.collectAsState(lyrics) }.value
|
||||
val progress = rememberPlayerProgress(playbackManager)
|
||||
|
||||
val primaryImageTag = content.item.imageTags?.get(ImageType.PRIMARY)
|
||||
val (imageItemId, imageTag) = when {
|
||||
primaryImageTag != null -> content.item.id to primaryImageTag
|
||||
(content.item.albumId != null && content.item.albumPrimaryImageTag != null) -> content.item.albumId to content.item.albumPrimaryImageTag
|
||||
else -> null to null
|
||||
}
|
||||
val primaryImage = content.item.itemImages[ImageType.PRIMARY]
|
||||
?: content.item.albumPrimaryImage
|
||||
?: content.item.parentImages[ImageType.PRIMARY]
|
||||
|
||||
// Background
|
||||
val imageBlurHash = imageTag?.let { tag ->
|
||||
content.item.imageBlurHashes?.get(ImageType.PRIMARY)?.get(tag)
|
||||
}
|
||||
if (imageBlurHash != null) {
|
||||
if (primaryImage?.blurHash != null) {
|
||||
Image(
|
||||
painter = blurHashPainter(imageBlurHash, IntSize(32, 32)),
|
||||
painter = blurHashPainter(primaryImage.blurHash, IntSize(32, 32)),
|
||||
contentDescription = null,
|
||||
alignment = Alignment.Center,
|
||||
contentScale = ContentScale.Crop,
|
||||
@@ -103,15 +98,10 @@ fun DreamContentNowPlaying(
|
||||
.align(Alignment.BottomStart)
|
||||
.overscan(),
|
||||
) {
|
||||
if (imageItemId != null) {
|
||||
if (primaryImage != null) {
|
||||
AsyncImage(
|
||||
url = api.imageApi.getItemImageUrl(
|
||||
itemId = imageItemId,
|
||||
imageType = ImageType.PRIMARY,
|
||||
tag = imageTag,
|
||||
format = ImageFormat.WEBP,
|
||||
),
|
||||
blurHash = imageBlurHash,
|
||||
url = primaryImage.getUrl(api),
|
||||
blurHash = primaryImage.blurHash,
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP,
|
||||
modifier = Modifier
|
||||
.size(128.dp)
|
||||
@@ -150,20 +140,16 @@ fun DreamContentNowPlaying(
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Box(
|
||||
PlayerSeekbar(
|
||||
playbackManager = playbackManager,
|
||||
colors = SeekbarDefaults.colors(
|
||||
backgroundColor = Color.White.copy(alpha = 0.2f),
|
||||
progressColor = Color.White,
|
||||
bufferColor = Color.Transparent,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.drawWithContent {
|
||||
// Background
|
||||
drawRect(Color.White, alpha = 0.2f)
|
||||
// Foreground
|
||||
drawRect(
|
||||
Color.White,
|
||||
size = size.copy(width = progress * size.width)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +1,32 @@
|
||||
package org.jellyfin.androidtv.integration.dream.composable
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.composable.modifier.overscan
|
||||
import org.jellyfin.androidtv.ui.composable.rememberCurrentTime
|
||||
import org.jellyfin.androidtv.ui.shared.toolbar.ToolbarClock
|
||||
|
||||
@Composable
|
||||
fun DreamHeader(
|
||||
showLogo: Boolean,
|
||||
showClock: Boolean,
|
||||
) = Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.overscan(),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
// Clock
|
||||
AnimatedVisibility(
|
||||
visible = showClock,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.overscan(),
|
||||
.align(Alignment.TopEnd),
|
||||
) {
|
||||
// Logo
|
||||
AnimatedVisibility(
|
||||
visible = showLogo,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier.height(41.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.app_logo),
|
||||
contentDescription = stringResource(R.string.app_name),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0f)
|
||||
)
|
||||
|
||||
// Clock
|
||||
AnimatedVisibility(
|
||||
visible = showClock,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
val time = rememberCurrentTime()
|
||||
Text(
|
||||
text = DateUtils.formatDateTime(LocalContext.current, time, DateUtils.FORMAT_SHOW_TIME),
|
||||
style = TextStyle(
|
||||
color = Color.White,
|
||||
fontSize = 20.sp
|
||||
),
|
||||
)
|
||||
}
|
||||
ToolbarClock()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ fun DreamView(
|
||||
|
||||
// Header overlay
|
||||
DreamHeader(
|
||||
showLogo = content != DreamContent.Logo,
|
||||
showClock = showClock,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ package org.jellyfin.androidtv.preference
|
||||
import android.content.Context
|
||||
import android.view.KeyEvent
|
||||
import androidx.preference.PreferenceManager
|
||||
import org.jellyfin.androidtv.preference.UserPreferences.Companion.screensaverInAppEnabled
|
||||
import org.jellyfin.androidtv.preference.constant.AppTheme
|
||||
import org.jellyfin.androidtv.preference.constant.AudioBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.ClockBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.NextUpBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.RatingType
|
||||
import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.StillWatchingBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.WatchedIndicatorBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.ZoomMode
|
||||
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentAction
|
||||
@@ -86,6 +88,11 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
*/
|
||||
var cinemaModeEnabled = booleanPreference("pref_enable_cinema_mode", true)
|
||||
|
||||
/**
|
||||
* Enable still watching
|
||||
*/
|
||||
var stillWatchingBehavior = enumPreference("enable_still_watching", StillWatchingBehavior.DISABLED)
|
||||
|
||||
/* Playback - Video */
|
||||
/**
|
||||
* Whether to use an external playback application or not.
|
||||
@@ -170,6 +177,11 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
*/
|
||||
var subtitlesBackgroundColor = longPreference("subtitles_background_color", 0x00FFFFFF)
|
||||
|
||||
/**
|
||||
* Subtitles bold text
|
||||
*/
|
||||
var subtitlesTextWeight = intPreference("subtitles_text_weight", 400)
|
||||
|
||||
/**
|
||||
* Subtitles foreground color
|
||||
*/
|
||||
@@ -185,6 +197,11 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
*/
|
||||
var subtitlesTextSize = floatPreference("subtitles_text_size", 1f)
|
||||
|
||||
/**
|
||||
* Subtitles offset
|
||||
*/
|
||||
var subtitlesOffsetPosition = floatPreference("subtitles_offset_position", 0.08f)
|
||||
|
||||
/**
|
||||
* Show screensaver in app
|
||||
*/
|
||||
@@ -230,6 +247,16 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
* Enable TrickPlay in legacy player user interface while seeking.
|
||||
*/
|
||||
var trickPlayEnabled = booleanPreference("trick_play_enabled", false)
|
||||
|
||||
/**
|
||||
* Enable libass.
|
||||
*/
|
||||
var assDirectPlay = booleanPreference("libass_enabled", false)
|
||||
|
||||
/**
|
||||
* Enable PGS subtitle direct-play.
|
||||
*/
|
||||
var pgsDirectPlay = booleanPreference("pgs_enabled", true)
|
||||
}
|
||||
|
||||
init {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.jellyfin.androidtv.preference.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
|
||||
enum class StillWatchingBehavior(
|
||||
override val nameRes: Int
|
||||
) : PreferenceEnum {
|
||||
/**
|
||||
* Shorter than default. Show screen at 2 episodes or 60 minutes of uninterrupted watch time.
|
||||
*/
|
||||
SHORT(R.string.lbl_still_watching_short),
|
||||
/**
|
||||
* Default behavior for still watching. Show screen at 3 episodes or 90 minutes of uninterrupted watch time.
|
||||
*/
|
||||
DEFAULT(R.string.lbl_still_watching_default),
|
||||
/**
|
||||
* Longer than default. Show screen at 5 episodes or 150 minutes of uninterrupted watch time.
|
||||
*/
|
||||
LONG(R.string.lbl_still_watching_long),
|
||||
/**
|
||||
* Much longer than default. Show screen at 8 episodes or 240 minutes of uninterrupted watch time.
|
||||
*/
|
||||
VERY_LONG(R.string.lbl_still_watching_very_long),
|
||||
/**
|
||||
* Disables still watching screen.
|
||||
*/
|
||||
DISABLED(R.string.state_disabled)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.jellyfin.androidtv.preference.store
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.preference.Preference
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
import org.jellyfin.preference.migration.MigrationContext
|
||||
@@ -56,10 +58,12 @@ abstract class DisplayPreferencesStore(
|
||||
|
||||
override suspend fun update(): Boolean {
|
||||
try {
|
||||
val result by api.displayPreferencesApi.getDisplayPreferences(
|
||||
displayPreferencesId = displayPreferencesId,
|
||||
client = app
|
||||
)
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
api.displayPreferencesApi.getDisplayPreferences(
|
||||
displayPreferencesId = displayPreferencesId,
|
||||
client = app
|
||||
).content
|
||||
}
|
||||
displayPreferencesDto = result
|
||||
cachedPreferences = result.customPrefs.toMutableMap()
|
||||
|
||||
@@ -82,7 +86,7 @@ abstract class DisplayPreferencesStore(
|
||||
override fun getLong(key: String, defaultValue: Long) =
|
||||
cachedPreferences[key]?.toLongOrNull() ?: defaultValue
|
||||
|
||||
override fun getFloat(key: String, defaultValue: Float) =
|
||||
override fun getFloat(key: String, defaultValue: Float) =
|
||||
cachedPreferences[key]?.toFloatOrNull() ?: defaultValue
|
||||
|
||||
override fun getBool(key: String, defaultValue: Boolean) =
|
||||
|
||||
@@ -18,6 +18,11 @@ import org.acra.sender.ReportSenderFactory
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.preference.TelemetryPreferences
|
||||
import org.jellyfin.androidtv.util.appendCodeBlock
|
||||
import org.jellyfin.androidtv.util.appendItem
|
||||
import org.jellyfin.androidtv.util.appendSection
|
||||
import org.jellyfin.androidtv.util.appendValue
|
||||
import org.jellyfin.androidtv.util.buildMarkdown
|
||||
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
@@ -63,8 +68,12 @@ object TelemetryService {
|
||||
// Create connection
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
// Add authorization
|
||||
val clientName = buildString {
|
||||
append("Jellyfin Android TV")
|
||||
if (BuildConfig.DEBUG) append(" (debug)")
|
||||
}
|
||||
val authorization = AuthorizationHeaderBuilder.buildHeader(
|
||||
clientName = BuildConfig.APPLICATION_ID,
|
||||
clientName = clientName,
|
||||
clientVersion = BuildConfig.VERSION_NAME,
|
||||
deviceId = "",
|
||||
deviceName = "",
|
||||
@@ -85,31 +94,7 @@ object TelemetryService {
|
||||
throw ReportSenderException("Unable to send crash report to server", e)
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendSection(name: String, content: StringBuilder.() -> Unit) {
|
||||
appendLine("### $name")
|
||||
appendLine()
|
||||
content()
|
||||
appendLine()
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendItem(name: String, value: StringBuilder.() -> Unit) {
|
||||
append("***$name***: ")
|
||||
value()
|
||||
appendLine(" ")
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendCodeBlock(language: String, code: String?) {
|
||||
appendLine()
|
||||
appendLine("```$language")
|
||||
appendLine(code ?: "<null>")
|
||||
append("```")
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendValue(value: String?) {
|
||||
append("`", value ?: "<null>", "`")
|
||||
}
|
||||
|
||||
private fun CrashReportData.toReport(): String = buildString {
|
||||
private fun CrashReportData.toReport(): String = buildMarkdown {
|
||||
// Header
|
||||
appendLine("---")
|
||||
appendLine("client: Jellyfin for Android TV")
|
||||
@@ -118,6 +103,7 @@ object TelemetryService {
|
||||
appendLine("type: crash_report")
|
||||
appendLine("format: markdown")
|
||||
appendLine("---")
|
||||
appendLine()
|
||||
|
||||
// Content
|
||||
appendSection("Logs") {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package org.jellyfin.androidtv.ui
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import android.widget.ImageView
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.core.view.doOnAttach
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
@@ -17,6 +19,7 @@ import coil3.request.target
|
||||
import coil3.request.transformations
|
||||
import coil3.transform.CircleCropTransformation
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.R
|
||||
@@ -39,6 +42,7 @@ class AsyncImageView @JvmOverloads constructor(
|
||||
private val lifeCycleOwner get() = findViewTreeLifecycleOwner()
|
||||
private val styledAttributes = context.obtainStyledAttributes(attrs, R.styleable.AsyncImageView, defStyleAttr, 0)
|
||||
private val imageLoader by inject<ImageLoader>()
|
||||
private var loadJob: Job? = null
|
||||
|
||||
/**
|
||||
* The duration of the crossfade when changing switching the images of the url, blurhash and
|
||||
@@ -64,11 +68,15 @@ class AsyncImageView @JvmOverloads constructor(
|
||||
aspectRatio: Double = 1.0,
|
||||
blurHashResolution: Int = 32,
|
||||
) = doOnAttach {
|
||||
lifeCycleOwner?.lifecycleScope?.launch {
|
||||
// Cancel the previous load if still running
|
||||
loadJob?.cancel()
|
||||
|
||||
loadJob = lifeCycleOwner?.lifecycleScope?.launch(Dispatchers.IO) {
|
||||
var placeholderOrBlurHash = placeholder
|
||||
|
||||
// Only show blurhash if an image is going to be loaded from the network
|
||||
if (url != null && blurHash != null) withContext(Dispatchers.IO) {
|
||||
val isLowRamDevice = context.getSystemService<ActivityManager>()?.isLowRamDevice == true
|
||||
if (url != null && blurHash != null && !isLowRamDevice) withContext(Dispatchers.IO) {
|
||||
val blurHashBitmap = BlurHashDecoder.decode(
|
||||
blurHash,
|
||||
if (aspectRatio > 1) round(blurHashResolution * aspectRatio).toInt() else blurHashResolution,
|
||||
@@ -78,14 +86,14 @@ class AsyncImageView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
// Start loading image or placeholder
|
||||
if (url == null) {
|
||||
imageLoader.enqueue(ImageRequest.Builder(context).apply {
|
||||
val request = if (url == null) {
|
||||
ImageRequest.Builder(context).apply {
|
||||
target(this@AsyncImageView)
|
||||
data(placeholder)
|
||||
if (circleCrop) transformations(CircleCropTransformation())
|
||||
}.build())
|
||||
}.build()
|
||||
} else {
|
||||
imageLoader.enqueue(ImageRequest.Builder(context).apply {
|
||||
ImageRequest.Builder(context).apply {
|
||||
val crossFadeDurationMs = crossFadeDuration.inWholeMilliseconds.toInt()
|
||||
if (crossFadeDurationMs > 0) crossfade(crossFadeDurationMs)
|
||||
else crossfade(false)
|
||||
@@ -95,8 +103,10 @@ class AsyncImageView @JvmOverloads constructor(
|
||||
placeholder(placeholderOrBlurHash?.asImage())
|
||||
if (circleCrop) transformations(CircleCropTransformation())
|
||||
error(placeholder?.asImage())
|
||||
}.build())
|
||||
}.build()
|
||||
}
|
||||
|
||||
imageLoader.enqueue(request).job.await()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package org.jellyfin.androidtv.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.animation.AlphaAnimation
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.IntRange
|
||||
|
||||
class FadeViewSwitcherLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
defStyleRes: Int = 0,
|
||||
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
|
||||
companion object {
|
||||
const val VIEW_NONE = -1
|
||||
}
|
||||
|
||||
private var currentView = VIEW_NONE
|
||||
private val nextViewId
|
||||
get() = if (currentView >= childCount - 1) 0 else currentView + 1
|
||||
private val previousViewId
|
||||
get() = if (currentView <= 0) childCount - 1 else currentView - 1
|
||||
|
||||
override fun onViewAdded(child: View?) {
|
||||
super.onViewAdded(child)
|
||||
|
||||
// Hide new views by default
|
||||
child?.alpha = 0f
|
||||
}
|
||||
|
||||
fun showNextView() = showView(nextViewId)
|
||||
fun showPreviousView() = showView(previousViewId)
|
||||
fun hideAllViews() = showView(VIEW_NONE)
|
||||
|
||||
fun <V : View> getNextView() = getChildAt(nextViewId) as V
|
||||
fun <V : View> getCurrentView() = if (currentView == VIEW_NONE) null else currentView as V
|
||||
fun <V : View> getPreviousView() = getChildAt(previousViewId) as V
|
||||
|
||||
fun showView(@IntRange(from = -1) view: Int) {
|
||||
// TODO: right now crossfades, need to be smarter to hide old view after new view is completely visible
|
||||
if (currentView != VIEW_NONE) getChildAt(currentView).fadeOut()
|
||||
if (view != VIEW_NONE) getChildAt(view).fadeIn()
|
||||
|
||||
currentView = view
|
||||
}
|
||||
|
||||
private fun View.fadeIn() {
|
||||
alpha = 1f
|
||||
startAnimation(AlphaAnimation(0f, 1f).apply {
|
||||
duration = 1_000
|
||||
fillAfter = true
|
||||
})
|
||||
}
|
||||
|
||||
private fun View.fadeOut() = startAnimation(AlphaAnimation(1f, 0f).apply {
|
||||
duration = 1_000
|
||||
fillAfter = true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package org.jellyfin.androidtv.ui
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackController
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
|
||||
import org.jellyfin.androidtv.ui.playback.stillwatching.StillWatchingPresetConfigs
|
||||
import org.jellyfin.androidtv.ui.playback.stillwatching.StillWatchingStates
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class InteractionTrackerViewModel(
|
||||
private val userPreferences: UserPreferences,
|
||||
private val playbackControllerContainer: PlaybackControllerContainer
|
||||
) : ViewModel() {
|
||||
// Screensaver vars
|
||||
|
||||
private var timer: Job? = null
|
||||
private var locks = 0
|
||||
|
||||
// Still Watching vars
|
||||
|
||||
private var isWatchingEpisodes = false
|
||||
private var episodeCount = 0
|
||||
private var watchTime = 0L
|
||||
private var episodeInteractMs = 0L
|
||||
private var episodeWasInterrupted: Boolean = false
|
||||
private var showStillWatching: Boolean = false
|
||||
|
||||
// Preferences
|
||||
|
||||
private val inAppEnabled get() = userPreferences[UserPreferences.screensaverInAppEnabled]
|
||||
private val timeout get() = userPreferences[UserPreferences.screensaverInAppTimeout].milliseconds
|
||||
|
||||
// State
|
||||
|
||||
private val _screensaverVisible = MutableStateFlow(false)
|
||||
val visible get() = _screensaverVisible.asStateFlow()
|
||||
|
||||
private val _keepScreenOn = MutableStateFlow(inAppEnabled)
|
||||
val keepScreenOn get() = _keepScreenOn.asStateFlow()
|
||||
|
||||
var activityPaused: Boolean = false
|
||||
set(value) {
|
||||
field = value
|
||||
notifyInteraction(canCancel = true, userInitiated = false)
|
||||
}
|
||||
|
||||
init {
|
||||
notifyInteraction(canCancel = true, userInitiated = false)
|
||||
}
|
||||
|
||||
fun getShowStillWatching(): Boolean {
|
||||
return showStillWatching
|
||||
}
|
||||
|
||||
fun notifyStartSession(item: BaseItemDto, items: List<BaseItemDto>) {
|
||||
// No need to track when only watching 1 episode
|
||||
if (itemIsEpisode(item) && items.size > 1) {
|
||||
resetSession()
|
||||
episodeWasInterrupted = false
|
||||
showStillWatching = false
|
||||
}
|
||||
}
|
||||
|
||||
fun onEpisodeWatched() {
|
||||
if (!episodeWasInterrupted) episodeCount++
|
||||
calculateWatchTime()
|
||||
episodeWasInterrupted = false
|
||||
checkStillWatchingStatus()
|
||||
}
|
||||
|
||||
fun notifyStart(item: BaseItemDto) {
|
||||
if (itemIsEpisode(item)) {
|
||||
isWatchingEpisodes = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkStillWatchingStatus() {
|
||||
val presetName = userPreferences[UserPreferences.stillWatchingBehavior].toString().uppercase()
|
||||
val preset = runCatching { StillWatchingPresetConfigs.valueOf(presetName) }.getOrDefault(StillWatchingPresetConfigs.DISABLED)
|
||||
|
||||
val stillWatchingSetting = StillWatchingStates.getSetting(preset)
|
||||
val episodeRequirementMet = episodeCount == stillWatchingSetting.episodeCount
|
||||
val watchTimeRequirementMet = watchTime >= stillWatchingSetting.minDuration.inWholeMilliseconds
|
||||
|
||||
if (episodeRequirementMet || watchTimeRequirementMet) {
|
||||
showStillWatching = true
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyInteraction(canCancel: Boolean, userInitiated: Boolean) {
|
||||
// Cancel pending screensaver timer (if any)
|
||||
timer?.cancel()
|
||||
|
||||
// If watching episodes, reset episode count and watch time
|
||||
if (isWatchingEpisodes && userInitiated) {
|
||||
resetSession()
|
||||
|
||||
val playbackController: PlaybackController = playbackControllerContainer.playbackController!!
|
||||
|
||||
episodeInteractMs = playbackController.currentPosition
|
||||
episodeWasInterrupted = true
|
||||
}
|
||||
|
||||
// Hide screensaver when interacted with allowed cancellation or when disabled
|
||||
if (_screensaverVisible.value && (canCancel || !inAppEnabled || activityPaused)) {
|
||||
_screensaverVisible.value = false
|
||||
}
|
||||
|
||||
// Create new timer to show screensaver when enabled
|
||||
if (inAppEnabled && !activityPaused && locks == 0) {
|
||||
timer = viewModelScope.launch {
|
||||
delay(timeout)
|
||||
_screensaverVisible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// Update KEEP_SCREEN_ON flag value
|
||||
_keepScreenOn.value = inAppEnabled || locks > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a lock that prevents the screensaver for running until the returned function is called
|
||||
* or the lifecycle is destroyed.
|
||||
*
|
||||
* @return Function to cancel the lock
|
||||
*/
|
||||
fun addLifecycleLock(lifecycle: Lifecycle): () -> Unit {
|
||||
if (lifecycle.currentState == Lifecycle.State.DESTROYED) return {}
|
||||
|
||||
val lock = ScreensaverLock(lifecycle)
|
||||
lock.activate()
|
||||
return lock::cancel
|
||||
}
|
||||
|
||||
private inner class ScreensaverLock(private val lifecycle: Lifecycle) : LifecycleEventObserver {
|
||||
private var active: Boolean = false
|
||||
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
if (event == Lifecycle.Event.ON_DESTROY) cancel()
|
||||
}
|
||||
|
||||
fun activate() {
|
||||
if (active) return
|
||||
lifecycle.addObserver(this)
|
||||
locks++
|
||||
notifyInteraction(canCancel = true, userInitiated = false)
|
||||
active = true
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
if (!active) return
|
||||
locks--
|
||||
lifecycle.removeObserver(this)
|
||||
notifyInteraction(canCancel = false, userInitiated = false)
|
||||
active = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun itemIsEpisode(item: BaseItemDto? = null): Boolean {
|
||||
if (item != null) {
|
||||
return item.type == BaseItemKind.EPISODE
|
||||
}
|
||||
|
||||
val playbackController = playbackControllerContainer.playbackController
|
||||
|
||||
return playbackController?.currentlyPlayingItem?.type == BaseItemKind.EPISODE
|
||||
}
|
||||
|
||||
private fun resetSession() {
|
||||
watchTime = 0L
|
||||
episodeCount = 0
|
||||
episodeInteractMs = 0L
|
||||
}
|
||||
|
||||
private fun calculateWatchTime() {
|
||||
val duration = playbackControllerContainer.playbackController!!.duration
|
||||
val durationWatchedUninterrupted = if (episodeWasInterrupted) duration - episodeInteractMs else duration
|
||||
watchTime += durationWatchedUninterrupted
|
||||
}
|
||||
}
|
||||
@@ -2,27 +2,24 @@ package org.jellyfin.androidtv.ui
|
||||
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.data.repository.ItemRepository
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.koin.java.KoinJavaComponent
|
||||
|
||||
fun ItemListView.refresh() {
|
||||
val api by KoinJavaComponent.inject<ApiClient>(ApiClient::class.java)
|
||||
|
||||
findViewTreeLifecycleOwner()?.lifecycleScope?.launch {
|
||||
val response by api.itemsApi.getItems(
|
||||
ids = mItemIds,
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.ITEM_COUNTS,
|
||||
ItemFields.DISPLAY_PREFERENCES_ID,
|
||||
ItemFields.CHILD_COUNT,
|
||||
ItemFields.MEDIA_SOURCES,
|
||||
)
|
||||
)
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
api.itemsApi.getItems(
|
||||
ids = mItemIds,
|
||||
fields = ItemRepository.itemFields
|
||||
).content
|
||||
}
|
||||
|
||||
response.items?.forEachIndexed { index, item ->
|
||||
val view = mList.getChildAt(index)
|
||||
|
||||
@@ -24,7 +24,7 @@ import org.jellyfin.androidtv.data.model.DataRefreshService;
|
||||
import org.jellyfin.androidtv.ui.livetv.LiveTvGuide;
|
||||
import org.jellyfin.androidtv.ui.livetv.TvManager;
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
import org.jellyfin.androidtv.util.apiclient.EmptyLifecycleAwareResponse;
|
||||
import org.jellyfin.androidtv.util.apiclient.EmptyResponse;
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto;
|
||||
import org.koin.java.KoinJavaComponent;
|
||||
|
||||
@@ -48,13 +48,13 @@ public class LiveProgramDetailPopup {
|
||||
private Button mFirstButton;
|
||||
private Button mSeriesSettingsButton;
|
||||
|
||||
private EmptyLifecycleAwareResponse mTuneAction;
|
||||
private EmptyResponse mTuneAction;
|
||||
|
||||
private View mAnchor;
|
||||
private int mPosLeft;
|
||||
private int mPosTop;
|
||||
|
||||
public LiveProgramDetailPopup(Context context, LifecycleOwner lifecycleOwner, LiveTvGuide tvGuide, int width, EmptyLifecycleAwareResponse tuneAction) {
|
||||
public LiveProgramDetailPopup(Context context, LifecycleOwner lifecycleOwner, LiveTvGuide tvGuide, int width, EmptyResponse tuneAction) {
|
||||
mContext = context;
|
||||
lifecycle = lifecycleOwner.getLifecycle();
|
||||
mTvGuide = tvGuide;
|
||||
@@ -247,7 +247,7 @@ public class LiveProgramDetailPopup {
|
||||
tune.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
if (mTuneAction != null && mTuneAction.getActive()) mTuneAction.onResponse();
|
||||
if (mTuneAction != null) mTuneAction.onResponse();
|
||||
mPopup.dismiss();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package org.jellyfin.androidtv.ui
|
||||
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.data.repository.ItemMutationRepository
|
||||
import org.jellyfin.androidtv.util.getActivity
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
@@ -32,7 +34,9 @@ fun LiveProgramDetailPopup.cancelTimer(
|
||||
|
||||
lifecycle.coroutineScope.launch {
|
||||
runCatching {
|
||||
api.liveTvApi.cancelTimer(timerId)
|
||||
withContext(Dispatchers.IO) {
|
||||
api.liveTvApi.cancelTimer(timerId)
|
||||
}
|
||||
}.onSuccess {
|
||||
callback()
|
||||
}
|
||||
@@ -47,7 +51,9 @@ fun LiveProgramDetailPopup.cancelSeriesTimer(
|
||||
|
||||
lifecycle.coroutineScope.launch {
|
||||
runCatching {
|
||||
api.liveTvApi.cancelSeriesTimer(seriesTimerId)
|
||||
withContext(Dispatchers.IO) {
|
||||
api.liveTvApi.cancelSeriesTimer(seriesTimerId)
|
||||
}
|
||||
}.onSuccess {
|
||||
callback()
|
||||
}
|
||||
@@ -88,10 +94,12 @@ fun LiveProgramDetailPopup.recordProgram(
|
||||
|
||||
lifecycle.coroutineScope.launch {
|
||||
runCatching {
|
||||
val seriesTimer by api.liveTvApi.getDefaultTimer(programId.toString())
|
||||
val timer = seriesTimer.asTimerInfoDto()
|
||||
api.liveTvApi.createTimer(timer)
|
||||
api.liveTvApi.getProgram(programId.toString()).content
|
||||
withContext(Dispatchers.IO) {
|
||||
val seriesTimer by api.liveTvApi.getDefaultTimer(programId.toString())
|
||||
val timer = seriesTimer.asTimerInfoDto()
|
||||
api.liveTvApi.createTimer(timer)
|
||||
api.liveTvApi.getProgram(programId.toString()).content
|
||||
}
|
||||
}.onSuccess { program ->
|
||||
callback(program)
|
||||
}
|
||||
@@ -106,9 +114,11 @@ fun LiveProgramDetailPopup.recordSeries(
|
||||
|
||||
lifecycle.coroutineScope.launch {
|
||||
runCatching {
|
||||
val timer by api.liveTvApi.getDefaultTimer(programId.toString())
|
||||
api.liveTvApi.createSeriesTimer(timer)
|
||||
api.liveTvApi.getProgram(programId.toString()).content
|
||||
withContext(Dispatchers.IO) {
|
||||
val timer by api.liveTvApi.getDefaultTimer(programId.toString())
|
||||
api.liveTvApi.createSeriesTimer(timer)
|
||||
api.liveTvApi.getProgram(programId.toString()).content
|
||||
}
|
||||
}.onSuccess { program ->
|
||||
callback(program)
|
||||
}
|
||||
@@ -123,7 +133,9 @@ fun LiveProgramDetailPopup.getSeriesTimer(
|
||||
|
||||
lifecycle.coroutineScope.launch {
|
||||
runCatching {
|
||||
api.liveTvApi.getSeriesTimer(seriesTimerId).content
|
||||
withContext(Dispatchers.IO) {
|
||||
api.liveTvApi.getSeriesTimer(seriesTimerId).content
|
||||
}
|
||||
}.onSuccess { seriesTimer ->
|
||||
callback(seriesTimer)
|
||||
}
|
||||
|
||||
@@ -3,20 +3,19 @@ package org.jellyfin.androidtv.ui
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.ImageView
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -24,122 +23,113 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.AbstractComposeView
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ProvideTextStyle
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.base.ProvideTextStyle
|
||||
import org.jellyfin.androidtv.ui.base.Text
|
||||
import org.jellyfin.androidtv.ui.base.button.ButtonBase
|
||||
import org.jellyfin.androidtv.ui.composable.AsyncImage
|
||||
import org.jellyfin.androidtv.ui.composable.rememberPlayerProgress
|
||||
import org.jellyfin.androidtv.ui.composable.rememberQueueEntry
|
||||
import org.jellyfin.androidtv.ui.navigation.Destinations
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
|
||||
import org.jellyfin.androidtv.util.ImageHelper
|
||||
import org.jellyfin.androidtv.util.apiclient.albumPrimaryImage
|
||||
import org.jellyfin.androidtv.util.apiclient.getUrl
|
||||
import org.jellyfin.androidtv.util.apiclient.itemImages
|
||||
import org.jellyfin.androidtv.util.apiclient.parentImages
|
||||
import org.jellyfin.playback.core.PlaybackManager
|
||||
import org.jellyfin.playback.jellyfin.queue.baseItem
|
||||
import org.jellyfin.playback.jellyfin.queue.baseItemFlow
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@Composable
|
||||
fun NowPlayingComposable() {
|
||||
fun NowPlayingComposable(
|
||||
modifier: Modifier = Modifier,
|
||||
onFocusableChange: (focusable: Boolean) -> Unit,
|
||||
) {
|
||||
val api = koinInject<ApiClient>()
|
||||
val playbackManager = koinInject<PlaybackManager>()
|
||||
val navigationRepository = koinInject<NavigationRepository>()
|
||||
val imageHelper = koinInject<ImageHelper>()
|
||||
|
||||
val entry by rememberQueueEntry(playbackManager)
|
||||
val item = entry?.run { baseItemFlow.collectAsState(baseItem) }?.value
|
||||
val progress = rememberPlayerProgress(playbackManager)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = item != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Surface(
|
||||
onClick = { navigationRepository.navigate(Destinations.nowPlaying) },
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colorResource(id = R.color.button_default_normal_background),
|
||||
focusedContainerColor = colorResource(id = R.color.button_default_highlight_background),
|
||||
contentColor = colorResource(id = R.color.button_default_normal_text),
|
||||
focusedContentColor = colorResource(id = R.color.button_default_highlight_text),
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||
shape = ClickableSurfaceDefaults.shape(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
),
|
||||
modifier = Modifier
|
||||
.widthIn(0.dp, 250.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.drawWithContent {
|
||||
// Background
|
||||
drawRect(Color.White, alpha = 0.4f)
|
||||
// Foreground
|
||||
drawRect(Color.White, size = size.copy(width = progress * size.width))
|
||||
}
|
||||
)
|
||||
LaunchedEffect(item == null) { onFocusableChange(item != null) }
|
||||
|
||||
ProvideTextStyle(
|
||||
value = TextStyle.Default.copy(
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
AnimatedContent(
|
||||
modifier = modifier,
|
||||
targetState = item,
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||
) { item ->
|
||||
if (item != null) {
|
||||
ButtonBase(
|
||||
onClick = { navigationRepository.navigate(Destinations.nowPlaying) },
|
||||
modifier = Modifier
|
||||
.widthIn(100.dp, 250.dp)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(5.dp)
|
||||
ProvideTextStyle(
|
||||
value = TextStyle.Default.copy(
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
) {
|
||||
val primaryImageTag = item?.imageTags?.get(ImageType.PRIMARY)
|
||||
val (imageItemId, imageTag) = when {
|
||||
primaryImageTag != null -> item.id to primaryImageTag
|
||||
(item?.albumId != null && item.albumPrimaryImageTag != null) -> item.albumId to item.albumPrimaryImageTag
|
||||
else -> null to null
|
||||
}
|
||||
val imageUrl = when {
|
||||
imageItemId != null && imageTag != null -> imageHelper.getImageUrl(
|
||||
itemId = imageItemId,
|
||||
imageType = ImageType.PRIMARY,
|
||||
imageTag = imageTag
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(3.dp)
|
||||
) {
|
||||
val image = item.itemImages[ImageType.PRIMARY] ?: item.albumPrimaryImage ?: item.parentImages[ImageType.PRIMARY]
|
||||
|
||||
AsyncImage(
|
||||
url = image?.getUrl(api),
|
||||
blurHash = image?.blurHash,
|
||||
placeholder = ContextCompat.getDrawable(LocalContext.current, R.drawable.ic_album),
|
||||
aspectRatio = image?.aspectRatio ?: 1f,
|
||||
modifier = Modifier
|
||||
.size(35.dp)
|
||||
.clip(CircleShape)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
|
||||
// Background
|
||||
drawCircle(
|
||||
style = Stroke(width = 3.dp.toPx()),
|
||||
color = Color.Black,
|
||||
alpha = 0.4f,
|
||||
)
|
||||
// Foreground
|
||||
drawArc(
|
||||
style = Stroke(width = 3.dp.toPx()),
|
||||
color = Color.White,
|
||||
useCenter = false,
|
||||
startAngle = -90f,
|
||||
sweepAngle = 360f * progress,
|
||||
)
|
||||
},
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP,
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
val imageBlurHash = imageTag?.let { tag ->
|
||||
item?.imageBlurHashes?.get(ImageType.PRIMARY)?.get(tag)
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
url = imageUrl,
|
||||
blurHash = imageBlurHash,
|
||||
placeholder = ContextCompat.getDrawable(LocalContext.current, R.drawable.ic_album),
|
||||
aspectRatio = item?.primaryImageAspectRatio ?: 1.0,
|
||||
modifier = Modifier
|
||||
.size(35.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP,
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
) {
|
||||
// Name
|
||||
Text(text = item?.name.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
val artists = item?.artists ?: item?.albumArtists ?: item?.albumArtist?.let(::listOf)
|
||||
Text(text = artists?.joinToString(", ").orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
modifier = Modifier
|
||||
.padding(start = 2.dp, end = 8.dp)
|
||||
) {
|
||||
// Name
|
||||
Text(text = item.name.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Bold)
|
||||
val artists = item.artists ?: item.albumArtists ?: item.albumArtist?.let(::listOf)
|
||||
Text(text = artists?.joinToString(", ").orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,5 +143,11 @@ class NowPlayingView @JvmOverloads constructor(
|
||||
defStyle: Int = 0
|
||||
) : AbstractComposeView(context, attrs, defStyle) {
|
||||
@Composable
|
||||
override fun Content() = NowPlayingComposable()
|
||||
override fun Content() = NowPlayingComposable(
|
||||
// Workaround for older Android versions unable to find focus in our toolbar view when the NowPlayingView is added but inactive
|
||||
onFocusableChange = { focusable ->
|
||||
isFocusable = focusable
|
||||
descendantFocusability = if (focusable) FOCUS_AFTER_DESCENDANTS else FOCUS_BLOCK_DESCENDANTS
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package org.jellyfin.androidtv.ui
|
||||
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.util.getActivity
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||
@@ -41,9 +43,11 @@ fun RecordPopup.updateSeriesTimer(
|
||||
|
||||
lifecycle.coroutineScope.launch {
|
||||
runCatching {
|
||||
val id = seriesTimer.id
|
||||
if (id == null) api.liveTvApi.createSeriesTimer(seriesTimer)
|
||||
else api.liveTvApi.updateSeriesTimer(id, seriesTimer)
|
||||
withContext(Dispatchers.IO) {
|
||||
val id = seriesTimer.id
|
||||
if (id == null) api.liveTvApi.createSeriesTimer(seriesTimer)
|
||||
else api.liveTvApi.updateSeriesTimer(id, seriesTimer)
|
||||
}
|
||||
}.onSuccess {
|
||||
callback()
|
||||
}
|
||||
@@ -58,9 +62,11 @@ fun RecordPopup.updateTimer(
|
||||
|
||||
lifecycle.coroutineScope.launch {
|
||||
runCatching {
|
||||
val id = timer.id
|
||||
if (id == null) api.liveTvApi.createTimer(timer)
|
||||
else api.liveTvApi.updateTimer(id, timer)
|
||||
withContext(Dispatchers.IO) {
|
||||
val id = timer.id
|
||||
if (id == null) api.liveTvApi.createTimer(timer)
|
||||
else api.liveTvApi.updateTimer(id, timer)
|
||||
}
|
||||
}.onSuccess {
|
||||
callback()
|
||||
}
|
||||
@@ -86,7 +92,9 @@ fun RecordPopup.getLiveTvProgram(
|
||||
|
||||
lifecycle.coroutineScope.launch {
|
||||
runCatching {
|
||||
api.liveTvApi.getProgram(id.toString()).content
|
||||
withContext(Dispatchers.IO) {
|
||||
api.liveTvApi.getProgram(id.toString()).content
|
||||
}
|
||||
}.onSuccess { program ->
|
||||
callback(program)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.jellyfin.androidtv.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
private typealias Lock = () -> Unit
|
||||
|
||||
@Composable
|
||||
fun ScreensaverLock(
|
||||
enabled: Boolean
|
||||
) {
|
||||
val viewModel = koinInject<InteractionTrackerViewModel>()
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
var lock by remember { mutableStateOf<Lock?>(null) }
|
||||
|
||||
DisposableEffect(enabled, lifecycleOwner) {
|
||||
if (enabled && lock == null) {
|
||||
lock = viewModel.addLifecycleLock(lifecycleOwner.lifecycle)
|
||||
}
|
||||
|
||||
onDispose {
|
||||
lock?.invoke()
|
||||
lock = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package org.jellyfin.androidtv.ui
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class ScreensaverViewModel(
|
||||
private val userPreferences: UserPreferences,
|
||||
) : ViewModel() {
|
||||
private var timer: Job? = null
|
||||
private var locks = 0
|
||||
|
||||
// Preferences
|
||||
|
||||
val inAppEnabled get() = userPreferences[UserPreferences.screensaverInAppEnabled]
|
||||
private val timeout get() = userPreferences[UserPreferences.screensaverInAppTimeout].milliseconds
|
||||
|
||||
// State
|
||||
|
||||
private val _visible = MutableStateFlow(false)
|
||||
val visible get() = _visible.asStateFlow()
|
||||
|
||||
private val _keepScreenOn = MutableStateFlow(inAppEnabled)
|
||||
val keepScreenOn get() = _keepScreenOn.asStateFlow()
|
||||
|
||||
var activityPaused: Boolean = false
|
||||
set(value) {
|
||||
field = value
|
||||
notifyInteraction(true)
|
||||
}
|
||||
|
||||
init {
|
||||
notifyInteraction(true)
|
||||
}
|
||||
|
||||
fun notifyInteraction(canCancel: Boolean) {
|
||||
// Cancel pending timer (if any)
|
||||
timer?.cancel()
|
||||
|
||||
// Hide when interacted with allowed cancellation or when disabled
|
||||
if (_visible.value && (canCancel || !inAppEnabled || activityPaused)) {
|
||||
_visible.value = false
|
||||
}
|
||||
|
||||
// Create new timer to show screensaver when enabled
|
||||
if (inAppEnabled && !activityPaused && locks == 0) {
|
||||
timer = viewModelScope.launch {
|
||||
delay(timeout)
|
||||
_visible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// Update KEEP_SCREEN_ON flag value
|
||||
_keepScreenOn.value = inAppEnabled || locks > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a lock that prevents the screensaver for running until the returned function is called
|
||||
* or the lifecycle is destroyed.
|
||||
*
|
||||
* @return Function to cancel the lock
|
||||
*/
|
||||
fun addLifecycleLock(lifecycle: Lifecycle): () -> Unit {
|
||||
if (lifecycle.currentState == Lifecycle.State.DESTROYED) return {}
|
||||
|
||||
val lock = ScreensaverLock(lifecycle)
|
||||
lock.activate()
|
||||
return lock::cancel
|
||||
}
|
||||
|
||||
private inner class ScreensaverLock(private val lifecycle: Lifecycle) : LifecycleEventObserver {
|
||||
private var active: Boolean = false
|
||||
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
if (event == Lifecycle.Event.ON_DESTROY) cancel()
|
||||
}
|
||||
|
||||
fun activate() {
|
||||
if (active) return
|
||||
lifecycle.addObserver(this)
|
||||
locks++
|
||||
notifyInteraction(true)
|
||||
active = true
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
if (!active) return
|
||||
locks--
|
||||
lifecycle.removeObserver(this)
|
||||
notifyInteraction(false)
|
||||
active = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +1,118 @@
|
||||
package org.jellyfin.androidtv.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.compose.foundation.interaction.FocusInteraction
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.AbstractComposeView
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.databinding.ViewButtonServerBinding
|
||||
import org.jellyfin.androidtv.ui.base.CircularProgressIndicator
|
||||
import org.jellyfin.androidtv.ui.base.Icon
|
||||
import org.jellyfin.androidtv.ui.base.LocalTextStyle
|
||||
import org.jellyfin.androidtv.ui.base.ProvideTextStyle
|
||||
import org.jellyfin.androidtv.ui.base.Text
|
||||
import org.jellyfin.androidtv.ui.base.button.ButtonBase
|
||||
import org.jellyfin.androidtv.ui.base.button.ButtonDefaults
|
||||
import org.jellyfin.androidtv.util.MenuBuilder
|
||||
import org.jellyfin.androidtv.util.popupMenu
|
||||
import org.jellyfin.androidtv.util.showIfNotEmpty
|
||||
|
||||
@Composable
|
||||
fun ServerButton(
|
||||
icon: @Composable () -> Unit,
|
||||
name: @Composable () -> Unit,
|
||||
address: @Composable () -> Unit,
|
||||
version: @Composable () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
shape: Shape = ButtonDefaults.Shape,
|
||||
) {
|
||||
ButtonBase(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
interactionSource = interactionSource,
|
||||
shape = shape,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
icon()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
ProvideTextStyle(LocalTextStyle.current.copy(fontSize = 14.sp)) {
|
||||
name()
|
||||
}
|
||||
|
||||
ProvideTextStyle(LocalTextStyle.current.copy(fontSize = 12.sp)) {
|
||||
address()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.align(Alignment.Bottom)) {
|
||||
ProvideTextStyle(LocalTextStyle.current.copy(fontSize = 12.sp)) {
|
||||
version()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ServerButtonView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
defStyleRes: Int = 0,
|
||||
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
|
||||
val binding = ViewButtonServerBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
) : AbstractComposeView(context, attrs, defStyleAttr) {
|
||||
var name by mutableStateOf("")
|
||||
var address by mutableStateOf("")
|
||||
var version by mutableStateOf<String?>(null)
|
||||
var state by mutableStateOf(State.DEFAULT)
|
||||
private var focused by mutableStateOf(false)
|
||||
|
||||
init {
|
||||
isFocusable = true
|
||||
isClickable = true
|
||||
descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
|
||||
foreground = ResourcesCompat.getDrawable(resources, R.drawable.ripple, context.theme)
|
||||
descendantFocusability = FOCUS_BLOCK_DESCENDANTS
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) defaultFocusHighlightEnabled = false
|
||||
}
|
||||
|
||||
var name: String
|
||||
get() = binding.serverName.text.toString()
|
||||
set(value) {
|
||||
binding.serverName.text = value
|
||||
}
|
||||
|
||||
var address: String
|
||||
get() = binding.serverAddress.text.toString()
|
||||
set(value) {
|
||||
binding.serverAddress.text = value
|
||||
}
|
||||
|
||||
var version: String?
|
||||
get() = binding.serverVersion.text?.toString()
|
||||
set(value) {
|
||||
binding.serverVersion.text = value
|
||||
}
|
||||
|
||||
|
||||
private var _state: State = State.DEFAULT
|
||||
var state: State
|
||||
get() = _state
|
||||
set(value) {
|
||||
_state = value
|
||||
|
||||
when (_state) {
|
||||
State.DEFAULT -> {
|
||||
binding.iconDefault.isVisible = true
|
||||
binding.iconDefault.setImageResource(R.drawable.ic_house)
|
||||
binding.iconProgress.isVisible = false
|
||||
}
|
||||
State.EDIT -> {
|
||||
binding.iconDefault.isVisible = true
|
||||
binding.iconDefault.setImageResource(R.drawable.ic_house_edit)
|
||||
binding.iconProgress.isVisible = false
|
||||
}
|
||||
State.CONNECTING -> {
|
||||
binding.iconDefault.isInvisible = true
|
||||
binding.iconProgress.isVisible = true
|
||||
}
|
||||
State.ERROR -> {
|
||||
binding.iconDefault.isVisible = true
|
||||
binding.iconDefault.setImageResource(R.drawable.ic_error)
|
||||
binding.iconProgress.isVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setPopupMenu(init: MenuBuilder.() -> Unit) {
|
||||
setOnLongClickListener {
|
||||
popupMenu(context, binding.root, init = init).showIfNotEmpty()
|
||||
popupMenu(context, this, init = init).showIfNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +125,59 @@ class ServerButtonView @JvmOverloads constructor(
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
|
||||
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
|
||||
|
||||
focused = gainFocus
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
// Forward focus events to the interaction source
|
||||
val focusInteraction = remember { FocusInteraction.Focus() }
|
||||
LaunchedEffect(focused) {
|
||||
if (focused) interactionSource.emit(focusInteraction)
|
||||
else interactionSource.emit(FocusInteraction.Unfocus(focusInteraction))
|
||||
}
|
||||
|
||||
ServerButton(
|
||||
icon = {
|
||||
when (state) {
|
||||
State.DEFAULT -> Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_house),
|
||||
contentDescription = null,
|
||||
)
|
||||
|
||||
State.EDIT -> Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_house_edit),
|
||||
contentDescription = null,
|
||||
)
|
||||
|
||||
State.CONNECTING -> CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
State.ERROR -> Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_error),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
name = { Text(name) },
|
||||
address = { Text(address) },
|
||||
version = { Text(version.orEmpty()) },
|
||||
interactionSource = interactionSource,
|
||||
// Use the old shape as the Android View implementation is only used in legacy UI
|
||||
shape = RoundedCornerShape(3.dp),
|
||||
// New implementation doesn't end up at 51.dp by default so force the old size
|
||||
modifier = Modifier.height(51.dp),
|
||||
// We use our own click handler for the view
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
|
||||
enum class State {
|
||||
DEFAULT,
|
||||
EDIT,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.jellyfin.androidtv.ui.base
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
|
||||
@Composable
|
||||
fun CircularProgressIndicator(
|
||||
color: Color = LocalTextStyle.current.color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val infiniteTransition = rememberInfiniteTransition()
|
||||
val rotation by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 360f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(
|
||||
durationMillis = 1_500, // 1.5 seconds
|
||||
easing = LinearEasing,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.aspectRatio(1f)
|
||||
.rotate(rotation)
|
||||
) {
|
||||
drawArc(
|
||||
color = color,
|
||||
startAngle = 0f,
|
||||
sweepAngle = 270f,
|
||||
useCenter = false,
|
||||
style = Stroke(width = 4f, cap = StrokeCap.Round)
|
||||
)
|
||||
}
|
||||
}
|
||||
82
app/src/main/java/org/jellyfin/androidtv/ui/base/Icon.kt
Normal file
82
app/src/main/java/org/jellyfin/androidtv/ui/base/Icon.kt
Normal file
@@ -0,0 +1,82 @@
|
||||
package org.jellyfin.androidtv.ui.base
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.paint
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.toolingGraphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun Icon(
|
||||
imageVector: ImageVector,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = LocalTextStyle.current.color,
|
||||
) = Icon(
|
||||
painter = rememberVectorPainter(imageVector),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
tint = tint,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun Icon(
|
||||
bitmap: ImageBitmap,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = LocalTextStyle.current.color,
|
||||
) = Icon(
|
||||
painter = remember(bitmap) { BitmapPainter(bitmap) },
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
tint = tint,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun Icon(
|
||||
painter: Painter,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = LocalTextStyle.current.color,
|
||||
) {
|
||||
val colorFilter = remember(tint) { tint.takeUnless { it == Color.Unspecified }?.let(ColorFilter::tint) }
|
||||
val semantics = if (contentDescription != null) Modifier.semantics {
|
||||
this.contentDescription = contentDescription
|
||||
this.role = Role.Image
|
||||
} else Modifier
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.toolingGraphicsLayer()
|
||||
.defaultSizeFor(painter)
|
||||
.paint(painter, colorFilter = colorFilter, contentScale = ContentScale.Fit)
|
||||
.then(semantics)
|
||||
)
|
||||
}
|
||||
|
||||
private fun Modifier.defaultSizeFor(painter: Painter) = then(
|
||||
if (painter.intrinsicSize == Size.Unspecified || painter.intrinsicSize.isInfinite()) {
|
||||
Modifier.size(20.dp)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
|
||||
private fun Size.isInfinite() = width.isInfinite() && height.isInfinite()
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.jellyfin.androidtv.ui.base
|
||||
|
||||
import androidx.compose.foundation.LocalOverscrollFactory
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
|
||||
@Composable
|
||||
fun JellyfinTheme(
|
||||
colorScheme: ColorScheme = JellyfinTheme.colorScheme,
|
||||
shapes: Shapes = JellyfinTheme.shapes,
|
||||
typography: Typography = JellyfinTheme.typography,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalColorScheme provides colorScheme,
|
||||
LocalShapes provides shapes,
|
||||
LocalTypography provides typography,
|
||||
// Disable overscroll
|
||||
LocalOverscrollFactory provides null,
|
||||
) {
|
||||
ProvideTextStyle(value = typography.default, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
object JellyfinTheme {
|
||||
val colorScheme: ColorScheme
|
||||
@Composable @ReadOnlyComposable get() = LocalColorScheme.current
|
||||
|
||||
val typography: Typography
|
||||
@Composable @ReadOnlyComposable get() = LocalTypography.current
|
||||
|
||||
val shapes: Shapes
|
||||
@Composable @ReadOnlyComposable get() = LocalShapes.current
|
||||
}
|
||||
191
app/src/main/java/org/jellyfin/androidtv/ui/base/Seekbar.kt
Normal file
191
app/src/main/java/org/jellyfin/androidtv/ui/base/Seekbar.kt
Normal file
@@ -0,0 +1,191 @@
|
||||
package org.jellyfin.androidtv.ui.base
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.times
|
||||
|
||||
@Immutable
|
||||
data class SeekbarColors(
|
||||
val backgroundColor: Color,
|
||||
val bufferColor: Color,
|
||||
val progressColor: Color,
|
||||
val knobColor: Color,
|
||||
)
|
||||
|
||||
object SeekbarDefaults {
|
||||
@ReadOnlyComposable
|
||||
@Composable
|
||||
fun colors(
|
||||
backgroundColor: Color = Color(0xCC4B4B4B),
|
||||
bufferColor: Color = Color(0x40FFFFFF),
|
||||
progressColor: Color = Color(0xFF00A4DC),
|
||||
knobColor: Color = Color.White,
|
||||
) = SeekbarColors(
|
||||
backgroundColor = backgroundColor,
|
||||
bufferColor = bufferColor,
|
||||
progressColor = progressColor,
|
||||
knobColor = knobColor,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Seekbar(
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
progress: Duration = Duration.ZERO,
|
||||
buffer: Duration = Duration.ZERO,
|
||||
duration: Duration = Duration.ZERO,
|
||||
seekForwardAmount: Duration = duration / 100,
|
||||
seekRewindAmount: Duration = duration / 100,
|
||||
onScrubbing: ((scrubbing: Boolean) -> Unit)? = null,
|
||||
onSeek: ((progress: Duration) -> Unit)? = null,
|
||||
enabled: Boolean = true,
|
||||
colors: SeekbarColors = SeekbarDefaults.colors(),
|
||||
) {
|
||||
val durationMs = duration.inWholeMilliseconds.toFloat()
|
||||
val progressPercentage = progress.inWholeMilliseconds.toFloat() / durationMs
|
||||
val bufferPercentage = buffer.inWholeMilliseconds.toFloat() / durationMs
|
||||
val seekForwardPercentage = seekForwardAmount.inWholeMilliseconds.toFloat() / durationMs
|
||||
val seekRewindPercentage = seekRewindAmount.inWholeMilliseconds.toFloat() / durationMs
|
||||
|
||||
Seekbar(
|
||||
modifier = modifier,
|
||||
interactionSource = interactionSource,
|
||||
progress = progressPercentage,
|
||||
buffer = bufferPercentage,
|
||||
seekForwardAmount = seekForwardPercentage,
|
||||
seekRewindAmount = seekRewindPercentage,
|
||||
onScrubbing = onScrubbing,
|
||||
onSeek = if (onSeek == null) null else { progress -> onSeek(progress.toDouble() * duration) },
|
||||
enabled = enabled,
|
||||
colors = colors,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Seekbar(
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
progress: Float = 0f,
|
||||
buffer: Float = 0f,
|
||||
seekForwardAmount: Float = 0.01f,
|
||||
seekRewindAmount: Float = 0.01f,
|
||||
onScrubbing: ((scrubbing: Boolean) -> Unit)? = null,
|
||||
onSeek: ((progress: Float) -> Unit)? = null,
|
||||
enabled: Boolean = true,
|
||||
colors: SeekbarColors = SeekbarDefaults.colors(),
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
var progressOverride by remember { mutableStateOf<Float?>(null) }
|
||||
val visibleProgress = progressOverride ?: progress
|
||||
val knobAlpha by animateFloatAsState(if (focused) 1f else 0f)
|
||||
var scrubCancelJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.onKeyEvent {
|
||||
if (!enabled) return@onKeyEvent false
|
||||
|
||||
val isForward = it.key == Key.DirectionRight
|
||||
val isRewind = it.key == Key.DirectionLeft
|
||||
val isScrubbing = isForward || isRewind
|
||||
val isKeyUp = it.type == KeyEventType.KeyUp
|
||||
val isKeyDown = it.type == KeyEventType.KeyDown
|
||||
|
||||
val newProgress = when {
|
||||
isKeyDown && isForward -> (visibleProgress + seekForwardAmount).coerceAtMost(1f)
|
||||
isKeyDown && isRewind -> (visibleProgress - seekRewindAmount).coerceAtLeast(0f)
|
||||
else -> visibleProgress
|
||||
}
|
||||
|
||||
if (isScrubbing && isKeyDown && onScrubbing != null) {
|
||||
scrubCancelJob?.cancel()
|
||||
onScrubbing(true)
|
||||
}
|
||||
|
||||
if (visibleProgress != newProgress) {
|
||||
progressOverride = newProgress
|
||||
if (onSeek != null) onSeek(newProgress)
|
||||
}
|
||||
|
||||
if (isScrubbing && isKeyUp && onScrubbing != null) {
|
||||
scrubCancelJob?.cancel()
|
||||
scrubCancelJob = coroutineScope.launch {
|
||||
delay(300.milliseconds)
|
||||
onScrubbing(false)
|
||||
progressOverride = null
|
||||
}
|
||||
}
|
||||
|
||||
return@onKeyEvent isScrubbing
|
||||
}
|
||||
.focusable(interactionSource = interactionSource, enabled = enabled)
|
||||
.drawWithContent {
|
||||
val barCornerRadius = CornerRadius(size.minDimension, size.minDimension)
|
||||
|
||||
// Background bar
|
||||
drawRoundRect(
|
||||
color = colors.backgroundColor,
|
||||
cornerRadius = barCornerRadius,
|
||||
)
|
||||
|
||||
// Buffer bar
|
||||
if (buffer > 0f) {
|
||||
drawRoundRect(
|
||||
color = colors.bufferColor,
|
||||
size = size.copy(
|
||||
width = buffer * size.width,
|
||||
),
|
||||
cornerRadius = barCornerRadius,
|
||||
)
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
if (visibleProgress > 0f) {
|
||||
drawRoundRect(
|
||||
color = colors.progressColor,
|
||||
size = size.copy(
|
||||
width = visibleProgress * size.width,
|
||||
),
|
||||
cornerRadius = barCornerRadius,
|
||||
)
|
||||
}
|
||||
|
||||
// Progress knob
|
||||
drawCircle(
|
||||
color = colors.knobColor,
|
||||
alpha = knobAlpha,
|
||||
center = center.copy(
|
||||
x = visibleProgress * size.width,
|
||||
),
|
||||
radius = size.minDimension * 2,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
122
app/src/main/java/org/jellyfin/androidtv/ui/base/Text.kt
Normal file
122
app/src/main/java/org/jellyfin/androidtv/ui/base/Text.kt
Normal file
@@ -0,0 +1,122 @@
|
||||
package org.jellyfin.androidtv.ui.base
|
||||
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.structuralEqualityPolicy
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
|
||||
@Composable
|
||||
fun Text(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = Color.Unspecified,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
fontStyle: FontStyle? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontFamily: FontFamily? = null,
|
||||
letterSpacing: TextUnit = TextUnit.Unspecified,
|
||||
textDecoration: TextDecoration? = null,
|
||||
textAlign: TextAlign? = null,
|
||||
lineHeight: TextUnit = TextUnit.Unspecified,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
softWrap: Boolean = true,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
minLines: Int = 1,
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
style: TextStyle = LocalTextStyle.current
|
||||
) {
|
||||
val textColor = color.takeOrElse { style.color.takeOrElse { Color.Black } }
|
||||
|
||||
BasicText(
|
||||
text = text,
|
||||
modifier = modifier.graphicsLayer { this.compositingStrategy = CompositingStrategy.Offscreen },
|
||||
style = style.merge(
|
||||
color = textColor,
|
||||
fontSize = fontSize,
|
||||
fontWeight = fontWeight,
|
||||
textAlign = textAlign ?: TextAlign.Unspecified,
|
||||
lineHeight = lineHeight,
|
||||
fontFamily = fontFamily,
|
||||
textDecoration = textDecoration,
|
||||
fontStyle = fontStyle,
|
||||
letterSpacing = letterSpacing
|
||||
),
|
||||
onTextLayout = onTextLayout,
|
||||
overflow = overflow,
|
||||
softWrap = softWrap,
|
||||
maxLines = maxLines,
|
||||
minLines = minLines
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Text(
|
||||
text: AnnotatedString,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = Color.Unspecified,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
fontStyle: FontStyle? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontFamily: FontFamily? = null,
|
||||
letterSpacing: TextUnit = TextUnit.Unspecified,
|
||||
textDecoration: TextDecoration? = null,
|
||||
textAlign: TextAlign? = null,
|
||||
lineHeight: TextUnit = TextUnit.Unspecified,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
softWrap: Boolean = true,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
minLines: Int = 1,
|
||||
inlineContent: Map<String, InlineTextContent> = mapOf(),
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
style: TextStyle = LocalTextStyle.current
|
||||
) {
|
||||
val textColor = color.takeOrElse { style.color.takeOrElse { Color.Black } }
|
||||
|
||||
BasicText(
|
||||
text = text,
|
||||
modifier = modifier.graphicsLayer { this.compositingStrategy = CompositingStrategy.Offscreen },
|
||||
style =
|
||||
style.merge(
|
||||
color = textColor,
|
||||
fontSize = fontSize,
|
||||
fontWeight = fontWeight,
|
||||
textAlign = textAlign ?: TextAlign.Unspecified,
|
||||
lineHeight = lineHeight,
|
||||
fontFamily = fontFamily,
|
||||
textDecoration = textDecoration,
|
||||
fontStyle = fontStyle,
|
||||
letterSpacing = letterSpacing
|
||||
),
|
||||
onTextLayout = onTextLayout,
|
||||
overflow = overflow,
|
||||
softWrap = softWrap,
|
||||
maxLines = maxLines,
|
||||
minLines = minLines,
|
||||
inlineContent = inlineContent
|
||||
)
|
||||
}
|
||||
|
||||
val LocalTextStyle = compositionLocalOf(structuralEqualityPolicy()) { TypographyDefaults.Default }
|
||||
|
||||
@Composable
|
||||
fun ProvideTextStyle(value: TextStyle, content: @Composable () -> Unit) {
|
||||
val mergedStyle = LocalTextStyle.current.merge(value)
|
||||
CompositionLocalProvider(LocalTextStyle provides mergedStyle, content = content)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package org.jellyfin.androidtv.ui.base.button
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jellyfin.androidtv.ui.base.JellyfinTheme
|
||||
|
||||
object ButtonDefaults {
|
||||
val Shape: Shape = CircleShape
|
||||
val ContentPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 10.dp)
|
||||
|
||||
@ReadOnlyComposable
|
||||
@Composable
|
||||
fun colors(
|
||||
containerColor: Color = JellyfinTheme.colorScheme.button,
|
||||
contentColor: Color = JellyfinTheme.colorScheme.onButton,
|
||||
focusedContainerColor: Color = JellyfinTheme.colorScheme.buttonFocused,
|
||||
focusedContentColor: Color = JellyfinTheme.colorScheme.onButtonFocused,
|
||||
disabledContainerColor: Color = JellyfinTheme.colorScheme.buttonDisabled,
|
||||
disabledContentColor: Color = JellyfinTheme.colorScheme.onButtonDisabled,
|
||||
) = ButtonColors(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
focusedContainerColor = focusedContainerColor,
|
||||
focusedContentColor = focusedContentColor,
|
||||
disabledContainerColor = disabledContainerColor,
|
||||
disabledContentColor = disabledContentColor,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Button(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
enabled: Boolean = true,
|
||||
shape: Shape = ButtonDefaults.Shape,
|
||||
colors: ButtonColors = ButtonDefaults.colors(),
|
||||
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
content: @Composable RowScope.() -> Unit
|
||||
) {
|
||||
ButtonBase(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
onLongClick = onLongClick,
|
||||
enabled = enabled,
|
||||
shape = shape,
|
||||
colors = colors,
|
||||
interactionSource = interactionSource,
|
||||
) {
|
||||
ButtonRow(
|
||||
contentPadding = contentPadding,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProgressButton(
|
||||
progress: Float,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
enabled: Boolean = true,
|
||||
shape: Shape = ButtonDefaults.Shape,
|
||||
colors: ButtonColors = ButtonDefaults.colors(),
|
||||
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
content: @Composable RowScope.() -> Unit
|
||||
) {
|
||||
ProgressButtonBase(
|
||||
progress = progress,
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
onLongClick = onLongClick,
|
||||
enabled = enabled,
|
||||
shape = shape,
|
||||
colors = colors,
|
||||
interactionSource = interactionSource,
|
||||
) {
|
||||
ButtonRow(
|
||||
contentPadding = contentPadding,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ButtonRow(
|
||||
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) = Row(
|
||||
modifier = Modifier
|
||||
.defaultMinSize(
|
||||
minWidth = 58.dp,
|
||||
minHeight = 40.dp
|
||||
)
|
||||
.padding(contentPadding),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.jellyfin.androidtv.ui.base.button
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.jellyfin.androidtv.ui.base.JellyfinTheme
|
||||
import org.jellyfin.androidtv.ui.base.ProvideTextStyle
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ButtonBase(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
enabled: Boolean = true,
|
||||
shape: Shape = ButtonDefaults.Shape,
|
||||
colors: ButtonColors = ButtonDefaults.colors(),
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
content: @Composable BoxScope.() -> Unit
|
||||
) {
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val pressed by interactionSource.collectIsPressedAsState()
|
||||
|
||||
val colors = when {
|
||||
!enabled -> colors.disabledContainerColor to colors.disabledContentColor
|
||||
pressed -> colors.focusedContainerColor to colors.focusedContentColor
|
||||
focused -> colors.focusedContainerColor to colors.focusedContentColor
|
||||
else -> colors.containerColor to colors.contentColor
|
||||
}
|
||||
|
||||
ProvideTextStyle(value = JellyfinTheme.typography.default.copy(fontSize = 14.sp, color = colors.second)) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.combinedClickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
enabled = enabled,
|
||||
role = Role.Button,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
.background(colors.first, shape)
|
||||
.clip(shape),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.jellyfin.androidtv.ui.base.button
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@Immutable
|
||||
data class ButtonColors(
|
||||
val containerColor: Color,
|
||||
val contentColor: Color,
|
||||
val focusedContainerColor: Color,
|
||||
val focusedContentColor: Color,
|
||||
val disabledContainerColor: Color,
|
||||
val disabledContentColor: Color,
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.jellyfin.androidtv.ui.base.button
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jellyfin.androidtv.ui.base.JellyfinTheme
|
||||
|
||||
object IconButtonDefaults {
|
||||
val Shape: Shape = ButtonDefaults.Shape
|
||||
val ContentPadding: PaddingValues = PaddingValues(horizontal = 10.dp, vertical = 10.dp)
|
||||
|
||||
@ReadOnlyComposable
|
||||
@Composable
|
||||
fun colors(
|
||||
containerColor: Color = JellyfinTheme.colorScheme.button,
|
||||
contentColor: Color = JellyfinTheme.colorScheme.onButton,
|
||||
focusedContainerColor: Color = JellyfinTheme.colorScheme.buttonFocused,
|
||||
focusedContentColor: Color = JellyfinTheme.colorScheme.onButtonFocused,
|
||||
disabledContainerColor: Color = JellyfinTheme.colorScheme.buttonDisabled,
|
||||
disabledContentColor: Color = JellyfinTheme.colorScheme.onButtonDisabled,
|
||||
) = ButtonDefaults.colors(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
focusedContainerColor = focusedContainerColor,
|
||||
focusedContentColor = focusedContentColor,
|
||||
disabledContainerColor = disabledContainerColor,
|
||||
disabledContentColor = disabledContentColor,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
enabled: Boolean = true,
|
||||
shape: Shape = IconButtonDefaults.Shape,
|
||||
colors: ButtonColors = ButtonDefaults.colors(),
|
||||
contentPadding: PaddingValues = IconButtonDefaults.ContentPadding,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
content: @Composable BoxScope.() -> Unit
|
||||
) {
|
||||
ButtonBase(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
onLongClick = onLongClick,
|
||||
enabled = enabled,
|
||||
shape = shape,
|
||||
colors = colors,
|
||||
interactionSource = interactionSource,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(contentPadding),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.jellyfin.androidtv.ui.base.button
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import org.jellyfin.androidtv.R
|
||||
|
||||
@Composable
|
||||
fun ProgressButtonBase(
|
||||
progress: Float,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
enabled: Boolean = true,
|
||||
shape: Shape = ButtonDefaults.Shape,
|
||||
colors: ButtonColors = ButtonDefaults.colors(),
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
content: @Composable BoxScope.() -> Unit
|
||||
) {
|
||||
val progressColor = colorResource(R.color.button_default_progress_background)
|
||||
|
||||
ButtonBase(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
onLongClick = onLongClick,
|
||||
enabled = enabled,
|
||||
shape = shape,
|
||||
colors = colors,
|
||||
interactionSource = interactionSource,
|
||||
) {
|
||||
Box(Modifier.matchParentSize()) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(progress)
|
||||
.background(progressColor)
|
||||
)
|
||||
}
|
||||
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.jellyfin.androidtv.ui.base
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
fun colorScheme(): ColorScheme = ColorScheme(
|
||||
background = Color(0xFF101010),
|
||||
onBackground = Color(0xFFFFFFFF),
|
||||
button = Color(0xB3747474),
|
||||
onButton = Color(0xFFDDDDDD),
|
||||
buttonFocused = Color(0xE6CCCCCC),
|
||||
onButtonFocused = Color(0xFF444444),
|
||||
buttonDisabled = Color(0x33747474),
|
||||
onButtonDisabled = Color(0xFF686868),
|
||||
buttonActive = Color(0x4DCCCCCC),
|
||||
onButtonActive = Color(0xFFDDDDDD),
|
||||
input = Color(0xB3747474),
|
||||
onInput = Color(0xE6CCCCCC),
|
||||
inputFocused = Color(0xE6CCCCCC),
|
||||
onInputFocused = Color(0xFFDDDDDD),
|
||||
recording = Color(0xB3FF7474),
|
||||
onRecording = Color(0xFFDDDDDD),
|
||||
popover = Color(0xFF212225),
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class ColorScheme(
|
||||
val background: Color,
|
||||
val onBackground: Color,
|
||||
|
||||
val button: Color,
|
||||
val onButton: Color,
|
||||
val buttonFocused: Color,
|
||||
val onButtonFocused: Color,
|
||||
val buttonDisabled: Color,
|
||||
val onButtonDisabled: Color,
|
||||
val buttonActive: Color,
|
||||
val onButtonActive: Color,
|
||||
|
||||
val input: Color,
|
||||
val onInput: Color,
|
||||
val inputFocused: Color,
|
||||
val onInputFocused: Color,
|
||||
|
||||
val recording: Color,
|
||||
val onRecording: Color,
|
||||
|
||||
val popover: Color,
|
||||
)
|
||||
|
||||
val LocalColorScheme = staticCompositionLocalOf { colorScheme() }
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.jellyfin.androidtv.ui.base.modifier
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.layout.onPlaced
|
||||
|
||||
/**
|
||||
* Automatically focus this composable once visible. Normally this would be used for a **single** composable in a screen/popup.
|
||||
* Items that can disappear will regain focus when being made visible later.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.autoFocus(
|
||||
focusRequester: FocusRequester = remember { FocusRequester() }
|
||||
): Modifier = focusRequester(focusRequester)
|
||||
.onPlaced { focusRequester.requestFocus() }
|
||||
@@ -0,0 +1,99 @@
|
||||
package org.jellyfin.androidtv.ui.base.popover
|
||||
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import org.jellyfin.androidtv.ui.base.JellyfinTheme
|
||||
|
||||
object PopoverDefaults {
|
||||
val Shape: Shape = RoundedCornerShape(4.dp)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Popover(
|
||||
expanded: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
alignment: Alignment = Alignment.TopStart,
|
||||
offset: DpOffset = DpOffset.Zero,
|
||||
shape: Shape = PopoverDefaults.Shape,
|
||||
backgroundColor: Color = JellyfinTheme.colorScheme.popover,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val popupPositionProvider = remember(alignment, density, offset) {
|
||||
PopoverMenuPositionProvider(
|
||||
alignment = alignment,
|
||||
offset = IntOffset(
|
||||
x = with(density) { offset.x.roundToPx() },
|
||||
y = with(density) { offset.y.roundToPx() },
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val transition = updateTransition(expanded)
|
||||
val alpha by transition.animateFloat(
|
||||
targetValueByState = { expanded -> if (expanded) 1f else 0f }
|
||||
)
|
||||
|
||||
if (alpha != 0f) {
|
||||
Popup(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties = PopupProperties(
|
||||
focusable = true,
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true,
|
||||
),
|
||||
popupPositionProvider = popupPositionProvider
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.graphicsLayer(
|
||||
shape = shape,
|
||||
clip = true,
|
||||
shadowElevation = with(density) { 4.dp.toPx() },
|
||||
ambientShadowColor = Color.Black.copy(alpha = alpha),
|
||||
spotShadowColor = Color.Black.copy(alpha = alpha),
|
||||
)
|
||||
.graphicsLayer(
|
||||
shape = shape,
|
||||
clip = true,
|
||||
alpha = alpha,
|
||||
)
|
||||
.background(backgroundColor, shape)
|
||||
.wrapContentSize()
|
||||
.focusRequester(focusRequester)
|
||||
.focusGroup()
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(focusRequester) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.jellyfin.androidtv.ui.base.popover
|
||||
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.window.PopupPositionProvider
|
||||
|
||||
class PopoverMenuPositionProvider(
|
||||
val alignment: Alignment = Alignment.Companion.BottomCenter,
|
||||
val offset: IntOffset = IntOffset.Companion.Zero,
|
||||
) : PopupPositionProvider {
|
||||
private companion object {
|
||||
private const val EPSILON = 1e-3f
|
||||
|
||||
private const val OVERSCAN_X = 48
|
||||
private const val OVERSCAN_Y = 27
|
||||
private val PROBE_SPACE = IntSize(10_000, 10_000)
|
||||
}
|
||||
|
||||
override fun calculatePosition(
|
||||
anchorBounds: IntRect,
|
||||
windowSize: IntSize,
|
||||
layoutDirection: LayoutDirection,
|
||||
popupContentSize: IntSize
|
||||
): IntOffset {
|
||||
// Calculate alignment if placed inside the anchor
|
||||
val insidePosition = alignment.align(
|
||||
size = popupContentSize,
|
||||
space = anchorBounds.size,
|
||||
layoutDirection = layoutDirection,
|
||||
)
|
||||
|
||||
// Measure bias
|
||||
val biasPosition = alignment.align(
|
||||
size = IntSize.Companion.Zero,
|
||||
space = PROBE_SPACE,
|
||||
layoutDirection = layoutDirection,
|
||||
)
|
||||
val biasX = (2f * biasPosition.x / PROBE_SPACE.width) - 1f
|
||||
val biasY = (2f * biasPosition.y / PROBE_SPACE.height) - 1f
|
||||
|
||||
// Calculate position
|
||||
val x =
|
||||
(anchorBounds.left + insidePosition.x + biasX.dir * popupContentSize.width + offset.x)
|
||||
val y =
|
||||
(anchorBounds.top + insidePosition.y + biasY.dir * popupContentSize.height + offset.y)
|
||||
|
||||
// Return position clamped to fit in window with overscan
|
||||
return IntOffset(
|
||||
x = x.coerceIn(OVERSCAN_X, windowSize.width - popupContentSize.width - OVERSCAN_X),
|
||||
y = y.coerceIn(OVERSCAN_Y, windowSize.height - popupContentSize.height - OVERSCAN_Y),
|
||||
)
|
||||
}
|
||||
|
||||
private val Float.dir
|
||||
get() = when {
|
||||
this > EPSILON -> 1
|
||||
this < -EPSILON -> -1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
26
app/src/main/java/org/jellyfin/androidtv/ui/base/shapes.kt
Normal file
26
app/src/main/java/org/jellyfin/androidtv/ui/base/shapes.kt
Normal file
@@ -0,0 +1,26 @@
|
||||
package org.jellyfin.androidtv.ui.base
|
||||
|
||||
import androidx.compose.foundation.shape.CornerBasedShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
object ShapeDefaults {
|
||||
val ExtraSmall: CornerBasedShape = RoundedCornerShape(4.0.dp)
|
||||
val Small: CornerBasedShape = RoundedCornerShape(8.0.dp)
|
||||
val Medium: CornerBasedShape = RoundedCornerShape(12.0.dp)
|
||||
val Large: CornerBasedShape = RoundedCornerShape(16.0.dp)
|
||||
val ExtraLarge: CornerBasedShape = RoundedCornerShape(28.0.dp)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class Shapes(
|
||||
val extraSmall: CornerBasedShape = ShapeDefaults.ExtraSmall,
|
||||
val small: CornerBasedShape = ShapeDefaults.Small,
|
||||
val medium: CornerBasedShape = ShapeDefaults.Medium,
|
||||
val large: CornerBasedShape = ShapeDefaults.Large,
|
||||
val extraLarge: CornerBasedShape = ShapeDefaults.ExtraLarge,
|
||||
)
|
||||
|
||||
val LocalShapes = staticCompositionLocalOf { Shapes() }
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.jellyfin.androidtv.ui.base
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
|
||||
object TypographyDefaults {
|
||||
val Default: TextStyle = TextStyle.Default
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class Typography(
|
||||
val default: TextStyle = TypographyDefaults.Default,
|
||||
)
|
||||
|
||||
val LocalTypography = staticCompositionLocalOf { Typography() }
|
||||
@@ -50,7 +50,7 @@ import org.jellyfin.androidtv.ui.itemhandling.BaseRowItem;
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher;
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter;
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapterHelperKt;
|
||||
import org.jellyfin.androidtv.ui.navigation.Destinations;
|
||||
import org.jellyfin.androidtv.ui.navigation.ActivityDestinations;
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepository;
|
||||
import org.jellyfin.androidtv.ui.presentation.CardPresenter;
|
||||
import org.jellyfin.androidtv.ui.presentation.HorizontalGridPresenter;
|
||||
@@ -59,7 +59,7 @@ import org.jellyfin.androidtv.util.ImageHelper;
|
||||
import org.jellyfin.androidtv.util.InfoLayoutHelper;
|
||||
import org.jellyfin.androidtv.util.KeyProcessor;
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
import org.jellyfin.androidtv.util.apiclient.EmptyLifecycleAwareResponse;
|
||||
import org.jellyfin.androidtv.util.apiclient.EmptyResponse;
|
||||
import org.jellyfin.sdk.api.client.ApiClient;
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto;
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind;
|
||||
@@ -659,11 +659,9 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
filters.setFavoriteOnly(libraryPreferences.get(LibraryPreferences.Companion.getFilterFavoritesOnly()));
|
||||
filters.setUnwatchedOnly(libraryPreferences.get(LibraryPreferences.Companion.getFilterUnwatchedOnly()));
|
||||
|
||||
mAdapter.setRetrieveFinishedListener(new EmptyLifecycleAwareResponse(getLifecycle()) {
|
||||
mAdapter.setRetrieveFinishedListener(new EmptyResponse() {
|
||||
@Override
|
||||
public void onResponse() {
|
||||
if (!getActive()) return;
|
||||
|
||||
setStatusText(mFolder.getName());
|
||||
if (mCurrentItem == null) { // don't mess-up pos via loadMoreItemsIfNeeded
|
||||
setItem(null);
|
||||
@@ -816,7 +814,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
boolean allowViewSelection = userViewsRepository.getValue().allowViewSelection(mFolder.getCollectionType());
|
||||
navigationRepository.getValue().navigate(Destinations.INSTANCE.displayPreferences(mFolder.getDisplayPreferencesId(), allowViewSelection));
|
||||
startActivity(ActivityDestinations.INSTANCE.displayPreferences(getContext(), mFolder.getDisplayPreferencesId(), allowViewSelection));
|
||||
}
|
||||
});
|
||||
mSettingsButton.setContentDescription(getString(R.string.lbl_settings));
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package org.jellyfin.androidtv.ui.browsing
|
||||
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.data.repository.ItemRepository
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.LocationType
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.api.TimerInfoDto
|
||||
@@ -23,15 +25,13 @@ fun EnhancedBrowseFragment.getLiveTvRecordingsAndTimers(
|
||||
|
||||
lifecycleScope.launch {
|
||||
runCatching {
|
||||
val recordings by api.liveTvApi.getRecordings(
|
||||
fields = setOf(
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
enableImages = true,
|
||||
limit = 40,
|
||||
)
|
||||
val recordings = withContext(Dispatchers.IO) {
|
||||
api.liveTvApi.getRecordings(
|
||||
fields = ItemRepository.itemFields,
|
||||
enableImages = true,
|
||||
limit = 40,
|
||||
).content
|
||||
}
|
||||
|
||||
val timers by api.liveTvApi.getTimers()
|
||||
|
||||
@@ -51,7 +51,9 @@ fun EnhancedBrowseFragment.getLiveTvTimers(
|
||||
|
||||
lifecycleScope.launch {
|
||||
runCatching {
|
||||
api.liveTvApi.getTimers().content
|
||||
withContext(Dispatchers.IO) {
|
||||
api.liveTvApi.getTimers().content
|
||||
}
|
||||
}.fold(
|
||||
onSuccess = { timers -> callback(timers) },
|
||||
onFailure = { exception -> errorCallback(exception) }
|
||||
|
||||
@@ -2,14 +2,16 @@ package org.jellyfin.androidtv.ui.browsing
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.data.repository.ItemRepository
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.ItemFilter
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
@@ -36,7 +38,7 @@ object BrowsingUtils {
|
||||
type: BaseItemKind,
|
||||
callback: (item: BaseItemDto?) -> Unit
|
||||
) {
|
||||
lifecycle.lifecycleScope.launch {
|
||||
lifecycle.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val result by api.itemsApi.getItems(
|
||||
parentId = library.id,
|
||||
@@ -46,10 +48,15 @@ object BrowsingUtils {
|
||||
limit = 1,
|
||||
)
|
||||
|
||||
callback(result.items?.firstOrNull())
|
||||
withContext(Dispatchers.Main) {
|
||||
callback(result.items.firstOrNull())
|
||||
}
|
||||
} catch (error: ApiClientException) {
|
||||
Timber.w(error, "Failed to retrieve random item")
|
||||
callback(null)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
callback(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,22 +66,13 @@ object BrowsingUtils {
|
||||
limit = 50,
|
||||
parentId = parentId,
|
||||
imageTypeLimit = 1,
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.CHILD_COUNT,
|
||||
ItemFields.MEDIA_SOURCES,
|
||||
ItemFields.MEDIA_STREAMS,
|
||||
)
|
||||
fields = ItemRepository.itemFields
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun createSeriesGetNextUpRequest(parentId: UUID) = GetNextUpRequest(
|
||||
seriesId = parentId,
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.CHILD_COUNT,
|
||||
)
|
||||
fields = ItemRepository.itemFields
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
@@ -84,13 +82,7 @@ object BrowsingUtils {
|
||||
itemType: BaseItemKind? = null,
|
||||
groupItems: Boolean? = null
|
||||
) = GetLatestMediaRequest(
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.CHILD_COUNT,
|
||||
ItemFields.MEDIA_SOURCES,
|
||||
ItemFields.MEDIA_STREAMS,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
parentId = parentId,
|
||||
limit = 50,
|
||||
imageTypeLimit = 1,
|
||||
@@ -101,42 +93,26 @@ object BrowsingUtils {
|
||||
@JvmStatic
|
||||
fun createSeasonsRequest(seriesId: UUID) = GetSeasonsRequest(
|
||||
seriesId = seriesId,
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.DISPLAY_PREFERENCES_ID,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun createUpcomingEpisodesRequest(parentId: UUID) = GetUpcomingEpisodesRequest(
|
||||
parentId = parentId,
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun createSimilarItemsRequest(itemId: UUID) = GetSimilarItemsRequest(
|
||||
itemId = itemId,
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.DISPLAY_PREFERENCES_ID,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
limit = 20,
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun createLiveTVOnNowRequest() = GetRecommendedProgramsRequest(
|
||||
isAiring = true,
|
||||
fields = setOf(
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.CHANNEL_INFO,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
imageTypeLimit = 1,
|
||||
enableTotalRecordCount = false,
|
||||
limit = 150,
|
||||
@@ -146,12 +122,7 @@ object BrowsingUtils {
|
||||
fun createLiveTVUpcomingRequest() = GetRecommendedProgramsRequest(
|
||||
isAiring = false,
|
||||
hasAired = false,
|
||||
fields = setOf(
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.CHANNEL_INFO,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
imageTypeLimit = 1,
|
||||
enableTotalRecordCount = false,
|
||||
limit = 150,
|
||||
@@ -160,22 +131,14 @@ object BrowsingUtils {
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun createLiveTVRecordingsRequest(limit: Int? = null) = GetRecordingsRequest(
|
||||
fields = setOf(
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
enableImages = true,
|
||||
limit = limit,
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun createLiveTVMovieRecordingsRequest() = GetRecordingsRequest(
|
||||
fields = setOf(
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
enableImages = true,
|
||||
limit = 60,
|
||||
isMovie = true,
|
||||
@@ -183,11 +146,7 @@ object BrowsingUtils {
|
||||
|
||||
@JvmStatic
|
||||
fun createLiveTVSeriesRecordingsRequest() = GetRecordingsRequest(
|
||||
fields = setOf(
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
enableImages = true,
|
||||
limit = 60,
|
||||
isSeries = true,
|
||||
@@ -195,11 +154,7 @@ object BrowsingUtils {
|
||||
|
||||
@JvmStatic
|
||||
fun createLiveTVSportsRecordingsRequest() = GetRecordingsRequest(
|
||||
fields = setOf(
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
enableImages = true,
|
||||
limit = 60,
|
||||
isSports = true,
|
||||
@@ -207,11 +162,7 @@ object BrowsingUtils {
|
||||
|
||||
@JvmStatic
|
||||
fun createLiveTVKidsRecordingsRequest() = GetRecordingsRequest(
|
||||
fields = setOf(
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
enableImages = true,
|
||||
limit = 60,
|
||||
isKids = true,
|
||||
@@ -224,31 +175,19 @@ object BrowsingUtils {
|
||||
|
||||
@JvmStatic
|
||||
fun createAlbumArtistsRequest(parentId: UUID) = GetAlbumArtistsRequest(
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.ITEM_COUNTS,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
parentId = parentId,
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun createArtistsRequest(parentId: UUID) = GetArtistsRequest(
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.ITEM_COUNTS,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
parentId = parentId,
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun createPersonItemsRequest(personId: UUID, itemType: BaseItemKind) = GetItemsRequest(
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.DISPLAY_PREFERENCES_ID,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
personIds = setOf(personId),
|
||||
recursive = true,
|
||||
includeItemTypes = setOf(itemType),
|
||||
@@ -257,11 +196,7 @@ object BrowsingUtils {
|
||||
|
||||
@JvmStatic
|
||||
fun createArtistItemsRequest(artistId: UUID, itemType: BaseItemKind) = GetItemsRequest(
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.DISPLAY_PREFERENCES_ID,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
artistIds = setOf(artistId),
|
||||
recursive = true,
|
||||
includeItemTypes = setOf(itemType),
|
||||
@@ -270,13 +205,7 @@ object BrowsingUtils {
|
||||
|
||||
@JvmStatic
|
||||
fun createNextEpisodesRequest(seasonId: UUID, indexNumber: Int) = GetItemsRequest(
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.ITEM_COUNTS,
|
||||
ItemFields.DISPLAY_PREFERENCES_ID,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
parentId = seasonId,
|
||||
includeItemTypes = setOf(BaseItemKind.EPISODE),
|
||||
startIndex = indexNumber,
|
||||
@@ -285,15 +214,7 @@ object BrowsingUtils {
|
||||
|
||||
@JvmStatic
|
||||
fun createResumeItemsRequest(parentId: UUID, itemType: BaseItemKind) = GetItemsRequest(
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.ITEM_COUNTS,
|
||||
ItemFields.DISPLAY_PREFERENCES_ID,
|
||||
ItemFields.CHILD_COUNT,
|
||||
ItemFields.MEDIA_STREAMS,
|
||||
ItemFields.MEDIA_SOURCES,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
includeItemTypes = setOf(itemType),
|
||||
recursive = true,
|
||||
parentId = parentId,
|
||||
@@ -308,15 +229,7 @@ object BrowsingUtils {
|
||||
|
||||
@JvmStatic
|
||||
fun createFavoriteItemsRequest(parentId: UUID, itemType: BaseItemKind) = GetItemsRequest(
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.ITEM_COUNTS,
|
||||
ItemFields.DISPLAY_PREFERENCES_ID,
|
||||
ItemFields.CHILD_COUNT,
|
||||
ItemFields.MEDIA_STREAMS,
|
||||
ItemFields.MEDIA_SOURCES,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
includeItemTypes = setOf(itemType),
|
||||
recursive = true,
|
||||
parentId = parentId,
|
||||
@@ -327,7 +240,7 @@ object BrowsingUtils {
|
||||
|
||||
@JvmStatic
|
||||
fun createCollectionsRequest(parentId: UUID) = GetItemsRequest(
|
||||
fields = setOf(ItemFields.CHILD_COUNT),
|
||||
fields = ItemRepository.itemFields,
|
||||
includeItemTypes = setOf(BaseItemKind.BOX_SET),
|
||||
recursive = true,
|
||||
imageTypeLimit = 1,
|
||||
@@ -337,12 +250,7 @@ object BrowsingUtils {
|
||||
|
||||
@JvmStatic
|
||||
fun createPremieresRequest(parentId: UUID) = GetItemsRequest(
|
||||
fields = setOf(
|
||||
ItemFields.DATE_CREATED,
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
includeItemTypes = setOf(BaseItemKind.EPISODE),
|
||||
parentId = parentId,
|
||||
indexNumber = 1,
|
||||
@@ -358,13 +266,7 @@ object BrowsingUtils {
|
||||
|
||||
@JvmStatic
|
||||
fun createLastPlayedRequest(parentId: UUID) = GetItemsRequest(
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.ITEM_COUNTS,
|
||||
ItemFields.DISPLAY_PREFERENCES_ID,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
includeItemTypes = setOf(BaseItemKind.AUDIO),
|
||||
recursive = true,
|
||||
parentId = parentId,
|
||||
@@ -378,11 +280,7 @@ object BrowsingUtils {
|
||||
|
||||
@JvmStatic
|
||||
fun createPlaylistsRequest() = GetItemsRequest(
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.CUMULATIVE_RUN_TIME_TICKS,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
includeItemTypes = setOf(BaseItemKind.PLAYLIST),
|
||||
imageTypeLimit = 1,
|
||||
recursive = true,
|
||||
@@ -393,13 +291,7 @@ object BrowsingUtils {
|
||||
@JvmStatic
|
||||
fun createBrowseGridItemsRequest(parent: BaseItemDto): GetItemsRequest {
|
||||
val baseRequest = GetItemsRequest(
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.CHILD_COUNT,
|
||||
ItemFields.MEDIA_SOURCES,
|
||||
ItemFields.MEDIA_STREAMS,
|
||||
ItemFields.DISPLAY_PREFERENCES_ID,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
parentId = parent.id,
|
||||
)
|
||||
|
||||
@@ -415,12 +307,6 @@ object BrowsingUtils {
|
||||
recursive = true,
|
||||
)
|
||||
|
||||
CollectionType.BOXSETS -> baseRequest.copy(
|
||||
includeItemTypes = setOf(BaseItemKind.BOX_SET),
|
||||
parentId = null,
|
||||
recursive = true,
|
||||
)
|
||||
|
||||
CollectionType.MUSIC -> baseRequest.copy(
|
||||
includeItemTypes = setOf(BaseItemKind.MUSIC_ALBUM),
|
||||
recursive = true,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package org.jellyfin.androidtv.ui.browsing
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.data.repository.ItemRepository
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.genresApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.koin.android.ext.android.inject
|
||||
@@ -16,10 +18,12 @@ class ByGenreFragment : BrowseFolderFragment() {
|
||||
if (childCount <= 0) return
|
||||
|
||||
// Get all genres for this folder
|
||||
val genresResponse by apiClient.genresApi.getGenres(
|
||||
parentId = folder?.id,
|
||||
sortBy = setOf(ItemSortBy.SORT_NAME),
|
||||
)
|
||||
val genresResponse = withContext(Dispatchers.IO) {
|
||||
apiClient.genresApi.getGenres(
|
||||
parentId = folder?.id,
|
||||
sortBy = setOf(ItemSortBy.SORT_NAME),
|
||||
).content
|
||||
}
|
||||
|
||||
for (genre in genresResponse.items) {
|
||||
val itemsRequest = GetItemsRequest(
|
||||
@@ -28,13 +32,7 @@ class ByGenreFragment : BrowseFolderFragment() {
|
||||
includeItemTypes = includeType?.let(BaseItemKind::fromNameOrNull)?.let(::setOf),
|
||||
genres = setOf(genre.name.orEmpty()),
|
||||
recursive = true,
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.ITEM_COUNTS,
|
||||
ItemFields.DISPLAY_PREFERENCES_ID,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
)
|
||||
rows.add(BrowseRowDef(genre.name, itemsRequest, 40))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package org.jellyfin.androidtv.ui.browsing
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.data.repository.ItemRepository
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
|
||||
@@ -20,13 +20,7 @@ class ByLetterFragment : BrowseFolderFragment() {
|
||||
includeItemTypes = includeType?.let(BaseItemKind::fromNameOrNull)?.let(::setOf),
|
||||
nameLessThan = letters.substring(0, 1),
|
||||
recursive = true,
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.ITEM_COUNTS,
|
||||
ItemFields.DISPLAY_PREFERENCES_ID,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
)
|
||||
|
||||
rows.add(BrowseRowDef("#", numbersItemsRequest, 40))
|
||||
@@ -39,13 +33,7 @@ class ByLetterFragment : BrowseFolderFragment() {
|
||||
includeItemTypes = includeType?.let(BaseItemKind::fromNameOrNull)?.let(::setOf),
|
||||
nameStartsWith = letter.toString(),
|
||||
recursive = true,
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.ITEM_COUNTS,
|
||||
ItemFields.DISPLAY_PREFERENCES_ID,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
)
|
||||
|
||||
rows.add(BrowseRowDef(letter.toString(), letterItemsRequest, 40))
|
||||
|
||||
@@ -1,40 +1,28 @@
|
||||
package org.jellyfin.androidtv.ui.browsing
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.data.repository.ItemRepository
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
|
||||
class CollectionFragment : EnhancedBrowseFragment() {
|
||||
companion object {
|
||||
private val itemFields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.ITEM_COUNTS,
|
||||
ItemFields.DISPLAY_PREFERENCES_ID,
|
||||
ItemFields.CHILD_COUNT,
|
||||
ItemFields.MEDIA_STREAMS,
|
||||
ItemFields.MEDIA_SOURCES,
|
||||
)
|
||||
}
|
||||
|
||||
override fun setupQueries(rowLoader: RowLoader) {
|
||||
val movies = GetItemsRequest(
|
||||
fields = itemFields,
|
||||
fields = ItemRepository.itemFields,
|
||||
parentId = mFolder.id,
|
||||
includeItemTypes = setOf(BaseItemKind.MOVIE),
|
||||
)
|
||||
mRows.add(BrowseRowDef(getString(R.string.lbl_movies), movies, 100))
|
||||
|
||||
val series = GetItemsRequest(
|
||||
fields = itemFields,
|
||||
fields = ItemRepository.itemFields,
|
||||
parentId = mFolder.id,
|
||||
includeItemTypes = setOf(BaseItemKind.SERIES),
|
||||
)
|
||||
mRows.add(BrowseRowDef(getString(R.string.lbl_tv_series), series, 100))
|
||||
|
||||
val others = GetItemsRequest(
|
||||
fields = itemFields,
|
||||
fields = ItemRepository.itemFields,
|
||||
parentId = mFolder.id,
|
||||
excludeItemTypes = setOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.FragmentTransaction
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationAction
|
||||
import timber.log.Timber
|
||||
import java.util.Stack
|
||||
|
||||
private class HistoryEntry(
|
||||
@@ -151,12 +152,16 @@ class DestinationFragmentView @JvmOverloads constructor(
|
||||
|
||||
// Attach or add next fragment
|
||||
if (fragment.isDetached) attach(fragment)
|
||||
else add(container.id, fragment, FRAGMENT_TAG_CONTENT)
|
||||
else replace(container.id, fragment, FRAGMENT_TAG_CONTENT)
|
||||
}
|
||||
|
||||
// Commit
|
||||
if (fragmentManager.isStateSaved) transaction.commitNowAllowingStateLoss()
|
||||
else transaction.commitNow()
|
||||
if (fragmentManager.isDestroyed) {
|
||||
Timber.w("FragmentManager is already destroyed")
|
||||
} else if (fragmentManager.isStateSaved) {
|
||||
transaction.commitAllowingStateLoss()
|
||||
} else {
|
||||
transaction.commit()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable {
|
||||
|
||||
@@ -2,8 +2,8 @@ package org.jellyfin.androidtv.ui.browsing
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.data.querying.GetSpecialsRequest
|
||||
import org.jellyfin.androidtv.data.repository.ItemRepository
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.ItemFilter
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
@@ -17,14 +17,6 @@ class GenericFolderFragment : EnhancedBrowseFragment() {
|
||||
BaseItemKind.USER_VIEW,
|
||||
BaseItemKind.CHANNEL_FOLDER_ITEM,
|
||||
)
|
||||
|
||||
private val itemFields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.ITEM_COUNTS,
|
||||
ItemFields.DISPLAY_PREFERENCES_ID,
|
||||
ItemFields.CHILD_COUNT,
|
||||
)
|
||||
}
|
||||
|
||||
override fun setupQueries(rowLoader: RowLoader) {
|
||||
@@ -39,7 +31,7 @@ class GenericFolderFragment : EnhancedBrowseFragment() {
|
||||
if (showSpecialViewTypes.contains(mFolder.type)) {
|
||||
if (mFolder.type != BaseItemKind.CHANNEL_FOLDER_ITEM) {
|
||||
val resume = GetItemsRequest(
|
||||
fields = itemFields,
|
||||
fields = ItemRepository.itemFields,
|
||||
parentId = mFolder.id,
|
||||
limit = 50,
|
||||
filters = setOf(ItemFilter.IS_RESUMABLE),
|
||||
@@ -50,7 +42,7 @@ class GenericFolderFragment : EnhancedBrowseFragment() {
|
||||
}
|
||||
|
||||
val latest = GetItemsRequest(
|
||||
fields = itemFields,
|
||||
fields = ItemRepository.itemFields,
|
||||
parentId = mFolder.id,
|
||||
limit = 50,
|
||||
filters = setOf(ItemFilter.IS_UNPLAYED),
|
||||
@@ -61,7 +53,7 @@ class GenericFolderFragment : EnhancedBrowseFragment() {
|
||||
}
|
||||
|
||||
val byName = GetItemsRequest(
|
||||
fields = itemFields,
|
||||
fields = ItemRepository.itemFields,
|
||||
parentId = mFolder.id,
|
||||
)
|
||||
val header = when (mFolder.type) {
|
||||
|
||||
@@ -12,13 +12,17 @@ import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.flowWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.auth.repository.SessionRepository
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
import org.jellyfin.androidtv.databinding.ActivityMainBinding
|
||||
import org.jellyfin.androidtv.ui.ScreensaverViewModel
|
||||
import org.jellyfin.androidtv.integration.LeanbackChannelWorker
|
||||
import org.jellyfin.androidtv.ui.InteractionTrackerViewModel
|
||||
import org.jellyfin.androidtv.ui.background.AppBackground
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationAction
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
|
||||
@@ -34,7 +38,8 @@ class MainActivity : FragmentActivity() {
|
||||
private val navigationRepository by inject<NavigationRepository>()
|
||||
private val sessionRepository by inject<SessionRepository>()
|
||||
private val userRepository by inject<UserRepository>()
|
||||
private val screensaverViewModel by viewModel<ScreensaverViewModel>()
|
||||
private val interactionTrackerViewModel by viewModel<InteractionTrackerViewModel>()
|
||||
private val workManager by inject<WorkManager>()
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
|
||||
@@ -51,7 +56,7 @@ class MainActivity : FragmentActivity() {
|
||||
|
||||
if (!validateAuthentication()) return
|
||||
|
||||
screensaverViewModel.keepScreenOn.flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
|
||||
interactionTrackerViewModel.keepScreenOn.flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
|
||||
.onEach { keepScreenOn ->
|
||||
if (keepScreenOn) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
@@ -65,7 +70,7 @@ class MainActivity : FragmentActivity() {
|
||||
.onEach { action ->
|
||||
handleNavigationAction(action)
|
||||
backPressedCallback.isEnabled = navigationRepository.canGoBack
|
||||
screensaverViewModel.notifyInteraction(false)
|
||||
interactionTrackerViewModel.notifyInteraction(canCancel = false, userInitiated = false)
|
||||
}.launchIn(lifecycleScope)
|
||||
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
@@ -81,7 +86,7 @@ class MainActivity : FragmentActivity() {
|
||||
|
||||
applyTheme()
|
||||
|
||||
screensaverViewModel.activityPaused = false
|
||||
interactionTrackerViewModel.activityPaused = false
|
||||
}
|
||||
|
||||
private fun validateAuthentication(): Boolean {
|
||||
@@ -98,35 +103,27 @@ class MainActivity : FragmentActivity() {
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
screensaverViewModel.activityPaused = true
|
||||
interactionTrackerViewModel.activityPaused = true
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
|
||||
lifecycleScope.launch {
|
||||
workManager.enqueue(OneTimeWorkRequestBuilder<LeanbackChannelWorker>().build())
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
Timber.d("MainActivity stopped")
|
||||
sessionRepository.restoreSession(destroyOnly = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNavigationAction(action: NavigationAction) {
|
||||
screensaverViewModel.notifyInteraction(true)
|
||||
interactionTrackerViewModel.notifyInteraction(canCancel = true, userInitiated = false)
|
||||
|
||||
when (action) {
|
||||
// DestinationFragmentView actions
|
||||
is NavigationAction.NavigateFragment -> binding.contentView.navigate(action)
|
||||
NavigationAction.GoBack -> binding.contentView.goBack()
|
||||
|
||||
// Others
|
||||
is NavigationAction.NavigateActivity -> {
|
||||
val destination = action.destination
|
||||
val intent = Intent(this@MainActivity, destination.activity.java)
|
||||
intent.putExtras(destination.extras)
|
||||
startActivity(intent)
|
||||
action.onOpened()
|
||||
}
|
||||
|
||||
NavigationAction.Nothing -> Unit
|
||||
}
|
||||
}
|
||||
@@ -140,8 +137,8 @@ class MainActivity : FragmentActivity() {
|
||||
|
||||
private fun onKeyEvent(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
// Ignore the key event that closes the screensaver
|
||||
if (screensaverViewModel.visible.value) {
|
||||
screensaverViewModel.notifyInteraction(canCancel = event?.action == KeyEvent.ACTION_UP)
|
||||
if (interactionTrackerViewModel.visible.value) {
|
||||
interactionTrackerViewModel.notifyInteraction(canCancel = event?.action == KeyEvent.ACTION_UP, userInitiated = true)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -161,14 +158,14 @@ class MainActivity : FragmentActivity() {
|
||||
override fun onUserInteraction() {
|
||||
super.onUserInteraction()
|
||||
|
||||
screensaverViewModel.notifyInteraction(false)
|
||||
interactionTrackerViewModel.notifyInteraction(false, userInitiated = true)
|
||||
}
|
||||
|
||||
@Suppress("RestrictedApi") // False positive
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
// Ignore the key event that closes the screensaver
|
||||
if (!event.isMediaSessionKeyEvent() && screensaverViewModel.visible.value) {
|
||||
screensaverViewModel.notifyInteraction(canCancel = event.action == KeyEvent.ACTION_UP)
|
||||
if (!event.isMediaSessionKeyEvent() && interactionTrackerViewModel.visible.value) {
|
||||
interactionTrackerViewModel.notifyInteraction(canCancel = event.action == KeyEvent.ACTION_UP, userInitiated = true)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -179,8 +176,8 @@ class MainActivity : FragmentActivity() {
|
||||
@Suppress("RestrictedApi") // False positive
|
||||
override fun dispatchKeyShortcutEvent(event: KeyEvent): Boolean {
|
||||
// Ignore the key event that closes the screensaver
|
||||
if (!event.isMediaSessionKeyEvent() && screensaverViewModel.visible.value) {
|
||||
screensaverViewModel.notifyInteraction(canCancel = event.action == KeyEvent.ACTION_UP)
|
||||
if (!event.isMediaSessionKeyEvent() && interactionTrackerViewModel.visible.value) {
|
||||
interactionTrackerViewModel.notifyInteraction(canCancel = event.action == KeyEvent.ACTION_UP, userInitiated = true)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -190,8 +187,8 @@ class MainActivity : FragmentActivity() {
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
|
||||
// Ignore the touch event that closes the screensaver
|
||||
if (screensaverViewModel.visible.value) {
|
||||
screensaverViewModel.notifyInteraction(true)
|
||||
if (interactionTrackerViewModel.visible.value) {
|
||||
interactionTrackerViewModel.notifyInteraction(canCancel = true, userInitiated = true)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package org.jellyfin.androidtv.ui.browsing
|
||||
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.constant.QueryType
|
||||
import org.jellyfin.androidtv.data.repository.ItemRepository
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
|
||||
@@ -22,25 +24,21 @@ class SuggestedMoviesFragment : EnhancedBrowseFragment() {
|
||||
|
||||
override fun setupQueries(rowLoader: RowLoader) {
|
||||
lifecycleScope.launch {
|
||||
val response by api.itemsApi.getItems(
|
||||
parentId = mFolder.id,
|
||||
includeItemTypes = setOf(BaseItemKind.MOVIE),
|
||||
sortOrder = setOf(SortOrder.DESCENDING),
|
||||
sortBy = setOf(ItemSortBy.DATE_PLAYED),
|
||||
limit = 8,
|
||||
recursive = true,
|
||||
)
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
api.itemsApi.getItems(
|
||||
parentId = mFolder.id,
|
||||
includeItemTypes = setOf(BaseItemKind.MOVIE),
|
||||
sortOrder = setOf(SortOrder.DESCENDING),
|
||||
sortBy = setOf(ItemSortBy.DATE_PLAYED),
|
||||
limit = 8,
|
||||
recursive = true,
|
||||
).content
|
||||
}
|
||||
|
||||
for (item in response.items) {
|
||||
val similar = GetSimilarItemsRequest(
|
||||
itemId = item.id,
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.CHILD_COUNT,
|
||||
ItemFields.MEDIA_STREAMS,
|
||||
ItemFields.MEDIA_SOURCES
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
limit = 7,
|
||||
)
|
||||
mRows.add(BrowseRowDef(getString(R.string.because_you_watched, item.name), similar, QueryType.SimilarMovies))
|
||||
|
||||
@@ -8,17 +8,18 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.AbstractComposeView
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.preference.constant.RatingType
|
||||
import org.jellyfin.androidtv.ui.base.Text
|
||||
import org.jellyfin.androidtv.ui.composable.getResolutionName
|
||||
import org.jellyfin.androidtv.util.TimeUtils
|
||||
import org.jellyfin.androidtv.util.sdk.getProgramSubText
|
||||
@@ -118,7 +119,7 @@ fun BaseItemInfoRowRuntime(
|
||||
runTime: Duration,
|
||||
) {
|
||||
InfoRowItem(
|
||||
icon = painterResource(id = R.drawable.ic_time),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.ic_time),
|
||||
contentDescription = null,
|
||||
) {
|
||||
Text(TimeUtils.formatMillis(runTime.inWholeMilliseconds))
|
||||
@@ -150,10 +151,20 @@ private fun List<MediaStream>.getDefault(type: MediaStreamType, defaultIndex: In
|
||||
fun InfoRowMediaDetails(mediaSource: MediaSourceInfo) {
|
||||
val videoStream = mediaSource.mediaStreams?.getDefault(MediaStreamType.VIDEO)
|
||||
val audioStream = mediaSource.mediaStreams?.getDefault(MediaStreamType.AUDIO, mediaSource.defaultAudioStreamIndex)
|
||||
val subtitleStream = mediaSource.mediaStreams?.getDefault(MediaStreamType.SUBTITLE, mediaSource.defaultSubtitleStreamIndex)
|
||||
val hasSdhSubtitleStream = mediaSource.mediaStreams?.any { it.type == MediaStreamType.SUBTITLE && it.isHearingImpaired } == true
|
||||
val hasCcSubtitleStream = mediaSource.mediaStreams?.any { it.type == MediaStreamType.SUBTITLE && !it.isHearingImpaired } == true
|
||||
|
||||
// Subtitles
|
||||
if (subtitleStream != null) {
|
||||
if (hasSdhSubtitleStream) {
|
||||
InfoRowItem(
|
||||
contentDescription = null,
|
||||
colors = InfoRowColors.Default,
|
||||
) {
|
||||
Text(stringResource(R.string.indicator_subtitles_hearing_impaired))
|
||||
}
|
||||
}
|
||||
|
||||
if (hasCcSubtitleStream) {
|
||||
InfoRowItem(
|
||||
contentDescription = null,
|
||||
colors = InfoRowColors.Default,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.jellyfin.androidtv.ui.browsing.composable.inforow
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -13,12 +12,14 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.ProvideTextStyle
|
||||
import org.jellyfin.androidtv.ui.base.Icon
|
||||
import org.jellyfin.androidtv.ui.base.LocalTextStyle
|
||||
import org.jellyfin.androidtv.ui.base.ProvideTextStyle
|
||||
|
||||
/**
|
||||
* A single item in the [BaseItemInfoRow].
|
||||
@@ -26,7 +27,8 @@ import androidx.tv.material3.ProvideTextStyle
|
||||
@Composable
|
||||
fun InfoRowItem(
|
||||
// Icon options
|
||||
icon: Painter? = null,
|
||||
icon: ImageVector? = null,
|
||||
iconTint: Color? = null,
|
||||
contentDescription: String?,
|
||||
// Styling
|
||||
colors: Pair<Color, Color> = InfoRowColors.Transparent,
|
||||
@@ -56,10 +58,11 @@ fun InfoRowItem(
|
||||
modifier = modifier.fillMaxHeight(),
|
||||
) {
|
||||
if (icon != null) {
|
||||
Image(
|
||||
painter = icon,
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.size(if (backgroundColor.alpha > 0f) 16.dp else 18.dp),
|
||||
tint = iconTint ?: LocalTextStyle.current.color,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package org.jellyfin.androidtv.ui.browsing.composable.inforow
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.base.Text
|
||||
import java.text.NumberFormat
|
||||
|
||||
/**
|
||||
@@ -15,7 +17,8 @@ import java.text.NumberFormat
|
||||
@Composable
|
||||
fun InfoRowCommunityRating(communityRating: Float) {
|
||||
InfoRowItem(
|
||||
icon = painterResource(R.drawable.ic_star),
|
||||
icon = ImageVector.vectorResource(R.drawable.ic_star),
|
||||
iconTint = Color(0xFFEECE55),
|
||||
contentDescription = stringResource(R.string.lbl_community_rating),
|
||||
) {
|
||||
Text(String.format("%.1f", communityRating * 10f))
|
||||
@@ -33,9 +36,10 @@ private const val CRITIC_RATING_FRESH = 0.6f
|
||||
fun InfoRowCriticRating(criticRating: Float) {
|
||||
InfoRowItem(
|
||||
icon = when {
|
||||
criticRating >= CRITIC_RATING_FRESH -> painterResource(R.drawable.ic_rt_fresh)
|
||||
else -> painterResource(R.drawable.ic_rt_rotten)
|
||||
criticRating >= CRITIC_RATING_FRESH -> ImageVector.vectorResource(R.drawable.ic_rt_fresh)
|
||||
else -> ImageVector.vectorResource(R.drawable.ic_rt_rotten)
|
||||
},
|
||||
iconTint = Color.Unspecified,
|
||||
contentDescription = stringResource(R.string.lbl_critic_rating),
|
||||
) {
|
||||
Text(NumberFormat.getPercentInstance().format(criticRating))
|
||||
|
||||
@@ -42,14 +42,14 @@ public class ChannelCardView extends FrameLayout {
|
||||
.append(DateTimeExtensionsKt.getTimeFormatter(getContext()).format(program.getEndDate()))
|
||||
);
|
||||
|
||||
if (program.getStartDate().isAfter(LocalDateTime.now()) && program.getEndDate().isBefore(LocalDateTime.now())) {
|
||||
if (program.getStartDate().isBefore(LocalDateTime.now()) && program.getEndDate().isAfter(LocalDateTime.now())) {
|
||||
Duration duration = Duration.between(program.getStartDate(), program.getEndDate());
|
||||
Duration progress = Duration.between(program.getStartDate(), LocalDateTime.now());
|
||||
|
||||
binding.progress.setProgress((int) (progress.getSeconds() / duration.getSeconds() * 100));
|
||||
|
||||
binding.progress.setProgress((int) ((progress.getSeconds() / (double) duration.getSeconds()) * 100));
|
||||
} else {
|
||||
binding.progress.setProgress(0);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.time.setText("");
|
||||
binding.progress.setProgress(0);
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
package org.jellyfin.androidtv.ui.card
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.databinding.ViewCardDefaultBinding
|
||||
import org.jellyfin.androidtv.util.MenuBuilder
|
||||
import org.jellyfin.androidtv.util.popupMenu
|
||||
import org.jellyfin.androidtv.util.showIfNotEmpty
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class DefaultCardView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
defStyleRes: Int = 0,
|
||||
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
|
||||
init {
|
||||
isFocusable = true
|
||||
descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) defaultFocusHighlightEnabled = false
|
||||
}
|
||||
|
||||
val binding = ViewCardDefaultBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
|
||||
var title: String
|
||||
get() = binding.label.text.toString()
|
||||
set(value) {
|
||||
binding.label.text = value
|
||||
}
|
||||
|
||||
fun setSize(size: Size) = when (size) {
|
||||
Size.SQUARE -> setSize(size.width, size.height)
|
||||
}
|
||||
|
||||
private fun setSize(newWidth: Int, newHeight: Int) {
|
||||
binding.bannerContainer.updateLayoutParams {
|
||||
@Suppress("MagicNumber")
|
||||
height = (newHeight * context.resources.displayMetrics.density + 0.5f).roundToInt()
|
||||
}
|
||||
|
||||
val horizontalPadding = with(binding.container) { paddingStart + paddingEnd }
|
||||
binding.container.updateLayoutParams {
|
||||
@Suppress("MagicNumber")
|
||||
width = (newWidth * context.resources.displayMetrics.density + 0.5f).roundToInt() + horizontalPadding
|
||||
}
|
||||
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setImage(
|
||||
url: String? = null,
|
||||
blurHash: String? = null,
|
||||
placeholder: Drawable? = null,
|
||||
) = binding.banner.load(url, blurHash, placeholder)
|
||||
|
||||
fun setPopupMenu(init: MenuBuilder.() -> Unit) {
|
||||
setOnLongClickListener {
|
||||
popupMenu(context, binding.root, init = init).showIfNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (super.onKeyUp(keyCode, event)) return true
|
||||
|
||||
// Menu key should show the popup menu
|
||||
if (event.keyCode == KeyEvent.KEYCODE_MENU) return performLongClick()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
|
||||
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
|
||||
|
||||
val scaleRes = if (gainFocus) R.fraction.card_scale_focus else R.fraction.card_scale_default
|
||||
val scale = resources.getFraction(scaleRes, 1, 1)
|
||||
|
||||
post {
|
||||
animate().apply {
|
||||
scaleX(scale)
|
||||
scaleY(scale)
|
||||
duration = resources.getInteger(R.integer.card_scale_duration).toLong()
|
||||
withLayer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
enum class Size(val width: Int, val height: Int) {
|
||||
SQUARE(110, 110)
|
||||
}
|
||||
}
|
||||
188
app/src/main/java/org/jellyfin/androidtv/ui/card/UserCardView.kt
Normal file
188
app/src/main/java/org/jellyfin/androidtv/ui/card/UserCardView.kt
Normal file
@@ -0,0 +1,188 @@
|
||||
package org.jellyfin.androidtv.ui.card
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.view.KeyEvent
|
||||
import android.widget.ImageView
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.FocusInteraction
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.AbstractComposeView
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.base.Icon
|
||||
import org.jellyfin.androidtv.ui.base.JellyfinTheme
|
||||
import org.jellyfin.androidtv.ui.base.LocalTextStyle
|
||||
import org.jellyfin.androidtv.ui.base.ProvideTextStyle
|
||||
import org.jellyfin.androidtv.ui.base.Text
|
||||
import org.jellyfin.androidtv.ui.composable.AsyncImage
|
||||
import org.jellyfin.androidtv.util.MenuBuilder
|
||||
import org.jellyfin.androidtv.util.popupMenu
|
||||
import org.jellyfin.androidtv.util.showIfNotEmpty
|
||||
|
||||
@Composable
|
||||
fun UserCard(
|
||||
image: @Composable () -> Unit,
|
||||
name: @Composable () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
shape: Shape = CircleShape,
|
||||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
|
||||
// Mix button background with input foreground because we display text beneath the profile picture on a transparent background similar
|
||||
// to the input text
|
||||
val color = when {
|
||||
focused -> JellyfinTheme.colorScheme.buttonFocused to JellyfinTheme.colorScheme.onInputFocused
|
||||
else -> JellyfinTheme.colorScheme.button to JellyfinTheme.colorScheme.onInput
|
||||
}
|
||||
val scale by animateFloatAsState(if (focused) 1.1f else 1f, label = "UserCardFocusScale")
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.scale(scale)
|
||||
.focusable(interactionSource = interactionSource)
|
||||
.clickable(interactionSource = interactionSource, onClick = onClick, indication = null)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(shape)
|
||||
.border(2.dp, color.first, shape)
|
||||
) {
|
||||
image()
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.basicMarquee(
|
||||
iterations = if (focused) Int.MAX_VALUE else 0,
|
||||
initialDelayMillis = 0,
|
||||
),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
ProvideTextStyle(
|
||||
LocalTextStyle.current.copy(
|
||||
color = color.second,
|
||||
)
|
||||
) {
|
||||
name()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UserCardView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
) : AbstractComposeView(context, attrs, defStyleAttr) {
|
||||
var name by mutableStateOf<String?>(null)
|
||||
var image by mutableStateOf<String?>(null)
|
||||
private var focused by mutableStateOf(false)
|
||||
|
||||
init {
|
||||
isFocusable = true
|
||||
descendantFocusability = FOCUS_BLOCK_DESCENDANTS
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) defaultFocusHighlightEnabled = false
|
||||
}
|
||||
|
||||
fun setPopupMenu(init: MenuBuilder.() -> Unit) {
|
||||
setOnLongClickListener {
|
||||
popupMenu(context, this, init = init).showIfNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (super.onKeyUp(keyCode, event)) return true
|
||||
|
||||
// Menu key should show the popup menu
|
||||
if (event.keyCode == KeyEvent.KEYCODE_MENU) return performLongClick()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
|
||||
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
|
||||
|
||||
focused = gainFocus
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
// Forward focus events to the interaction source
|
||||
val focusInteraction = remember { FocusInteraction.Focus() }
|
||||
LaunchedEffect(focused) {
|
||||
if (focused) interactionSource.emit(focusInteraction)
|
||||
else interactionSource.emit(FocusInteraction.Unfocus(focusInteraction))
|
||||
}
|
||||
|
||||
UserCard(
|
||||
image = {
|
||||
if (image != null) {
|
||||
AsyncImage(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP,
|
||||
url = image,
|
||||
)
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_user),
|
||||
contentDescription = name,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
name = {
|
||||
Text(name.orEmpty(), maxLines = 1)
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 6.dp, vertical = 8.dp)
|
||||
.width(110.dp),
|
||||
interactionSource = interactionSource,
|
||||
// We use our own click handler for views used in presenters
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ fun AsyncImage(
|
||||
url: String? = null,
|
||||
blurHash: String? = null,
|
||||
placeholder: Drawable? = null,
|
||||
aspectRatio: Double = 1.0,
|
||||
aspectRatio: Float = 1f,
|
||||
blurHashResolution: Int = 32,
|
||||
scaleType: ImageView.ScaleType? = null,
|
||||
) {
|
||||
@@ -38,6 +38,7 @@ fun AsyncImage(
|
||||
modifier = modifier,
|
||||
factory = { context ->
|
||||
AsyncImageView(context).also { view ->
|
||||
view.adjustViewBounds = true
|
||||
view.scaleType = scaleType ?: ImageView.ScaleType.FIT_CENTER
|
||||
}
|
||||
},
|
||||
@@ -50,7 +51,7 @@ fun AsyncImage(
|
||||
url = compositionState.url,
|
||||
blurHash = compositionState.blurHash,
|
||||
placeholder = placeholder,
|
||||
aspectRatio = aspectRatio,
|
||||
aspectRatio = aspectRatio.toDouble(),
|
||||
blurHashResolution = blurHashResolution,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.LocalTextStyle
|
||||
import androidx.tv.material3.Text
|
||||
import org.jellyfin.androidtv.ui.base.LocalTextStyle
|
||||
import org.jellyfin.androidtv.ui.base.Text
|
||||
import org.jellyfin.sdk.model.api.LyricDto
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import kotlin.time.Duration
|
||||
|
||||
@@ -1,28 +1,42 @@
|
||||
package org.jellyfin.androidtv.ui.composable
|
||||
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import kotlinx.coroutines.delay
|
||||
import java.time.Instant
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import org.jellyfin.androidtv.util.locale
|
||||
import java.util.Date
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* A composable that returns the current time in milliseconds. Updates every second.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberCurrentTime(): Long {
|
||||
var value by remember { mutableLongStateOf(Instant.now().toEpochMilli()) }
|
||||
fun rememberCurrentTime(
|
||||
format12: String = "h:mm",
|
||||
format24: String = "k:mm",
|
||||
updateFrequency: Duration = 1.minutes,
|
||||
): State<String> {
|
||||
val context = LocalContext.current
|
||||
val locale = context.locale
|
||||
val format = remember(context, locale) {
|
||||
when (DateFormat.is24HourFormat(context)) {
|
||||
true -> format24
|
||||
false -> format12
|
||||
}
|
||||
}
|
||||
val currentTime = remember { mutableStateOf(DateFormat.format(format, Date()).toString()) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(format) {
|
||||
while (true) {
|
||||
value = Instant.now().toEpochMilli()
|
||||
delay(1.seconds)
|
||||
val now = Date()
|
||||
currentTime.value = DateFormat.format(format, now).toString()
|
||||
// Delay until next expected change in time based on updateFrequency
|
||||
delay(updateFrequency.inWholeMilliseconds - now.time % updateFrequency.inWholeMilliseconds)
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
return currentTime
|
||||
}
|
||||
|
||||
@@ -1,79 +1,90 @@
|
||||
package org.jellyfin.androidtv.ui.home
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.compose.AndroidFragment
|
||||
import androidx.fragment.compose.content
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.flowWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.repository.ServerRepository
|
||||
import org.jellyfin.androidtv.auth.repository.SessionRepository
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
import org.jellyfin.androidtv.data.repository.NotificationsRepository
|
||||
import org.jellyfin.androidtv.databinding.FragmentHomeBinding
|
||||
import org.jellyfin.androidtv.ui.navigation.Destinations
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
|
||||
import org.jellyfin.androidtv.ui.playback.MediaManager
|
||||
import org.jellyfin.androidtv.ui.startup.StartupActivity
|
||||
import org.jellyfin.androidtv.util.ImageHelper
|
||||
import org.jellyfin.androidtv.ui.base.JellyfinTheme
|
||||
import org.jellyfin.androidtv.ui.shared.toolbar.MainToolbar
|
||||
import org.jellyfin.androidtv.ui.shared.toolbar.MainToolbarActiveButton
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
class HomeFragment : Fragment() {
|
||||
private var _binding: FragmentHomeBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val sessionRepository by inject<SessionRepository>()
|
||||
private val userRepository by inject<UserRepository>()
|
||||
private val serverRepository by inject<ServerRepository>()
|
||||
private val notificationRepository by inject<NotificationsRepository>()
|
||||
private val navigationRepository by inject<NavigationRepository>()
|
||||
private val mediaManager by inject<MediaManager>()
|
||||
private val imageHelper by inject<ImageHelper>()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
_binding = FragmentHomeBinding.inflate(inflater, container, false)
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
) = content {
|
||||
val rowsFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(rowsFocusRequester) { rowsFocusRequester.requestFocus() }
|
||||
|
||||
binding.settings.setOnClickListener {
|
||||
navigationRepository.navigate(Destinations.userPreferences)
|
||||
JellyfinTheme {
|
||||
Column {
|
||||
MainToolbar(MainToolbarActiveButton.Home)
|
||||
|
||||
// The leanback code has its own awful focus handling that doesn't work properly with Compose view inteop to workaround this
|
||||
// issue we add custom behavior that only allows focus exit when the current selected row is the first one. Additionally when
|
||||
// we do switch the focus, we reset the leanback state so it won't cause weird behavior when focus is regained
|
||||
var rowsSupportFragment by remember { mutableStateOf<HomeRowsFragment?>(null) }
|
||||
AndroidFragment<HomeRowsFragment>(
|
||||
modifier = Modifier
|
||||
.focusGroup()
|
||||
.focusRequester(rowsFocusRequester)
|
||||
.focusProperties {
|
||||
onExit = {
|
||||
val isFirstRowSelected = rowsSupportFragment?.selectedPosition?.let { it <= 0 } ?: false
|
||||
if (requestedFocusDirection != FocusDirection.Up || !isFirstRowSelected) {
|
||||
cancelFocusChange()
|
||||
} else {
|
||||
rowsSupportFragment?.selectedPosition = 0
|
||||
rowsSupportFragment?.verticalGridView?.clearFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
.fillMaxSize(),
|
||||
onUpdate = { fragment ->
|
||||
rowsSupportFragment = fragment
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
binding.switchUsers.setOnClickListener {
|
||||
switchUser()
|
||||
}
|
||||
|
||||
binding.search.setOnClickListener {
|
||||
navigationRepository.navigate(Destinations.search())
|
||||
}
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
userRepository.currentUser
|
||||
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
|
||||
.onEach { user ->
|
||||
if (user != null) {
|
||||
binding.switchUsersImage.load(
|
||||
url = imageHelper.getPrimaryImageUrl(user),
|
||||
placeholder = ContextCompat.getDrawable(requireContext(), R.drawable.ic_user)
|
||||
)
|
||||
}
|
||||
}
|
||||
.launchIn(viewLifecycleOwner.lifecycleScope)
|
||||
|
||||
sessionRepository.currentSession
|
||||
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
|
||||
.map { session->
|
||||
.map { session ->
|
||||
if (session == null) null
|
||||
else serverRepository.getServer(session.serverId)
|
||||
}
|
||||
@@ -82,24 +93,4 @@ class HomeFragment : Fragment() {
|
||||
}
|
||||
.launchIn(viewLifecycleOwner.lifecycleScope)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private fun switchUser() {
|
||||
mediaManager.clearAudioQueue()
|
||||
sessionRepository.destroyCurrentSession()
|
||||
|
||||
// Open login activity
|
||||
val selectUserIntent = Intent(activity, StartupActivity::class.java)
|
||||
selectUserIntent.putExtra(StartupActivity.EXTRA_HIDE_SPLASH, true)
|
||||
// Remove history to prevent user going back to current activity
|
||||
selectUserIntent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
|
||||
|
||||
activity?.startActivity(selectUserIntent)
|
||||
activity?.finishAfterTransition()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,15 @@ import android.content.Context
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
import org.jellyfin.androidtv.constant.ChangeTriggerType
|
||||
import org.jellyfin.androidtv.data.repository.ItemRepository
|
||||
import org.jellyfin.androidtv.ui.browsing.BrowseRowDef
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||
import org.jellyfin.sdk.model.api.ItemFields as SdkItemFields
|
||||
|
||||
class HomeFragmentHelper(
|
||||
private val context: Context,
|
||||
@@ -26,13 +25,7 @@ class HomeFragmentHelper(
|
||||
fun loadResume(title: String, includeMediaTypes: Collection<MediaType>): HomeFragmentRow {
|
||||
val query = GetResumeItemsRequest(
|
||||
limit = ITEM_LIMIT_RESUME,
|
||||
fields = listOf(
|
||||
SdkItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
SdkItemFields.OVERVIEW,
|
||||
SdkItemFields.ITEM_COUNTS,
|
||||
SdkItemFields.DISPLAY_PREFERENCES_ID,
|
||||
SdkItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
imageTypeLimit = 1,
|
||||
enableTotalRecordCount = false,
|
||||
mediaTypes = includeMediaTypes,
|
||||
@@ -52,11 +45,7 @@ class HomeFragmentHelper(
|
||||
|
||||
fun loadLatestLiveTvRecordings(): HomeFragmentRow {
|
||||
val query = GetRecordingsRequest(
|
||||
fields = setOf(
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.CHILD_COUNT
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
enableImages = true,
|
||||
limit = ITEM_LIMIT_RECORDINGS
|
||||
)
|
||||
@@ -69,11 +58,7 @@ class HomeFragmentHelper(
|
||||
imageTypeLimit = 1,
|
||||
limit = ITEM_LIMIT_NEXT_UP,
|
||||
enableResumable = false,
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.CHILD_COUNT,
|
||||
)
|
||||
fields = ItemRepository.itemFields
|
||||
)
|
||||
|
||||
return HomeFragmentBrowseRowDefRow(BrowseRowDef(context.getString(R.string.lbl_next_up), query, arrayOf(ChangeTriggerType.TvPlayback)))
|
||||
@@ -82,12 +67,7 @@ class HomeFragmentHelper(
|
||||
fun loadOnNow(): HomeFragmentRow {
|
||||
val query = GetRecommendedProgramsRequest(
|
||||
isAiring = true,
|
||||
fields = setOf(
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.CHANNEL_INFO,
|
||||
ItemFields.CHILD_COUNT,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
imageTypeLimit = 1,
|
||||
enableTotalRecordCount = false,
|
||||
limit = ITEM_LIMIT_ON_NOW
|
||||
|
||||
@@ -5,12 +5,12 @@ import androidx.leanback.widget.Row
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
import org.jellyfin.androidtv.constant.ChangeTriggerType
|
||||
import org.jellyfin.androidtv.data.repository.ItemRepository
|
||||
import org.jellyfin.androidtv.ui.browsing.BrowseRowDef
|
||||
import org.jellyfin.androidtv.ui.presentation.CardPresenter
|
||||
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||
|
||||
class HomeFragmentLatestRow(
|
||||
@@ -28,12 +28,7 @@ class HomeFragmentLatestRow(
|
||||
.map { item ->
|
||||
// Create query and add it to a new row
|
||||
val request = GetLatestMediaRequest(
|
||||
fields = setOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.CHILD_COUNT,
|
||||
ItemFields.SERIES_PRIMARY_IMAGE,
|
||||
),
|
||||
fields = ItemRepository.itemFields,
|
||||
imageTypeLimit = 1,
|
||||
parentId = item.id,
|
||||
groupItems = true,
|
||||
|
||||
@@ -4,13 +4,18 @@ import android.content.Context
|
||||
import androidx.leanback.widget.HeaderItem
|
||||
import androidx.leanback.widget.ListRow
|
||||
import androidx.leanback.widget.Row
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.playback.AudioQueueBaseRowAdapter
|
||||
import org.jellyfin.androidtv.ui.playback.MediaManager
|
||||
import org.jellyfin.androidtv.ui.presentation.CardPresenter
|
||||
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter
|
||||
import org.jellyfin.playback.core.PlaybackManager
|
||||
|
||||
class HomeFragmentNowPlayingRow(
|
||||
private val mediaManager: MediaManager
|
||||
private val lifecycleScope: LifecycleCoroutineScope,
|
||||
private val playbackManager: PlaybackManager,
|
||||
private val mediaManager: MediaManager,
|
||||
) : HomeFragmentRow {
|
||||
private var row: ListRow? = null
|
||||
|
||||
@@ -27,7 +32,7 @@ class HomeFragmentNowPlayingRow(
|
||||
// Ensure row exists
|
||||
if (row == null) row = ListRow(
|
||||
HeaderItem(context.getString(R.string.lbl_now_playing)),
|
||||
mediaManager.managedAudioQueue
|
||||
AudioQueueBaseRowAdapter(playbackManager, lifecycleScope)
|
||||
)
|
||||
// Add row if it wasn't added already
|
||||
if (!rowsAdapter.contains(row!!)) rowsAdapter.add(0, row!!)
|
||||
|
||||
@@ -46,6 +46,7 @@ import org.jellyfin.androidtv.ui.presentation.CardPresenter
|
||||
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter
|
||||
import org.jellyfin.androidtv.ui.presentation.PositionableListRowPresenter
|
||||
import org.jellyfin.androidtv.util.KeyProcessor
|
||||
import org.jellyfin.playback.core.PlaybackManager
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||
import org.jellyfin.sdk.api.sockets.subscribe
|
||||
@@ -58,6 +59,7 @@ import kotlin.time.Duration.Companion.seconds
|
||||
class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyListener {
|
||||
private val api by inject<ApiClient>()
|
||||
private val backgroundService by inject<BackgroundService>()
|
||||
private val playbackManager by inject<PlaybackManager>()
|
||||
private val mediaManager by inject<MediaManager>()
|
||||
private val notificationsRepository by inject<NotificationsRepository>()
|
||||
private val userRepository by inject<UserRepository>()
|
||||
@@ -78,7 +80,7 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
|
||||
|
||||
// Special rows
|
||||
private val notificationsRow by lazy { NotificationsHomeFragmentRow(lifecycleScope, notificationsRepository) }
|
||||
private val nowPlaying by lazy { HomeFragmentNowPlayingRow(mediaManager) }
|
||||
private val nowPlaying by lazy { HomeFragmentNowPlayingRow(lifecycleScope, playbackManager, mediaManager) }
|
||||
private val liveTVRow by lazy { HomeFragmentLiveTVRow(requireActivity(), userRepository, navigationRepository) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -247,7 +249,9 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
|
||||
row: Row?,
|
||||
) {
|
||||
if (item !is BaseRowItem) return
|
||||
itemLauncher.launch(item, (row as ListRow).adapter as ItemRowAdapter, requireContext())
|
||||
if (row !is ListRow) return
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
itemLauncher.launch(item, row.adapter as MutableObjectAdapter<Any>, requireContext())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import org.jellyfin.androidtv.data.service.BackgroundService;
|
||||
import org.jellyfin.androidtv.databinding.FragmentFullDetailsBinding;
|
||||
import org.jellyfin.androidtv.preference.UserPreferences;
|
||||
import org.jellyfin.androidtv.preference.constant.ClockBehavior;
|
||||
import org.jellyfin.androidtv.ui.InteractionTrackerViewModel;
|
||||
import org.jellyfin.androidtv.ui.RecordPopup;
|
||||
import org.jellyfin.androidtv.ui.RecordingIndicatorView;
|
||||
import org.jellyfin.androidtv.ui.TextUnderButton;
|
||||
@@ -57,12 +58,10 @@ import org.jellyfin.androidtv.ui.itemhandling.BaseRowItem;
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher;
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter;
|
||||
import org.jellyfin.androidtv.ui.livetv.TvManager;
|
||||
import org.jellyfin.androidtv.ui.navigation.Destination;
|
||||
import org.jellyfin.androidtv.ui.navigation.Destinations;
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepository;
|
||||
import org.jellyfin.androidtv.ui.playback.MediaManager;
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackLauncher;
|
||||
import org.jellyfin.androidtv.ui.playback.VideoQueueManager;
|
||||
import org.jellyfin.androidtv.ui.presentation.CardPresenter;
|
||||
import org.jellyfin.androidtv.ui.presentation.CustomListRowPresenter;
|
||||
import org.jellyfin.androidtv.ui.presentation.InfoCardPresenter;
|
||||
@@ -77,7 +76,7 @@ import org.jellyfin.androidtv.util.PlaybackHelper;
|
||||
import org.jellyfin.androidtv.util.TimeUtils;
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
import org.jellyfin.androidtv.util.apiclient.BaseItemUtils;
|
||||
import org.jellyfin.androidtv.util.apiclient.LifecycleAwareResponse;
|
||||
import org.jellyfin.androidtv.util.apiclient.Response;
|
||||
import org.jellyfin.androidtv.util.sdk.BaseItemExtensionsKt;
|
||||
import org.jellyfin.androidtv.util.sdk.TrailerUtils;
|
||||
import org.jellyfin.androidtv.util.sdk.compat.JavaCompat;
|
||||
@@ -145,7 +144,6 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
private final Lazy<DataRefreshService> dataRefreshService = inject(DataRefreshService.class);
|
||||
private final Lazy<BackgroundService> backgroundService = inject(BackgroundService.class);
|
||||
final Lazy<MediaManager> mediaManager = inject(MediaManager.class);
|
||||
final Lazy<VideoQueueManager> videoQueueManager = inject(VideoQueueManager.class);
|
||||
private final Lazy<MarkdownRenderer> markdownRenderer = inject(MarkdownRenderer.class);
|
||||
private final Lazy<CustomMessageRepository> customMessageRepository = inject(CustomMessageRepository.class);
|
||||
final Lazy<NavigationRepository> navigationRepository = inject(NavigationRepository.class);
|
||||
@@ -153,6 +151,7 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
private final Lazy<KeyProcessor> keyProcessor = inject(KeyProcessor.class);
|
||||
final Lazy<PlaybackHelper> playbackHelper = inject(PlaybackHelper.class);
|
||||
private final Lazy<ImageHelper> imageHelper = inject(ImageHelper.class);
|
||||
private final Lazy<InteractionTrackerViewModel> interactionTracker = inject(InteractionTrackerViewModel.class);
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
@@ -419,7 +418,7 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
|
||||
mDetailsOverviewRow = new MyDetailsOverviewRow(item);
|
||||
|
||||
String primaryImageUrl = imageHelper.getValue().getLogoImageUrl(mBaseItem, 600, true);
|
||||
String primaryImageUrl = imageHelper.getValue().getLogoImageUrl(mBaseItem, 600);
|
||||
if (primaryImageUrl == null) {
|
||||
primaryImageUrl = imageHelper.getValue().getPrimaryImageUrl(mBaseItem, false, null, posterHeight);
|
||||
}
|
||||
@@ -678,7 +677,21 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
|
||||
private String getRunTime() {
|
||||
Long runtime = Utils.getSafeValue(mBaseItem.getRunTimeTicks(), mBaseItem.getRunTimeTicks());
|
||||
return runtime != null && runtime > 0 ? String.format("%d%s", (int) Math.ceil((double) runtime / 600000000), getString(R.string.lbl_min)) : "";
|
||||
|
||||
if (runtime == null || runtime <= 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
int totalMinutes = (int) Math.ceil((double) runtime / 600000000);
|
||||
|
||||
int hours = totalMinutes / 60;
|
||||
int minutes = totalMinutes % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return getString(R.string.runtime_hours_minutes, hours, minutes);
|
||||
}
|
||||
|
||||
return getString(R.string.runtime_minutes, minutes);
|
||||
}
|
||||
|
||||
private String getEndTime() {
|
||||
@@ -700,11 +713,9 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
BaseItemDto baseItem = mBaseItem;
|
||||
if (baseItem.getType() == BaseItemKind.AUDIO || baseItem.getType() == BaseItemKind.MUSIC_ALBUM || baseItem.getType() == BaseItemKind.MUSIC_ARTIST) {
|
||||
if (baseItem.getType() == BaseItemKind.MUSIC_ALBUM || baseItem.getType() == BaseItemKind.MUSIC_ARTIST) {
|
||||
playbackHelper.getValue().getItemsToPlay(getContext(), baseItem, false, false, new LifecycleAwareResponse<List<BaseItemDto>>(getLifecycle()) {
|
||||
playbackHelper.getValue().getItemsToPlay(getContext(), baseItem, false, false, new Response<List<BaseItemDto>>() {
|
||||
@Override
|
||||
public void onResponse(List<BaseItemDto> response) {
|
||||
if (!getActive()) return;
|
||||
|
||||
mediaManager.getValue().addToAudioQueue(response);
|
||||
}
|
||||
});
|
||||
@@ -760,14 +771,14 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
mResumeButton = TextUnderButton.create(requireContext(), R.drawable.ic_resume, buttonSize, 2, buttonLabel, new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
FullDetailsFragmentHelperKt.resumePlayback(FullDetailsFragment.this);
|
||||
FullDetailsFragmentHelperKt.resumePlayback(FullDetailsFragment.this, v);
|
||||
}
|
||||
});
|
||||
|
||||
if (BaseItemExtensionsKt.canPlay(baseItem)) {
|
||||
mDetailsOverviewRow.addAction(mResumeButton);
|
||||
boolean resumeButtonVisible = (baseItem.getType() == BaseItemKind.SERIES && !mBaseItem.getUserData().getPlayed()) || (JavaCompat.getCanResume(mBaseItem));
|
||||
mResumeButton.setVisibility(resumeButtonVisible ? View.VISIBLE : View.GONE);
|
||||
boolean isSeries = baseItem.getType() == BaseItemKind.SERIES;
|
||||
boolean isStarted = baseItem.getUserData().getPlayedPercentage() != null && baseItem.getUserData().getPlayedPercentage() > 0;
|
||||
|
||||
playButton = TextUnderButton.create(requireContext(), R.drawable.ic_play, buttonSize, 2, getString(BaseItemExtensionsKt.isLiveTv(mBaseItem) ? R.string.lbl_tune_to_channel : Utils.getSafeValue(mBaseItem.isFolder(), false) ? R.string.lbl_play_all : R.string.lbl_play), new View.OnClickListener() {
|
||||
@Override
|
||||
@@ -775,13 +786,15 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
play(mBaseItem, 0, false);
|
||||
}
|
||||
});
|
||||
|
||||
mDetailsOverviewRow.addAction(playButton);
|
||||
|
||||
if (resumeButtonVisible) {
|
||||
mResumeButton.requestFocus();
|
||||
if (isSeries && !isStarted) {
|
||||
FullDetailsFragmentHelperKt.getNextUpEpisode(this, nextUpEpisode -> {
|
||||
handleResumeButtonAndFocus(nextUpEpisode);
|
||||
return null;
|
||||
});
|
||||
} else {
|
||||
playButton.requestFocus();
|
||||
handleResumeButtonAndFocus(null);
|
||||
}
|
||||
|
||||
boolean isMusic = baseItem.getType() == BaseItemKind.MUSIC_ALBUM
|
||||
@@ -1054,6 +1067,24 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
showMoreButtonIfNeeded(); //Episodes check for previous and then call this above
|
||||
}
|
||||
|
||||
private void handleResumeButtonAndFocus(BaseItemDto nextUpEpisode) {
|
||||
boolean isSeries = mBaseItem.getType() == BaseItemKind.SERIES;
|
||||
boolean isFinished = mBaseItem.getUserData().getPlayed();
|
||||
boolean isStarted = mBaseItem.getUserData().getPlayedPercentage() != null && mBaseItem.getUserData().getPlayedPercentage() > 0;
|
||||
if (!isStarted && nextUpEpisode != null) {
|
||||
isStarted = nextUpEpisode.getUserData().getPlaybackPositionTicks() > 0;
|
||||
}
|
||||
|
||||
boolean resumeButtonVisible = (isSeries && isStarted && !isFinished) || (JavaCompat.getCanResume(mBaseItem));
|
||||
mResumeButton.setVisibility(resumeButtonVisible ? View.VISIBLE : View.GONE);
|
||||
|
||||
if (resumeButtonVisible) {
|
||||
mResumeButton.requestFocus();
|
||||
} else {
|
||||
playButton.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
private void addVersionsMenu(View v) {
|
||||
PopupMenu menu = new PopupMenu(requireContext(), v, Gravity.END);
|
||||
|
||||
@@ -1182,22 +1213,16 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
}
|
||||
|
||||
void play(final BaseItemDto item, final int pos, final boolean shuffle) {
|
||||
playbackHelper.getValue().getItemsToPlay(getContext(), item, pos == 0 && item.getType() == BaseItemKind.MOVIE, shuffle, new LifecycleAwareResponse<List<BaseItemDto>>(getLifecycle()) {
|
||||
playbackHelper.getValue().getItemsToPlay(getContext(), item, pos == 0 && item.getType() == BaseItemKind.MOVIE, shuffle, new Response<List<BaseItemDto>>() {
|
||||
@Override
|
||||
public void onResponse(List<BaseItemDto> response) {
|
||||
if (!getActive()) return;
|
||||
if (response.isEmpty()) {
|
||||
Timber.e("No items to play - ignoring play request.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.getType() == BaseItemKind.MUSIC_ARTIST) {
|
||||
mediaManager.getValue().playNow(requireContext(), response, 0, shuffle);
|
||||
} else {
|
||||
videoQueueManager.getValue().setCurrentVideoQueue(response);
|
||||
Destination destination = KoinJavaComponent.<PlaybackLauncher>get(PlaybackLauncher.class).getPlaybackDestination(item.getType(), pos);
|
||||
navigationRepository.getValue().navigate(destination);
|
||||
}
|
||||
interactionTracker.getValue().notifyStartSession(item, response);
|
||||
KoinJavaComponent.<PlaybackLauncher>get(PlaybackLauncher.class).launch(getContext(), response, pos, false, 0, shuffle);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1205,8 +1230,6 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
void play(final List<BaseItemDto> items, final int pos, final boolean shuffle) {
|
||||
if (items.isEmpty()) return;
|
||||
if (shuffle) Collections.shuffle(items);
|
||||
videoQueueManager.getValue().setCurrentVideoQueue(items);
|
||||
Destination destination = KoinJavaComponent.<PlaybackLauncher>get(PlaybackLauncher.class).getPlaybackDestination(items.get(0).getType(), pos);
|
||||
navigationRepository.getValue().navigate(destination);
|
||||
KoinJavaComponent.<PlaybackLauncher>get(PlaybackLauncher.class).launch(getContext(), items, pos);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user