Compare commits
497 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b98029ba0 | ||
|
|
5def949cff | ||
|
|
6a89de66c3 | ||
|
|
7986ad4112 | ||
|
|
2881600b57 | ||
|
|
c661b11c32 | ||
|
|
1f9b50f66d | ||
|
|
b825b4cc8e | ||
|
|
57f0075a66 | ||
|
|
ece02d19fe | ||
|
|
9e91e92ec4 | ||
|
|
22421d9f46 | ||
|
|
a3cfc160d6 | ||
|
|
efeb432672 | ||
|
|
403f1f24da | ||
|
|
cb6f1b3747 | ||
|
|
d301a675a4 | ||
|
|
d87947266a | ||
|
|
d0f5974f86 | ||
|
|
ccf3c85788 | ||
|
|
e34647548b | ||
|
|
eaf2411a99 | ||
|
|
25dd7515cb | ||
|
|
afb55fc06c | ||
|
|
bacbea0b59 | ||
|
|
cdcd528bcc | ||
|
|
a8cd75aec3 | ||
|
|
cac4958a0b | ||
|
|
19cbfcda3e | ||
|
|
dd7f64d1e0 | ||
|
|
d4c0af73c3 | ||
|
|
5ab2e91ae8 | ||
|
|
62c7a65365 | ||
|
|
c2391f85e4 | ||
|
|
d1691499a9 | ||
|
|
fa2fd94a6e | ||
|
|
59e918a144 | ||
|
|
de74f641ce | ||
|
|
48b5d262bc | ||
|
|
6fe5a96045 | ||
|
|
9307bb8d81 | ||
|
|
0e0aa4bd26 | ||
|
|
7e2300e5fa | ||
|
|
a50663a33c | ||
|
|
1a3d8d8cb0 | ||
|
|
d6453b7224 | ||
|
|
1d2a0842a6 | ||
|
|
cea16ea8d0 | ||
|
|
8415802188 | ||
|
|
c7d8790e17 | ||
|
|
cafd225074 | ||
|
|
74e24efcbf | ||
|
|
7600b4a27a | ||
|
|
613ba0ef8b | ||
|
|
2242d15a7a | ||
|
|
193fa73799 | ||
|
|
af80f41aac | ||
|
|
3e8e059ab2 | ||
|
|
0fca5d77fe | ||
|
|
b9f8757123 | ||
|
|
72ff0cfae8 | ||
|
|
1f8cfa0f30 | ||
|
|
64f3bdac41 | ||
|
|
8eaaa39c24 | ||
|
|
cbb230a588 | ||
|
|
489780f4ae | ||
|
|
b861f7372c | ||
|
|
f8aa323580 | ||
|
|
ab6c32825c | ||
|
|
b9aabaacec | ||
|
|
b8c9b079ee | ||
|
|
9436e6966a | ||
|
|
eb6e931a22 | ||
|
|
a3f4b112b8 | ||
|
|
5bae1198dc | ||
|
|
38d0ee6bce | ||
|
|
5d395978a7 | ||
|
|
02ec11135c | ||
|
|
df0ff6eaa1 | ||
|
|
58a1449766 | ||
|
|
d07a5d0fc4 | ||
|
|
924463bff9 | ||
|
|
67c7d7c0d0 | ||
|
|
db9695d702 | ||
|
|
10d599d5df | ||
|
|
aff07fcbd7 | ||
|
|
d8b072383d | ||
|
|
c5de74cf33 | ||
|
|
e7f51dba01 | ||
|
|
456bb619bc | ||
|
|
2de7bd876a | ||
|
|
7f0fbba0d2 | ||
|
|
829a88761f | ||
|
|
a054d9430e | ||
|
|
cab17b8bee | ||
|
|
b87d26d2ed | ||
|
|
e4952e6eba | ||
|
|
26c78157d9 | ||
|
|
8edf76c5dd | ||
|
|
83183892fd | ||
|
|
5b8c76c531 | ||
|
|
56b16b37c5 | ||
|
|
ba4b57ef93 | ||
|
|
4ba1bbc946 | ||
|
|
aa964647b5 | ||
|
|
e3062edc91 | ||
|
|
2a54faee98 | ||
|
|
3f0d4bdbfc | ||
|
|
4308f46f4d | ||
|
|
861e00ad1a | ||
|
|
947ab42e33 | ||
|
|
22f3bafcab | ||
|
|
62153f648a | ||
|
|
6a03b75eb2 | ||
|
|
333c8b9630 | ||
|
|
3c9fe12ddc | ||
|
|
26a83a6bc4 | ||
|
|
978177c48b | ||
|
|
afa7d42fa4 | ||
|
|
5c804a65d3 | ||
|
|
b15581c8a4 | ||
|
|
fcd3b2dcd6 | ||
|
|
c1d1b52fc0 | ||
|
|
d6e32b1075 | ||
|
|
df610b2ec4 | ||
|
|
c0c2a08572 | ||
|
|
fcff716b28 | ||
|
|
fce6998218 | ||
|
|
fc62714955 | ||
|
|
f0a8604acc | ||
|
|
bd5b10e040 | ||
|
|
f6af0b8075 | ||
|
|
ca715d4c10 | ||
|
|
967d690139 | ||
|
|
53e8a80592 | ||
|
|
8bb34428f8 | ||
|
|
3ab0ae4cf9 | ||
|
|
421ba3704c | ||
|
|
9f2ea8434f | ||
|
|
ad46a348ad | ||
|
|
02f9405c40 | ||
|
|
a368382262 | ||
|
|
6c7af50a34 | ||
|
|
8976dc302a | ||
|
|
c24ab17b10 | ||
|
|
4ae28b1f2c | ||
|
|
c14e504d8b | ||
|
|
cf507e8385 | ||
|
|
faaec6a999 | ||
|
|
3d7f252664 | ||
|
|
d41f992b62 | ||
|
|
7acfc41498 | ||
|
|
067f227983 | ||
|
|
b844bf3165 | ||
|
|
0935cc473b | ||
|
|
ebfa4bba7f | ||
|
|
2b9280f3ef | ||
|
|
258e20f605 | ||
|
|
5fa51d489d | ||
|
|
ed0e470a40 | ||
|
|
62d68a8485 | ||
|
|
d1d45906cf | ||
|
|
c83d7c62f1 | ||
|
|
8822076391 | ||
|
|
fd9ab5d431 | ||
|
|
d4ff5983a3 | ||
|
|
f60a72909d | ||
|
|
1ef40b614a | ||
|
|
e25acfadd6 | ||
|
|
92148e9da8 | ||
|
|
4f700ba9d3 | ||
|
|
69b3fa48be | ||
|
|
59c1ef00d5 | ||
|
|
860bfa529e | ||
|
|
51cec68bc9 | ||
|
|
a8d18f70ad | ||
|
|
da0ebbc5ce | ||
|
|
a91e9f9ad3 | ||
|
|
153ca1662f | ||
|
|
eb1ef2f295 | ||
|
|
aa54863603 | ||
|
|
51ce9dece4 | ||
|
|
0bdcccf29b | ||
|
|
7100b26c83 | ||
|
|
da38f75a0f | ||
|
|
44682144c8 | ||
|
|
035a115dc9 | ||
|
|
2480b05007 | ||
|
|
9184f106a7 | ||
|
|
fc1f469661 | ||
|
|
e7ee94ab82 | ||
|
|
24aac6a392 | ||
|
|
430f0ff689 | ||
|
|
3e7e9618c7 | ||
|
|
5e9bfd4744 | ||
|
|
9a1a55160a | ||
|
|
6f0bc196ba | ||
|
|
96cd70d5a8 | ||
|
|
6ad07f274f | ||
|
|
913beda916 | ||
|
|
e39877b6f5 | ||
|
|
346c21c0b4 | ||
|
|
60e58e9fdb | ||
|
|
99fb7d38aa | ||
|
|
8ac778d39f | ||
|
|
e77593265b | ||
|
|
eab61bd3a3 | ||
|
|
2c885a2ba7 | ||
|
|
9234073344 | ||
|
|
5d5ffc79ce | ||
|
|
63a6dca0e2 | ||
|
|
5189e2931a | ||
|
|
0eb848c2f2 | ||
|
|
e6a20cb47e | ||
|
|
84476439e7 | ||
|
|
a654d2af68 | ||
|
|
875ca123bb | ||
|
|
341f031660 | ||
|
|
14920a506e | ||
|
|
690977498c | ||
|
|
6998e6cdbb | ||
|
|
74535c7c03 | ||
|
|
25babf42c8 | ||
|
|
ce2023a4b1 | ||
|
|
4466b2fd69 | ||
|
|
1d6c9a81fc | ||
|
|
a11ef51851 | ||
|
|
a056ac1e25 | ||
|
|
05de9bf84b | ||
|
|
dcd536cd19 | ||
|
|
bb239f548c | ||
|
|
143be2f4f2 | ||
|
|
5312958e3b | ||
|
|
f1590c69be | ||
|
|
3cb86e356b | ||
|
|
bb150ba8bf | ||
|
|
21d0072d7d | ||
|
|
df494a9392 | ||
|
|
8d64f1ba63 | ||
|
|
72ca9cdc66 | ||
|
|
96d247c2b5 | ||
|
|
a45f5ce626 | ||
|
|
c69c0ab23d | ||
|
|
75b5f62e6b | ||
|
|
5ae1347ec0 | ||
|
|
59a36bacf1 | ||
|
|
9b321a1b17 | ||
|
|
638bd222fd | ||
|
|
9c312645b3 | ||
|
|
84cdc00115 | ||
|
|
40f4af82bc | ||
|
|
a01d539b92 | ||
|
|
f87302d15f | ||
|
|
7c8020ba30 | ||
|
|
068880e780 | ||
|
|
2af5ea8c28 | ||
|
|
4f67eeb0d2 | ||
|
|
875f094ff2 | ||
|
|
0f5f1b23fc | ||
|
|
a3f5928349 | ||
|
|
105f86b16b | ||
|
|
b26b04bddc | ||
|
|
6d1c48b696 | ||
|
|
2912b830b5 | ||
|
|
ebec491592 | ||
|
|
00125664ac | ||
|
|
d38627cb24 | ||
|
|
540a3855b4 | ||
|
|
c1f78e5c66 | ||
|
|
cff7ed0d74 | ||
|
|
07f89b2ac2 | ||
|
|
c3ce0bdc4b | ||
|
|
139c8e6202 | ||
|
|
0e0f330bc0 | ||
|
|
022ca79fc8 | ||
|
|
52bde656d0 | ||
|
|
9ce1487056 | ||
|
|
5d086b4ca3 | ||
|
|
184b966491 | ||
|
|
695bf20d27 | ||
|
|
e41e2874b2 | ||
|
|
df51fc0fea | ||
|
|
42d7078e5c | ||
|
|
2c3066b624 | ||
|
|
76647acd33 | ||
|
|
88ac61fe0b | ||
|
|
b0cd5db981 | ||
|
|
d481842b13 | ||
|
|
d0678c3982 | ||
|
|
81d84e47aa | ||
|
|
df94aadb19 | ||
|
|
d05836f755 | ||
|
|
f0d4a97e12 | ||
|
|
45d0ee8b7d | ||
|
|
ae22ecc1fd | ||
|
|
7bf042f389 | ||
|
|
a91ae41bb6 | ||
|
|
64763edf4d | ||
|
|
eeb3e95283 | ||
|
|
f012168d66 | ||
|
|
a29926b0c4 | ||
|
|
cae30bdacc | ||
|
|
3768ac9728 | ||
|
|
f2ec78235d | ||
|
|
fe04c050f1 | ||
|
|
3a8ca61cdc | ||
|
|
30e43e12e7 | ||
|
|
b46dba52f8 | ||
|
|
3fbc3a35ca | ||
|
|
1c30542802 | ||
|
|
9f328d0281 | ||
|
|
c5c98b4513 | ||
|
|
0e0f6c601c | ||
|
|
4bf92183d2 | ||
|
|
15bd5a247c | ||
|
|
29dacd490a | ||
|
|
4158b701c6 | ||
|
|
4fc21f94bf | ||
|
|
8dd9545b47 | ||
|
|
3eea008374 | ||
|
|
d600fcac38 | ||
|
|
16f394afa6 | ||
|
|
e6d8d8671c | ||
|
|
2d5f5966e6 | ||
|
|
69209bb842 | ||
|
|
af62319d11 | ||
|
|
beb08e071b | ||
|
|
629719d75a | ||
|
|
a32f58d4e0 | ||
|
|
096516b819 | ||
|
|
8a20f864de | ||
|
|
d9fd231034 | ||
|
|
022cc909f9 | ||
|
|
fa97921c31 | ||
|
|
133f189d5c | ||
|
|
ca57f2e103 | ||
|
|
036ca2bafa | ||
|
|
7e0a1529f7 | ||
|
|
d38d742cf5 | ||
|
|
ae52b479e0 | ||
|
|
43b41ad4d5 | ||
|
|
eee0942272 | ||
|
|
8bf581dae3 | ||
|
|
f7698e205c | ||
|
|
964e8eb065 | ||
|
|
700220bc07 | ||
|
|
888bfaa05b | ||
|
|
46dcc32a38 | ||
|
|
701d4b2826 | ||
|
|
9c64544a0e | ||
|
|
1247475cbf | ||
|
|
cb8b690a69 | ||
|
|
1a69f77a14 | ||
|
|
ac89159455 | ||
|
|
24218f9717 | ||
|
|
d714d58fef | ||
|
|
c6e0badbae | ||
|
|
507127c842 | ||
|
|
72d5b209f3 | ||
|
|
dc781ea86f | ||
|
|
66cafdd3c8 | ||
|
|
25161f11db | ||
|
|
3c97433a34 | ||
|
|
d5783740f7 | ||
|
|
a145a437a8 | ||
|
|
385c6483f3 | ||
|
|
e339290b53 | ||
|
|
4dbad2ddc3 | ||
|
|
1b4d09a37d | ||
|
|
73a8945830 | ||
|
|
58714986ae | ||
|
|
e4a9b0dc78 | ||
|
|
7584e54ed6 | ||
|
|
b44ac842c9 | ||
|
|
b96f934b49 | ||
|
|
49f386f59d | ||
|
|
74fde37643 | ||
|
|
dcc28101e1 | ||
|
|
b22b611c22 | ||
|
|
2b41879dfa | ||
|
|
54ea9bf805 | ||
|
|
1868d8eb53 | ||
|
|
759cc88e61 | ||
|
|
a6811b462e | ||
|
|
a2fc550adc | ||
|
|
4461ff1776 | ||
|
|
c3a2bb5120 | ||
|
|
5cac1a155e | ||
|
|
ffcfacd893 | ||
|
|
98489f603a | ||
|
|
72d67b5e03 | ||
|
|
d69d6a29bb | ||
|
|
e240769daf | ||
|
|
20ccff5514 | ||
|
|
ff22b8d666 | ||
|
|
08930ce9a9 | ||
|
|
740de57703 | ||
|
|
805bf966e2 | ||
|
|
fea336522d | ||
|
|
6674239bae | ||
|
|
a0957534a9 | ||
|
|
1c75688935 | ||
|
|
1b0c5bf47b | ||
|
|
ffb74649e6 | ||
|
|
357374d698 | ||
|
|
d6a70bab64 | ||
|
|
0fbe21882f | ||
|
|
3f4366c876 | ||
|
|
d35a1d328d | ||
|
|
f3597cd8ce | ||
|
|
f41f61ebb6 | ||
|
|
dd3378b66e | ||
|
|
98838ed123 | ||
|
|
59047e11f8 | ||
|
|
c851c0bb71 | ||
|
|
47d45b4dc9 | ||
|
|
d62035bb58 | ||
|
|
a8cf6310b4 | ||
|
|
5272d851c1 | ||
|
|
b8a5525f14 | ||
|
|
de961044ff | ||
|
|
bd95c13f50 | ||
|
|
f4ed78a611 | ||
|
|
04abc96882 | ||
|
|
3bfff95465 | ||
|
|
d98b8acf22 | ||
|
|
81cb9f04ad | ||
|
|
f8ee6de28f | ||
|
|
d80032fb7f | ||
|
|
f490064bec | ||
|
|
4b3fe53bda | ||
|
|
98128190d6 | ||
|
|
248fe58381 | ||
|
|
66573c399f | ||
|
|
a1e6b17faf | ||
|
|
07e3809227 | ||
|
|
f9e7e735e8 | ||
|
|
f394d17f37 | ||
|
|
72d98710a9 | ||
|
|
bb0096905f | ||
|
|
da71c6f6a0 | ||
|
|
7e95582f89 | ||
|
|
50e21994da | ||
|
|
009fe70ed3 | ||
|
|
6c3d289169 | ||
|
|
a5328eecb0 | ||
|
|
cd1e323616 | ||
|
|
3929512331 | ||
|
|
73cdfda7af | ||
|
|
0337f27942 | ||
|
|
44ff6ebbbf | ||
|
|
35df230463 | ||
|
|
d706f937c1 | ||
|
|
da950a1e07 | ||
|
|
07fc2d614a | ||
|
|
48357a6f81 | ||
|
|
c663982ba0 | ||
|
|
9d9adab416 | ||
|
|
04da7eb69e | ||
|
|
fd5ff1cdd9 | ||
|
|
7bc34dce91 | ||
|
|
ceaaa51812 | ||
|
|
2e2d9ec9b4 | ||
|
|
9471d4adf4 | ||
|
|
d3c84df367 | ||
|
|
936e33903c | ||
|
|
a377834e35 | ||
|
|
eb362165b1 | ||
|
|
93287746a8 | ||
|
|
4d8e432c67 | ||
|
|
5c9b6c625c | ||
|
|
0e7f52fd42 | ||
|
|
0d9abb9f17 | ||
|
|
36da60a1e2 | ||
|
|
a330cdcc26 | ||
|
|
56ac9ca8ff | ||
|
|
1dba22557d | ||
|
|
f748a566dd | ||
|
|
f5dfa010f8 | ||
|
|
55f3fd7b05 | ||
|
|
ffc6765cfe | ||
|
|
3be558ce91 | ||
|
|
d3130e38d1 | ||
|
|
25ba439e9c | ||
|
|
76d92c3361 | ||
|
|
b07bc3062c | ||
|
|
f43dca4b76 | ||
|
|
278cdb8e0f | ||
|
|
05180de3b4 | ||
|
|
d9c27d3849 | ||
|
|
d5167884f0 | ||
|
|
fe8f180354 | ||
|
|
a1dd6fcfd9 | ||
|
|
395db1ccf2 | ||
|
|
2d89bd4670 | ||
|
|
b3711228ab | ||
|
|
3fe6ed6f7b |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -5,3 +5,5 @@
|
||||
|
||||
CONTRIBUTORS.md merge=union
|
||||
app/src/main/res/values*/strings.xml merge=union
|
||||
|
||||
design/src/main/kotlin/token/* linguist-generated
|
||||
|
||||
18
.github/ISSUE_TEMPLATE/bug-report-playback.yaml
vendored
18
.github/ISSUE_TEMPLATE/bug-report-playback.yaml
vendored
@@ -10,6 +10,8 @@ body:
|
||||
label: "This issue respects the following points:"
|
||||
description: All conditions are **required**.
|
||||
options:
|
||||
- label: I have discussed this issue in the [Jellyfin forum](https://forum.jellyfin.org/) and/or [troubleshooting chat](https://jellyfin.org/contact) before opening this issue.
|
||||
required: true
|
||||
- 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).
|
||||
@@ -46,6 +48,20 @@ body:
|
||||
- Copy the media info from the web interface
|
||||
placeholder: Paste media info…
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: mediacapabilitiesreport
|
||||
attributes:
|
||||
label: Media capabilities report
|
||||
description: |
|
||||
Please share the media capability report generated from within the app. To do some follow these steps:
|
||||
1. Open the Jellyfin for Android TV app
|
||||
2. Connect to a server **that you have admin access to**
|
||||
3. Go to Preferences -> Playback -> Advanced playback preferences
|
||||
4. On the bottom, press the "Report playback capabilities to server" button
|
||||
5. Go to the logs in your Jellyfin dashboard (in your browser)
|
||||
6. Open the newest log file, it should be named similar to `upload_Jellyfin Android TV[...].log`
|
||||
7. Press the "Download" button and paste the file contents
|
||||
placeholder: Paste capability report contents…
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
@@ -121,7 +137,7 @@ body:
|
||||
id: other-sources
|
||||
attributes:
|
||||
label: Other sources
|
||||
description: If this topic has been discussed outside of GitHub, please link it.
|
||||
description: Please link the related forum topics or chat messages that prompted you open this issue.
|
||||
placeholder: https://forum.jellyfin.org/…
|
||||
validations:
|
||||
required: false
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
4
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -9,6 +9,8 @@ body:
|
||||
label: "This issue respects the following points:"
|
||||
description: All conditions are **required**.
|
||||
options:
|
||||
- label: I have discussed this issue in the [Jellyfin forum](https://forum.jellyfin.org/) and/or [troubleshooting chat](https://jellyfin.org/contact) before opening this issue.
|
||||
required: true
|
||||
- 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).
|
||||
@@ -98,7 +100,7 @@ body:
|
||||
id: other-sources
|
||||
attributes:
|
||||
label: Other sources
|
||||
description: If this topic has been discussed outside of GitHub, please link it.
|
||||
description: Please link the related forum topics or chat messages that prompted you open this issue.
|
||||
placeholder: https://forum.jellyfin.org/…
|
||||
validations:
|
||||
required: false
|
||||
|
||||
6
.github/pull_request_template.md
vendored
6
.github/pull_request_template.md
vendored
@@ -1,11 +1,15 @@
|
||||
<!--
|
||||
Ensure your title is short, descriptive, and in the imperative mood (Fix X, Change Y, instead of Fixed X, Changed Y).
|
||||
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://docs.jellyfin.org/general/contributing/issues.html page.
|
||||
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://jellyfin.org/docs/general/contributing/issues/ page.
|
||||
-->
|
||||
|
||||
**Changes**
|
||||
<!-- Describe your changes here in 1-5 sentences. -->
|
||||
|
||||
**Code assistance**
|
||||
<!-- If code assistance was used, describe how it contributed
|
||||
e.g., code generated by LLM, explanation of code base, debugging guidance. -->
|
||||
|
||||
**Issues**
|
||||
<!-- Tag any issues that this PR solves here.
|
||||
ex. Fixes # -->
|
||||
|
||||
11
.github/workflows/app-build.yaml
vendored
11
.github/workflows/app-build.yaml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- release-*
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
@@ -15,20 +16,20 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
java-version-file: .tools-versions
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
|
||||
- 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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: build-artifacts
|
||||
retention-days: 14
|
||||
|
||||
10
.github/workflows/app-lint.yaml
vendored
10
.github/workflows/app-lint.yaml
vendored
@@ -17,18 +17,18 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
java-version-file: .tools-versions
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
|
||||
- name: Run detekt and lint tasks
|
||||
run: ./gradlew detekt lint
|
||||
- name: Upload SARIF files
|
||||
uses: github/codeql-action/upload-sarif@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
||||
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
sarif_file: .
|
||||
|
||||
10
.github/workflows/app-publish.yaml
vendored
10
.github/workflows/app-publish.yaml
vendored
@@ -12,14 +12,14 @@ jobs:
|
||||
if: ${{ contains(github.repository_owner, 'jellyfin') }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
java-version-file: .tools-versions
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
|
||||
- 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@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: ${{ secrets.REPO_HOST }}
|
||||
username: ${{ secrets.REPO_USER }}
|
||||
|
||||
8
.github/workflows/app-test.yaml
vendored
8
.github/workflows/app-test.yaml
vendored
@@ -16,13 +16,13 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
java-version-file: .tools-versions
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
|
||||
- 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
|
||||
|
||||
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@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
days-before-stale: 120
|
||||
|
||||
1
.tools-versions
Normal file
1
.tools-versions
Normal file
@@ -0,0 +1 @@
|
||||
java 21
|
||||
70
README.md
70
README.md
@@ -25,9 +25,6 @@
|
||||
<a href="https://matrix.to/#/+jellyfin:matrix.org">
|
||||
<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfin:matrix.org.svg?logo=matrix"/>
|
||||
</a>
|
||||
<a href="https://www.reddit.com/r/jellyfin">
|
||||
<img alt="Join our Subreddit" src="https://img.shields.io/badge/reddit-r%2Fjellyfin-%23FF5700.svg"/>
|
||||
</a>
|
||||
<br/>
|
||||
<a href="https://play.google.com/store/apps/details?id=org.jellyfin.androidtv">
|
||||
<img width="153" alt="Jellyfin on Google Play" src="https://jellyfin.org/images/store-icons/google-play.png"/>
|
||||
@@ -42,48 +39,39 @@
|
||||
<a href="https://repo.jellyfin.org/releases/client/androidtv/">Download archive</a>
|
||||
</p>
|
||||
|
||||
Jellyfin Android TV is a Jellyfin client for Android TV, Nvidia Shield, and Amazon Fire TV devices.
|
||||
We welcome all contributions and pull requests! If you have a larger feature in mind please open an
|
||||
issue so we can discuss the implementation before you start.
|
||||
Jellyfin Android TV is a Jellyfin client for Android TV, Nvidia Shield, and Amazon Fire TV devices. We welcome all contributions and pull
|
||||
requests! If you have a larger feature in mind please open an issue so we can discuss the implementation before you start.
|
||||
|
||||
## Building
|
||||
|
||||
The app uses Gradle and requires the Android SDK. We recommend using Android Studio, which includes all required dependencies, for
|
||||
development and building. For manual building without Android Studio make sure a compatible JDK and Android SDK are installed and in your
|
||||
PATH, then use the Gradle wrapper (`./gradlew`) to build the project with the `assembleDebug` Gradle task to generate an apk file:
|
||||
|
||||
```shell
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
The task will create an APK file in the `/app/build/outputs/apk/debug` directory. This APK file uses a different app-id from our stable
|
||||
builds and can be manually installed to your device.
|
||||
|
||||
## Branching
|
||||
|
||||
The `master` branch is the primary development branch and the target for all pull requests. It is **unstable** and may contain breaking
|
||||
changes or unresolved bugs. For production deployments and forks, always use the latest `release-x.y.z` branch. Do not base production work
|
||||
or long-lived forks on `master`.
|
||||
|
||||
Release branches are created at the start of a beta cycle and are kept up to date with each published release. Maintainers will cherry-pick
|
||||
selected changes into release branches as needed for backports. These branches are reused for subsequent patch releases.
|
||||
|
||||
## Translating
|
||||
|
||||
Translations can be improved very easily from our
|
||||
[Weblate](https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv) instance.
|
||||
Look through the following graphic to see if your native language could use some work!
|
||||
Translations can be improved very easily from our [Weblate](https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv)
|
||||
instance. Look through the following graphic to see if your native language could use some work! We cannot accept changes to translation
|
||||
files via pull requests.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://translate.jellyfin.org/engage/jellyfin-android/">
|
||||
<img alt="Detailed Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin-android/-/jellyfin-androidtv/multi-auto.svg"/>
|
||||
</a>
|
||||
|
||||
## Build Process
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Android Studio
|
||||
|
||||
### Build
|
||||
|
||||
1. Clone or download this repository
|
||||
|
||||
```sh
|
||||
git clone https://github.com/jellyfin/jellyfin-androidtv.git
|
||||
cd jellyfin-androidtv
|
||||
```
|
||||
|
||||
2. Open the project in Android Studio and run it from there or build an APK directly through Gradle:
|
||||
|
||||
```sh
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Add the Android SDK to your PATH environment variable or create the ANDROID_SDK_ROOT variable for
|
||||
this to work.
|
||||
|
||||
### Deploy to device/emulator
|
||||
|
||||
```sh
|
||||
./gradlew installDebug
|
||||
```
|
||||
|
||||
*You can also replace the "Debug" with "Release" to get an optimized release binary.*
|
||||
</p>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
kotlin("android")
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.aboutlibraries)
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -24,6 +23,7 @@ android {
|
||||
buildConfig = true
|
||||
viewBinding = true
|
||||
compose = true
|
||||
resValues = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -32,7 +32,7 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
|
||||
// Set package names used in various XML files
|
||||
resValue("string", "app_id", namespace!!)
|
||||
@@ -87,6 +87,7 @@ tasks.register("versionTxt") {
|
||||
|
||||
dependencies {
|
||||
// Jellyfin
|
||||
implementation(projects.design)
|
||||
implementation(projects.playback.core)
|
||||
implementation(projects.playback.jellyfin)
|
||||
implementation(projects.playback.media3.exoplayer)
|
||||
@@ -114,6 +115,7 @@ dependencies {
|
||||
implementation(libs.androidx.fragment.compose)
|
||||
implementation(libs.androidx.leanback.core)
|
||||
implementation(libs.androidx.leanback.preference)
|
||||
implementation(libs.androidx.navigation3.ui)
|
||||
implementation(libs.androidx.preference)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.tvprovider)
|
||||
@@ -136,6 +138,7 @@ dependencies {
|
||||
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)
|
||||
|
||||
1
app/proguard-rules.pro
vendored
Normal file
1
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1 @@
|
||||
-dontwarn org.commonmark.ext.gfm.strikethrough.Strikethrough
|
||||
@@ -35,6 +35,11 @@
|
||||
android:host="youtube.com"
|
||||
android:scheme="https" />
|
||||
</intent>
|
||||
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
@@ -47,22 +52,10 @@
|
||||
android:icon="@mipmap/app_icon"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Jellyfin"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<!-- Legacy service required for AccountManagerMigration -->
|
||||
<service
|
||||
android:name=".auth.service.AuthenticatorService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/authenticator" />
|
||||
</service>
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<!-- Screensaver -->
|
||||
<service
|
||||
@@ -135,10 +128,6 @@
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustNothing" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.preference.PreferencesActivity"
|
||||
android:theme="@style/Theme.Jellyfin.Preferences" />
|
||||
|
||||
<activity android:name=".ui.playback.ExternalPlayerActivity" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package org.jellyfin.androidtv.auth
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import androidx.core.content.getSystemService
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
class AccountManagerMigration(
|
||||
context: Context,
|
||||
) {
|
||||
private val accountManager = requireNotNull(context.getSystemService<AccountManager>())
|
||||
|
||||
fun migrate(
|
||||
servers: Map<UUID, AuthenticationStoreServer>,
|
||||
) = servers.mapValues { (serverId, server) ->
|
||||
Timber.i("Migrating server $serverId (${server.name})")
|
||||
server.copy(
|
||||
users = server.users.mapValues { (userId, user) ->
|
||||
val accessToken = getAccessToken(serverId, userId)
|
||||
Timber.i("Migrating user $userId (${user.name}): ${if (accessToken != null) "success" else "no token"}")
|
||||
user.copy(accessToken = accessToken)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MissingPermission")
|
||||
private fun getAccessToken(serverId: UUID, userId: UUID): String? = runCatching {
|
||||
accountManager.getAccountsByType(ACCOUNT_TYPE)
|
||||
.firstOrNull {
|
||||
val validServerId = accountManager.getUserData(it, ACCOUNT_DATA_SERVER)?.toUUIDOrNull() == serverId
|
||||
val validUserId = accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == userId
|
||||
|
||||
validServerId && validUserId
|
||||
}
|
||||
?.let { account -> accountManager.peekAuthToken(account, ACCOUNT_ACCESS_TOKEN_TYPE) }
|
||||
}.getOrNull()
|
||||
|
||||
companion object {
|
||||
const val ACCOUNT_TYPE = BuildConfig.APPLICATION_ID
|
||||
const val ACCOUNT_DATA_ID = "$ACCOUNT_TYPE.id"
|
||||
const val ACCOUNT_DATA_SERVER = "$ACCOUNT_TYPE.server"
|
||||
const val ACCOUNT_ACCESS_TOKEN_TYPE = "$ACCOUNT_TYPE.access_token"
|
||||
}
|
||||
}
|
||||
@@ -26,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.BrandingOptionsDto
|
||||
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.
|
||||
@@ -55,7 +55,7 @@ interface ServerRepository {
|
||||
val minimumServerVersion = Jellyfin.minimumVersion.copy(build = null)
|
||||
val recommendedServerVersion = Jellyfin.apiVersion.copy(build = null)
|
||||
|
||||
val upcomingMinimumServerVersion = ServerVersion(10, 10, 0)
|
||||
val upcomingMinimumServerVersion = ServerVersion(10, 11, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ class ServerRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
Timber.d(buildString {
|
||||
Timber.i(buildString {
|
||||
append("Recommendations: ")
|
||||
if (greatRecommendation == null) append(0)
|
||||
else append(1)
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
package org.jellyfin.androidtv.auth.service
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountAuthenticatorResponse
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import androidx.core.os.bundleOf
|
||||
import org.jellyfin.androidtv.ui.preference.PreferencesActivity
|
||||
|
||||
class AuthenticatorService : Service() {
|
||||
private val authenticator by lazy {
|
||||
Authenticator(this)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? = authenticator.iBinder
|
||||
|
||||
private inner class Authenticator(
|
||||
private val service: AuthenticatorService
|
||||
) : AbstractAccountAuthenticator(service) {
|
||||
private val unsupportedOperationBundle = bundleOf(
|
||||
AccountManager.KEY_ERROR_CODE to AccountManager.ERROR_CODE_REMOTE_EXCEPTION,
|
||||
AccountManager.KEY_ERROR_MESSAGE to "Unsupported operation"
|
||||
)
|
||||
|
||||
override fun addAccount(
|
||||
response: AccountAuthenticatorResponse,
|
||||
accountType: String,
|
||||
authTokenType: String?,
|
||||
requiredFeatures: Array<String>?,
|
||||
options: Bundle,
|
||||
): Bundle = unsupportedOperationBundle
|
||||
|
||||
override fun confirmCredentials(
|
||||
response: AccountAuthenticatorResponse,
|
||||
account: Account,
|
||||
options: Bundle?,
|
||||
): Bundle = unsupportedOperationBundle
|
||||
|
||||
override fun editProperties(
|
||||
response: AccountAuthenticatorResponse,
|
||||
accountType: String,
|
||||
): Bundle = bundleOf(
|
||||
AccountManager.KEY_INTENT to Intent(service, PreferencesActivity::class.java)
|
||||
)
|
||||
|
||||
override fun getAccountRemovalAllowed(
|
||||
response: AccountAuthenticatorResponse,
|
||||
account: Account,
|
||||
): Bundle = bundleOf(
|
||||
AccountManager.KEY_BOOLEAN_RESULT to true
|
||||
)
|
||||
|
||||
override fun getAuthToken(
|
||||
response: AccountAuthenticatorResponse,
|
||||
account: Account,
|
||||
authTokenType: String,
|
||||
options: Bundle,
|
||||
): Bundle = unsupportedOperationBundle
|
||||
|
||||
override fun getAuthTokenLabel(authTokenType: String): String? = null
|
||||
|
||||
override fun hasFeatures(
|
||||
response: AccountAuthenticatorResponse,
|
||||
account: Account,
|
||||
features: Array<String>,
|
||||
): Bundle = unsupportedOperationBundle
|
||||
|
||||
override fun updateCredentials(
|
||||
response: AccountAuthenticatorResponse,
|
||||
account: Account,
|
||||
authTokenType: String?,
|
||||
options: Bundle?,
|
||||
): Bundle = unsupportedOperationBundle
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package org.jellyfin.androidtv.auth.store
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
@@ -13,7 +12,6 @@ import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.contextual
|
||||
import org.jellyfin.androidtv.auth.AccountManagerMigration
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticationStoreUser
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
@@ -28,7 +26,6 @@ import java.util.UUID
|
||||
*/
|
||||
class AuthenticationStore(
|
||||
private val context: Context,
|
||||
private val accountManagerMigration: AccountManagerMigration,
|
||||
) {
|
||||
private val storePath
|
||||
get() = context.filesDir.resolve("authentication_store.json")
|
||||
@@ -59,11 +56,13 @@ class AuthenticationStore(
|
||||
|
||||
// Check for version
|
||||
return when (root["version"]?.jsonPrimitive?.intOrNull) {
|
||||
1 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
|
||||
// Add access tokens from account manager to stored users and save the migrated data
|
||||
.let { servers -> accountManagerMigration.migrate(servers) }
|
||||
.also { servers -> write(servers) }
|
||||
// Migration was removed, clear stored servers
|
||||
1 -> {
|
||||
Timber.e("Migrating from version 1 is no longer possible")
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
// Current version, return as-is
|
||||
2 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
|
||||
|
||||
null -> {
|
||||
|
||||
@@ -66,6 +66,7 @@ object Codec {
|
||||
const val VP8 = "vp8"
|
||||
const val VP9 = "vp9"
|
||||
const val AV1 = "av1"
|
||||
const val VC1 = "vc1"
|
||||
}
|
||||
|
||||
object Subtitle {
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.jellyfin.androidtv.data.eventhandling
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
@@ -79,7 +78,7 @@ class SocketHandler(
|
||||
add(GeneralCommandType.SEND_STRING)
|
||||
|
||||
// Note: These are used in the PlaySessionSocketService
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !audioManager.isVolumeFixed) {
|
||||
if (!audioManager.isVolumeFixed) {
|
||||
add(GeneralCommandType.VOLUME_UP)
|
||||
add(GeneralCommandType.VOLUME_DOWN)
|
||||
add(GeneralCommandType.SET_VOLUME)
|
||||
@@ -172,13 +171,14 @@ class SocketHandler(
|
||||
}
|
||||
|
||||
private fun onPlayMessage(message: PlayMessage) {
|
||||
val itemId = message.data?.itemIds?.firstOrNull() ?: return
|
||||
val itemIds = message.data?.itemIds ?: return
|
||||
|
||||
runCatching {
|
||||
playbackHelper.retrieveAndPlay(
|
||||
itemId,
|
||||
itemIds,
|
||||
false,
|
||||
message.data?.startPositionTicks,
|
||||
message.data?.startIndex,
|
||||
context
|
||||
)
|
||||
}.onFailure { Timber.w(it, "Failed to start playback") }
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package org.jellyfin.androidtv.data.model
|
||||
|
||||
import org.jellyfin.androidtv.util.apiclient.JellyfinImage
|
||||
import java.util.UUID
|
||||
|
||||
data class ChapterItemInfo(
|
||||
val itemId: UUID,
|
||||
val name: String?,
|
||||
val startPositionTicks: Long,
|
||||
val imagePath: String?,
|
||||
val image: JellyfinImage?,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.jellyfin.androidtv.data.repository
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.ResolveInfo
|
||||
import androidx.core.net.toUri
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.util.componentName
|
||||
|
||||
class ExternalAppRepository(
|
||||
private val userPreferences: UserPreferences,
|
||||
) {
|
||||
companion object {
|
||||
const val SAMPLE_VIDEO_URL = "http://jellyfin.local/query.mp4"
|
||||
const val MEDIA_TYPE_VIDEO = "video/*"
|
||||
}
|
||||
|
||||
private val externalPlayerAppIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndTypeAndNormalize(SAMPLE_VIDEO_URL.toUri(), MEDIA_TYPE_VIDEO)
|
||||
}
|
||||
|
||||
fun getExternalPlayerApps(context: Context): List<ResolveInfo> = context.packageManager
|
||||
.queryIntentActivities(externalPlayerAppIntent, 0)
|
||||
// Hide apps with priority below zero (system stubs)
|
||||
.filter { it.priority >= 0 }
|
||||
|
||||
fun getCurrentExternalPlayerApp(context: Context): ActivityInfo? {
|
||||
// Validate if external app should be used at all
|
||||
val useExternalPlayer = userPreferences[UserPreferences.useExternalPlayer]
|
||||
if (!useExternalPlayer) return null
|
||||
|
||||
// Resolve external app information
|
||||
val resolvedInfo = userPreferences[UserPreferences.externalPlayerComponentName]
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let(ComponentName::unflattenFromString)
|
||||
?.runCatching { context.packageManager.getActivityInfo(this, 0) }
|
||||
?.getOrNull()
|
||||
if (resolvedInfo != null) return resolvedInfo
|
||||
|
||||
// Fallback in case the app is uninstalled or unavailable for some other reason
|
||||
val externalApps = getExternalPlayerApps(context)
|
||||
|
||||
// System default
|
||||
val systemDefault = externalApps.find { it.isDefault }?.activityInfo
|
||||
if (systemDefault != null) return systemDefault
|
||||
|
||||
// First compatible, or none
|
||||
return externalApps.firstOrNull()?.activityInfo
|
||||
}
|
||||
|
||||
fun setExternalPlayerapp(activityInfo: ActivityInfo?) {
|
||||
if (activityInfo == null) {
|
||||
userPreferences[UserPreferences.useExternalPlayer] = false
|
||||
userPreferences[UserPreferences.externalPlayerComponentName] = ""
|
||||
} else {
|
||||
userPreferences[UserPreferences.useExternalPlayer] = true
|
||||
userPreferences[UserPreferences.externalPlayerComponentName] = activityInfo.componentName.flattenToShortString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ 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.preference.constant.BackdropBehavior
|
||||
import org.jellyfin.androidtv.util.apiclient.getUrl
|
||||
import org.jellyfin.androidtv.util.apiclient.itemBackdropImages
|
||||
import org.jellyfin.androidtv.util.apiclient.parentBackdropImages
|
||||
@@ -59,12 +60,14 @@ class BackgroundService(
|
||||
* Use all available backdrops from [baseItem] as background.
|
||||
*/
|
||||
fun setBackground(baseItem: BaseItemDto?) {
|
||||
val backdropBehavior = userPreferences[UserPreferences.backdropBehavior]
|
||||
|
||||
// Check if item is set and backgrounds are enabled
|
||||
if (baseItem == null || !userPreferences[UserPreferences.backdropEnabled])
|
||||
if (baseItem == null || backdropBehavior == BackdropBehavior.DISABLED)
|
||||
return clearBackgrounds()
|
||||
|
||||
// Enable blur for backdrops
|
||||
_blurBackground.value = true
|
||||
_blurBackground.value = backdropBehavior == BackdropBehavior.BACKDROP_WITH_BLUR
|
||||
|
||||
// Get all backdrop urls
|
||||
val backdropUrls = (baseItem.itemBackdropImages + baseItem.parentBackdropImages)
|
||||
@@ -79,7 +82,7 @@ class BackgroundService(
|
||||
*/
|
||||
fun setBackground(server: Server) {
|
||||
// Check if item is set and backgrounds are enabled
|
||||
if (!userPreferences[UserPreferences.backdropEnabled])
|
||||
if (userPreferences[UserPreferences.backdropBehavior] == BackdropBehavior.DISABLED)
|
||||
return clearBackgrounds()
|
||||
|
||||
// Check if splashscreen is enabled in (cached) branding options
|
||||
|
||||
@@ -20,6 +20,7 @@ import org.jellyfin.androidtv.data.eventhandling.SocketHandler
|
||||
import org.jellyfin.androidtv.data.model.DataRefreshService
|
||||
import org.jellyfin.androidtv.data.repository.CustomMessageRepository
|
||||
import org.jellyfin.androidtv.data.repository.CustomMessageRepositoryImpl
|
||||
import org.jellyfin.androidtv.data.repository.ExternalAppRepository
|
||||
import org.jellyfin.androidtv.data.repository.ItemMutationRepository
|
||||
import org.jellyfin.androidtv.data.repository.ItemMutationRepositoryImpl
|
||||
import org.jellyfin.androidtv.data.repository.NotificationsRepository
|
||||
@@ -43,6 +44,7 @@ import org.jellyfin.androidtv.ui.search.SearchFragmentDelegate
|
||||
import org.jellyfin.androidtv.ui.search.SearchRepository
|
||||
import org.jellyfin.androidtv.ui.search.SearchRepositoryImpl
|
||||
import org.jellyfin.androidtv.ui.search.SearchViewModel
|
||||
import org.jellyfin.androidtv.ui.settings.compat.SettingsViewModel
|
||||
import org.jellyfin.androidtv.ui.startup.ServerAddViewModel
|
||||
import org.jellyfin.androidtv.ui.startup.StartupViewModel
|
||||
import org.jellyfin.androidtv.ui.startup.UserLoginViewModel
|
||||
@@ -139,6 +141,7 @@ val appModule = module {
|
||||
single<NavigationRepository> { NavigationRepositoryImpl(Destinations.home) }
|
||||
single<SearchRepository> { SearchRepositoryImpl(get()) }
|
||||
single<MediaSegmentRepository> { MediaSegmentRepositoryImpl(get(), get()) }
|
||||
single<ExternalAppRepository> { ExternalAppRepository(get()) }
|
||||
|
||||
viewModel { StartupViewModel(get(), get(), get(), get()) }
|
||||
viewModel { UserLoginViewModel(get(), get(), get(), get(defaultDeviceInfo)) }
|
||||
@@ -148,6 +151,7 @@ val appModule = module {
|
||||
viewModel { PhotoPlayerViewModel(get()) }
|
||||
viewModel { SearchViewModel(get()) }
|
||||
viewModel { DreamViewModel(get(), get(), get(), get(), get()) }
|
||||
viewModel { SettingsViewModel() }
|
||||
|
||||
single { BackgroundService(get(), get(), get(), get(), get()) }
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.jellyfin.androidtv.di
|
||||
|
||||
import org.jellyfin.androidtv.auth.AccountManagerMigration
|
||||
import org.jellyfin.androidtv.auth.repository.AuthenticationRepository
|
||||
import org.jellyfin.androidtv.auth.repository.AuthenticationRepositoryImpl
|
||||
import org.jellyfin.androidtv.auth.repository.ServerRepository
|
||||
@@ -14,8 +13,7 @@ import org.jellyfin.androidtv.auth.store.AuthenticationStore
|
||||
import org.koin.dsl.module
|
||||
|
||||
val authModule = module {
|
||||
single { AccountManagerMigration(get()) }
|
||||
single { AuthenticationStore(get(), get()) }
|
||||
single { AuthenticationStore(get()) }
|
||||
single { AuthenticationPreferences(get()) }
|
||||
|
||||
single<AuthenticationRepository> {
|
||||
|
||||
@@ -26,9 +26,11 @@ import org.jellyfin.playback.media3.session.MediaSessionOptions
|
||||
import org.jellyfin.playback.media3.session.media3SessionPlugin
|
||||
import org.jellyfin.sdk.api.client.HttpClientOptions
|
||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.scope.Scope
|
||||
import org.koin.dsl.module
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackManager as LegacyPlaybackManager
|
||||
|
||||
@@ -39,10 +41,12 @@ val playbackModule = module {
|
||||
|
||||
single { PlaybackLauncher(get(), get(), get(), get()) }
|
||||
|
||||
// OkHttp data source using OkHttpFactory from SDK
|
||||
single<HttpDataSource.Factory> {
|
||||
val okHttpFactory = get<OkHttpFactory>()
|
||||
val httpClientOptions = get<HttpClientOptions>()
|
||||
val httpClientOptions = get<HttpClientOptions>().copy(
|
||||
// Disable request timeout for media playback as this causes issues with Live TV
|
||||
requestTimeout = Duration.ZERO
|
||||
)
|
||||
|
||||
OkHttpDataSource.Factory(okHttpFactory.createClient(httpClientOptions))
|
||||
}
|
||||
@@ -68,6 +72,7 @@ fun Scope.createPlaybackManager() = playbackManager(androidContext()) {
|
||||
val userPreferences = get<UserPreferences>()
|
||||
val exoPlayerOptions = ExoPlayerOptions(
|
||||
preferFfmpeg = userPreferences[UserPreferences.preferExoPlayerFfmpeg],
|
||||
enableLibass = userPreferences[UserPreferences.assDirectPlay],
|
||||
enableDebugLogging = userPreferences[UserPreferences.debuggingEnabled],
|
||||
baseDataSourceFactory = get<HttpDataSource.Factory>(),
|
||||
)
|
||||
@@ -82,7 +87,7 @@ fun Scope.createPlaybackManager() = playbackManager(androidContext()) {
|
||||
install(media3SessionPlugin(get(), mediaSessionOptions))
|
||||
|
||||
val deviceProfileBuilder = { createDeviceProfile(androidContext(), userPreferences, get()) }
|
||||
install(jellyfinPlugin(get(), deviceProfileBuilder, ProcessLifecycleOwner.get().lifecycle))
|
||||
install(jellyfinPlugin(get(), deviceProfileBuilder, setOf(MediaSegmentType.INTRO), ProcessLifecycleOwner.get().lifecycle))
|
||||
|
||||
// Options
|
||||
val userSettingPreferences = get<UserSettingPreferences>()
|
||||
|
||||
@@ -42,8 +42,8 @@ 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.playback.jellyfin.lyrics.lyrics
|
||||
import org.jellyfin.playback.jellyfin.lyrics.lyricsFlow
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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.BackdropBehavior
|
||||
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
|
||||
@@ -42,19 +41,9 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
var appTheme = enumPreference("app_theme", AppTheme.DARK)
|
||||
|
||||
/**
|
||||
* Enable background images while browsing
|
||||
* Behavior of app background while browsing
|
||||
*/
|
||||
var backdropEnabled = booleanPreference("pref_show_backdrop", true)
|
||||
|
||||
/**
|
||||
* Show premieres on home screen
|
||||
*/
|
||||
var premieresEnabled = booleanPreference("pref_enable_premieres", false)
|
||||
|
||||
/**
|
||||
* Enable management of media like deleting items when the user has sufficient permissions.
|
||||
*/
|
||||
var mediaManagementEnabled = booleanPreference("enable_media_management", false)
|
||||
var backdropBehavior = enumPreference("backdrop_behavior", BackdropBehavior.BACKDROP_WITH_BLUR)
|
||||
|
||||
/* Playback - General*/
|
||||
/**
|
||||
@@ -99,6 +88,11 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
*/
|
||||
var useExternalPlayer = booleanPreference("external_player", false)
|
||||
|
||||
/**
|
||||
* Component name for the external playback application.
|
||||
*/
|
||||
var externalPlayerComponentName = stringPreference("external_player_component", "")
|
||||
|
||||
/**
|
||||
* Change refresh rate to match media when device supports it
|
||||
*/
|
||||
@@ -131,16 +125,6 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
*/
|
||||
var liveTvDirectPlayEnabled = booleanPreference("pref_live_direct", true)
|
||||
|
||||
/**
|
||||
* Shortcut used for changing the audio track
|
||||
*/
|
||||
var shortcutAudioTrack = intPreference("shortcut_audio_track", KeyEvent.KEYCODE_MEDIA_AUDIO_TRACK)
|
||||
|
||||
/**
|
||||
* Shortcut used for changing the subtitle track
|
||||
*/
|
||||
var shortcutSubtitleTrack = intPreference("shortcut_subtitle_track", KeyEvent.KEYCODE_CAPTIONS)
|
||||
|
||||
/* Developer options */
|
||||
/**
|
||||
* Show additional debug information
|
||||
@@ -157,11 +141,6 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
*/
|
||||
var clockBehavior = enumPreference("pref_clock_behavior", ClockBehavior.ALWAYS)
|
||||
|
||||
/**
|
||||
* Set which ratings provider should show on MyImageCardViews
|
||||
*/
|
||||
var defaultRatingType = enumPreference("pref_rating_type", RatingType.RATING_TOMATOES)
|
||||
|
||||
/**
|
||||
* Set when watched indicators should show on MyImageCardViews
|
||||
*/
|
||||
@@ -195,7 +174,7 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
/**
|
||||
* Subtitles font size
|
||||
*/
|
||||
var subtitlesTextSize = floatPreference("subtitles_text_size", 1f)
|
||||
var subtitlesTextSize = floatPreference("subtitles_text_size", 24f)
|
||||
|
||||
/**
|
||||
* Subtitles offset
|
||||
@@ -248,6 +227,11 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
*/
|
||||
var trickPlayEnabled = booleanPreference("trick_play_enabled", false)
|
||||
|
||||
/**
|
||||
* Enable libass.
|
||||
*/
|
||||
var assDirectPlay = booleanPreference("libass_enabled", false)
|
||||
|
||||
/**
|
||||
* Enable PGS subtitle direct-play.
|
||||
*/
|
||||
@@ -275,6 +259,19 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
val subtitleStrokeSize = it.getInt("subtitles_stroke_size", 0)
|
||||
putLong("subtitles_text_stroke_color", if (subtitleStrokeSize > 0) 0XFF000000L else 0X00FFFFFFL)
|
||||
}
|
||||
|
||||
// v0.19.0 to v0.20.0
|
||||
migration(toVersion = 9) {
|
||||
// Reset subtitle text size as we changed from fractional sizing to absolute sizing
|
||||
remove("subtitles_text_size")
|
||||
|
||||
// Set the BackdropBehavior if it was enabled in a previous version
|
||||
val backdropEnabled = it.getBoolean("pref_show_backdrop", true)
|
||||
putString(
|
||||
"backdrop_behavior",
|
||||
if (backdropEnabled) BackdropBehavior.BACKDROP_WITH_BLUR.name else BackdropBehavior.DISABLED.name
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.jellyfin.androidtv.preference.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
|
||||
enum class BackdropBehavior(
|
||||
override val nameRes: Int,
|
||||
) : PreferenceEnum {
|
||||
DISABLED(R.string.state_disabled),
|
||||
|
||||
/**
|
||||
* When selected, backdrop is shown with a blur effect.
|
||||
*/
|
||||
BACKDROP_WITH_BLUR(R.string.backdrop_behavior_with_blur),
|
||||
|
||||
/**
|
||||
* When selected, backdrop is shown without any blur effect.
|
||||
*/
|
||||
BACKDROP_WITHOUT_BLUR(R.string.backdrop_behavior_without_blur),
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package org.jellyfin.androidtv.preference.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
|
||||
enum class RatingType(
|
||||
override val nameRes: Int,
|
||||
) : PreferenceEnum {
|
||||
/**
|
||||
* Sets default rating type to tomatoes.
|
||||
*/
|
||||
RATING_TOMATOES(R.string.lbl_tomatoes),
|
||||
|
||||
/**
|
||||
* Sets the default rating type to stars.
|
||||
*/
|
||||
RATING_STARS(R.string.lbl_stars),
|
||||
|
||||
/**
|
||||
* Sets the default rating type to hidden.
|
||||
*/
|
||||
RATING_HIDDEN(R.string.lbl_hidden),
|
||||
}
|
||||
@@ -4,16 +4,12 @@ import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.RelativeLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
import org.jellyfin.androidtv.databinding.ClockUserBugBinding
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.preference.constant.ClockBehavior
|
||||
import org.jellyfin.androidtv.ui.navigation.Destinations
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
|
||||
import org.jellyfin.androidtv.util.ImageHelper
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
@@ -25,9 +21,7 @@ class ClockUserView @JvmOverloads constructor(
|
||||
) : RelativeLayout(context, attrs, defStyleAttr, defStyleRes), KoinComponent {
|
||||
private val binding: ClockUserBugBinding = ClockUserBugBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
private val userPreferences by inject<UserPreferences>()
|
||||
private val userRepository by inject<UserRepository>()
|
||||
private val navigationRepository by inject<NavigationRepository>()
|
||||
private val imageHelper by inject<ImageHelper>()
|
||||
|
||||
var isVideoPlayer = false
|
||||
set(value) {
|
||||
@@ -40,15 +34,6 @@ class ClockUserView @JvmOverloads constructor(
|
||||
init {
|
||||
updateClockVisibility()
|
||||
|
||||
val currentUser = userRepository.currentUser.value
|
||||
|
||||
binding.clockUserImage.load(
|
||||
url = currentUser?.let(imageHelper::getPrimaryImageUrl),
|
||||
placeholder = ContextCompat.getDrawable(context, R.drawable.ic_user)
|
||||
)
|
||||
|
||||
binding.clockUserImage.isVisible = currentUser != null
|
||||
|
||||
binding.home.setOnClickListener {
|
||||
navigationRepository.reset(Destinations.home, clearHistory = true)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ 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.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.AbstractComposeView
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -33,6 +34,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.base.JellyfinTheme
|
||||
import org.jellyfin.androidtv.ui.base.ProvideTextStyle
|
||||
import org.jellyfin.androidtv.ui.base.Text
|
||||
import org.jellyfin.androidtv.ui.base.button.ButtonBase
|
||||
@@ -90,6 +92,7 @@ fun NowPlayingComposable(
|
||||
.padding(3.dp)
|
||||
) {
|
||||
val image = item.itemImages[ImageType.PRIMARY] ?: item.albumPrimaryImage ?: item.parentImages[ImageType.PRIMARY]
|
||||
val progressFillColor = JellyfinTheme.colorScheme.rangeControlFill
|
||||
|
||||
AsyncImage(
|
||||
url = image?.getUrl(api),
|
||||
@@ -102,16 +105,18 @@ fun NowPlayingComposable(
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
|
||||
val width = 3.dp.toPx()
|
||||
|
||||
// Background
|
||||
drawCircle(
|
||||
style = Stroke(width = 3.dp.toPx()),
|
||||
style = Stroke(width),
|
||||
color = Color.Black,
|
||||
alpha = 0.4f,
|
||||
)
|
||||
// Foreground
|
||||
drawArc(
|
||||
style = Stroke(width = 3.dp.toPx()),
|
||||
color = Color.White,
|
||||
style = Stroke(width, cap = StrokeCap.Round),
|
||||
color = progressFillColor,
|
||||
useCenter = false,
|
||||
startAngle = -90f,
|
||||
sweepAngle = 360f * progress,
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package org.jellyfin.androidtv.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.FrameLayout
|
||||
import org.jellyfin.androidtv.databinding.ViewNumberSpinnerBinding
|
||||
|
||||
class NumberSpinnerView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
defStyleRes: Int = 0,
|
||||
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
|
||||
val binding = ViewNumberSpinnerBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
|
||||
var increment = 10L
|
||||
var valueChangedListener: ValueChangedListener<Long>? = null
|
||||
|
||||
var value = 0L
|
||||
set(value) {
|
||||
field = value
|
||||
binding.value.text = value.toString()
|
||||
valueChangedListener?.onValueChanged(value)
|
||||
}
|
||||
|
||||
init {
|
||||
binding.increase.setOnClickListener { value += increment }
|
||||
binding.decrease.setOnClickListener { value -= increment }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package org.jellyfin.androidtv.ui
|
||||
|
||||
fun interface ValueChangedListener<T> {
|
||||
fun onValueChanged(value: T)
|
||||
}
|
||||
36
app/src/main/java/org/jellyfin/androidtv/ui/base/Badge.kt
Normal file
36
app/src/main/java/org/jellyfin/androidtv/ui/base/Badge.kt
Normal file
@@ -0,0 +1,36 @@
|
||||
package org.jellyfin.androidtv.ui.base
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
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.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.design.Tokens
|
||||
|
||||
@Composable
|
||||
fun Badge(
|
||||
modifier: Modifier = Modifier,
|
||||
shape: Shape = CircleShape,
|
||||
containerColor: Color = JellyfinTheme.colorScheme.badge,
|
||||
contentColor: Color = JellyfinTheme.colorScheme.onBadge,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
ProvideTextStyle(JellyfinTheme.typography.badge.copy(color = contentColor)) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.defaultMinSize(minWidth = 24.dp, minHeight = 24.dp)
|
||||
.background(containerColor, shape)
|
||||
.padding(Tokens.Space.spaceXs),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.jellyfin.androidtv.ui.base
|
||||
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
|
||||
@Composable
|
||||
@Stable
|
||||
fun BaseScreen(
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
// Work around a focus issue with our current fragment based navigation approach
|
||||
// by always focusing the screen contents whenever the screen is created
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.focusGroup()
|
||||
) {
|
||||
content()
|
||||
}
|
||||
LaunchedEffect(focusRequester) { focusRequester.requestFocus() }
|
||||
}
|
||||
@@ -17,8 +17,8 @@ import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
|
||||
@Composable
|
||||
fun CircularProgressIndicator(
|
||||
color: Color = LocalTextStyle.current.color,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = LocalTextStyle.current.color,
|
||||
) {
|
||||
val infiniteTransition = rememberInfiniteTransition()
|
||||
val rotation by infiniteTransition.animateFloat(
|
||||
|
||||
@@ -16,10 +16,11 @@ fun JellyfinTheme(
|
||||
LocalColorScheme provides colorScheme,
|
||||
LocalShapes provides shapes,
|
||||
LocalTypography provides typography,
|
||||
LocalTextStyle provides LocalTextStyle.current.merge(typography.default),
|
||||
// Disable overscroll
|
||||
LocalOverscrollFactory provides null,
|
||||
) {
|
||||
ProvideTextStyle(value = typography.default, content = content)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.jellyfin.androidtv.ui.base
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.compose.rememberAsyncImagePainter
|
||||
import org.jellyfin.androidtv.R
|
||||
|
||||
@Composable
|
||||
fun ProfilePicture(
|
||||
url: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
iconPadding: PaddingValues = PaddingValues.Zero,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
) {
|
||||
val userImagePainter = rememberAsyncImagePainter(url)
|
||||
val userImageState by userImagePainter.state.collectAsState()
|
||||
val userImageVisible = userImageState is AsyncImagePainter.State.Success
|
||||
|
||||
if (!userImageVisible) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_user),
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(iconPadding)
|
||||
.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
painter = userImagePainter,
|
||||
contentDescription = contentDescription,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,10 +41,10 @@ object SeekbarDefaults {
|
||||
@ReadOnlyComposable
|
||||
@Composable
|
||||
fun colors(
|
||||
backgroundColor: Color = Color(0xCC4B4B4B),
|
||||
bufferColor: Color = Color(0x40FFFFFF),
|
||||
progressColor: Color = Color(0xFF00A4DC),
|
||||
knobColor: Color = Color.White,
|
||||
backgroundColor: Color = JellyfinTheme.colorScheme.rangeControlBackground,
|
||||
bufferColor: Color = JellyfinTheme.colorScheme.seekbarBuffer,
|
||||
progressColor: Color = JellyfinTheme.colorScheme.rangeControlFill,
|
||||
knobColor: Color = JellyfinTheme.colorScheme.rangeControlKnob,
|
||||
) = SeekbarColors(
|
||||
backgroundColor = backgroundColor,
|
||||
bufferColor = bufferColor,
|
||||
@@ -67,7 +67,7 @@ fun Seekbar(
|
||||
enabled: Boolean = true,
|
||||
colors: SeekbarColors = SeekbarDefaults.colors(),
|
||||
) {
|
||||
val durationMs = duration.inWholeMilliseconds.toFloat()
|
||||
val durationMs = duration.inWholeMilliseconds.toFloat().coerceAtLeast(1f)
|
||||
val progressPercentage = progress.inWholeMilliseconds.toFloat() / durationMs
|
||||
val bufferPercentage = buffer.inWholeMilliseconds.toFloat() / durationMs
|
||||
val seekForwardPercentage = seekForwardAmount.inWholeMilliseconds.toFloat() / durationMs
|
||||
|
||||
@@ -3,10 +3,11 @@ package org.jellyfin.androidtv.ui.base
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.jellyfin.design.Tokens
|
||||
|
||||
fun colorScheme(): ColorScheme = ColorScheme(
|
||||
background = Color(0xFF101010),
|
||||
onBackground = Color(0xFFFFFFFF),
|
||||
background = Tokens.Color.colorGrey975,
|
||||
onBackground = Tokens.Color.colorBluegrey25,
|
||||
button = Color(0xB3747474),
|
||||
onButton = Color(0xFFDDDDDD),
|
||||
buttonFocused = Color(0xE6CCCCCC),
|
||||
@@ -19,9 +20,22 @@ fun colorScheme(): ColorScheme = ColorScheme(
|
||||
onInput = Color(0xE6CCCCCC),
|
||||
inputFocused = Color(0xE6CCCCCC),
|
||||
onInputFocused = Color(0xFFDDDDDD),
|
||||
recording = Color(0xB3FF7474),
|
||||
onRecording = Color(0xFFDDDDDD),
|
||||
popover = Color(0xFF212225),
|
||||
rangeControlBackground = Tokens.Color.colorBluegrey700,
|
||||
rangeControlFill = Tokens.Color.colorCyan500,
|
||||
rangeControlKnob = Tokens.Color.colorBluegrey100,
|
||||
seekbarBuffer = Tokens.Color.colorBluegrey300,
|
||||
recording = Tokens.Color.colorRed300,
|
||||
onRecording = Tokens.Color.colorRed25,
|
||||
badge = Tokens.Color.colorCyan500,
|
||||
onBadge = Tokens.Color.colorBluegrey100,
|
||||
listHeader = Tokens.Color.colorGrey50,
|
||||
listOverline = Tokens.Color.colorGrey500,
|
||||
listHeadline = Tokens.Color.colorGrey25,
|
||||
listCaption = Tokens.Color.colorGrey200,
|
||||
listButton = Color.Transparent,
|
||||
listButtonFocused = Tokens.Color.colorBluegrey800,
|
||||
surface = Tokens.Color.colorBluegrey900,
|
||||
scrim = Tokens.Color.colorBlack.copy(alpha = 0.67f),
|
||||
)
|
||||
|
||||
@Immutable
|
||||
@@ -43,10 +57,26 @@ data class ColorScheme(
|
||||
val inputFocused: Color,
|
||||
val onInputFocused: Color,
|
||||
|
||||
val rangeControlBackground: Color,
|
||||
val rangeControlFill: Color,
|
||||
val rangeControlKnob: Color,
|
||||
val seekbarBuffer: Color,
|
||||
|
||||
val recording: Color,
|
||||
val onRecording: Color,
|
||||
|
||||
val popover: Color,
|
||||
val badge: Color,
|
||||
val onBadge: Color,
|
||||
|
||||
val listHeader: Color,
|
||||
val listOverline: Color,
|
||||
val listHeadline: Color,
|
||||
val listCaption: Color,
|
||||
val listButton: Color,
|
||||
val listButtonFocused: Color,
|
||||
|
||||
val surface: Color,
|
||||
val scrim: Color,
|
||||
)
|
||||
|
||||
val LocalColorScheme = staticCompositionLocalOf { colorScheme() }
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package org.jellyfin.androidtv.ui.base.dialog
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
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.fillMaxSize
|
||||
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.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import org.jellyfin.androidtv.ui.base.JellyfinTheme
|
||||
import org.jellyfin.androidtv.ui.base.interactionTracker
|
||||
|
||||
@Composable
|
||||
fun DialogBase(
|
||||
visible: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
scrimColor: Color = JellyfinTheme.colorScheme.scrim,
|
||||
enterTransition: EnterTransition = fadeIn(tween(300)),
|
||||
exitTransition: ExitTransition = fadeOut(tween(300)),
|
||||
contentAlignment: Alignment = Alignment.Center,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val transition = updateTransition(visible)
|
||||
val alpha by transition.animateFloat(
|
||||
transitionSpec = { tween(1000) },
|
||||
targetValueByState = { visible -> if (visible) 1f else 0f }
|
||||
)
|
||||
|
||||
if (transition.currentState || transition.isRunning) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var contentVisible by remember { mutableStateOf(false) }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true,
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scrimColor.copy(alpha = scrimColor.alpha * alpha))
|
||||
.focusRequester(focusRequester)
|
||||
.focusGroup()
|
||||
.interactionTracker(),
|
||||
contentAlignment = contentAlignment,
|
||||
) {
|
||||
transition.AnimatedVisibility(
|
||||
visible = { it && contentVisible },
|
||||
enter = enterTransition,
|
||||
exit = exitTransition,
|
||||
) {
|
||||
content()
|
||||
LaunchedEffect(focusRequester) { focusRequester.requestFocus() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(contentVisible) { contentVisible = true }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.jellyfin.androidtv.ui.base.form
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.Shape
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun Checkbox(
|
||||
checked: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
shape: Shape = JellyfinTheme.shapes.extraSmall,
|
||||
containerColor: Color = JellyfinTheme.colorScheme.button,
|
||||
contentColor: Color = JellyfinTheme.colorScheme.onButton
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.defaultMinSize(minWidth = 18.dp, minHeight = 18.dp)
|
||||
.background(if (checked) containerColor else Color.Unspecified, shape)
|
||||
.border(if (checked) 0.dp else 2.dp, containerColor, shape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = checked,
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_check),
|
||||
tint = contentColor,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.jellyfin.androidtv.ui.base.form
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ColorSwatch(
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
shape: Shape = CircleShape,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.defaultMinSize(minWidth = 18.dp, minHeight = 18.dp)
|
||||
.clip(shape)
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
) {
|
||||
if (color.alpha < 1f) {
|
||||
val squareSize = 6.dp.toPx()
|
||||
repeat((size.height / squareSize).toInt()) { row ->
|
||||
repeat((size.width / squareSize).toInt()) { col ->
|
||||
val isLight = (row + col) % 2 == 0
|
||||
|
||||
drawRect(
|
||||
color = if (isLight) Color.LightGray else Color.DarkGray,
|
||||
topLeft = Offset(col * squareSize, row * squareSize),
|
||||
size = Size(squareSize, squareSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawRect(
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.jellyfin.androidtv.ui.base.form
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun RadioButton(
|
||||
checked: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
shape: Shape = CircleShape,
|
||||
containerColor: Color = JellyfinTheme.colorScheme.button,
|
||||
contentColor: Color = JellyfinTheme.colorScheme.onButton
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.defaultMinSize(minWidth = 18.dp, minHeight = 18.dp)
|
||||
.background(if (checked) containerColor else Color.Unspecified, shape)
|
||||
.border(if (checked) 0.dp else 2.dp, containerColor, shape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = checked,
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_check),
|
||||
tint = contentColor,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package org.jellyfin.androidtv.ui.base.form
|
||||
|
||||
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.remember
|
||||
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 org.jellyfin.androidtv.ui.base.JellyfinTheme
|
||||
|
||||
@Immutable
|
||||
data class RangeControlColors(
|
||||
val backgroundColor: Color,
|
||||
val fillColor: Color,
|
||||
val knobColor: Color,
|
||||
)
|
||||
|
||||
object RangeControlDefaults {
|
||||
@ReadOnlyComposable
|
||||
@Composable
|
||||
fun colors(
|
||||
backgroundColor: Color = JellyfinTheme.colorScheme.rangeControlBackground,
|
||||
fillColor: Color = JellyfinTheme.colorScheme.rangeControlFill,
|
||||
knobColor: Color = JellyfinTheme.colorScheme.rangeControlKnob,
|
||||
) = RangeControlColors(
|
||||
backgroundColor = backgroundColor,
|
||||
fillColor = fillColor,
|
||||
knobColor = knobColor,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RangeControl(
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
min: Float = 0f,
|
||||
max: Float = 1f,
|
||||
value: Float = 0f,
|
||||
stepForward: Float = 0.01f,
|
||||
stepBackward: Float = stepForward,
|
||||
onValueChange: ((vakye: Float) -> Unit)? = null,
|
||||
enabled: Boolean = true,
|
||||
colors: RangeControlColors = RangeControlDefaults.colors(),
|
||||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val knobSize by animateFloatAsState(if (focused) 1.75f else 1f)
|
||||
|
||||
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 isKeyDown = it.type == KeyEventType.KeyDown
|
||||
|
||||
val newValue = when {
|
||||
isKeyDown && isForward -> (value + stepForward).coerceAtMost(max)
|
||||
isKeyDown && isRewind -> (value - stepBackward).coerceAtLeast(min)
|
||||
else -> value
|
||||
}
|
||||
|
||||
if (value != newValue && onValueChange != null) onValueChange(newValue)
|
||||
|
||||
return@onKeyEvent isScrubbing
|
||||
}
|
||||
.focusable(interactionSource = interactionSource, enabled = enabled)
|
||||
.drawWithContent {
|
||||
val barCornerRadius = CornerRadius(size.minDimension, size.minDimension)
|
||||
|
||||
// Background bar
|
||||
drawRoundRect(
|
||||
color = colors.backgroundColor,
|
||||
cornerRadius = barCornerRadius,
|
||||
)
|
||||
|
||||
// Get percental value based on min/max options
|
||||
val valuePercentage = (value - min) / (max - min)
|
||||
|
||||
// Value fill bar
|
||||
if (valuePercentage > 0) {
|
||||
drawRoundRect(
|
||||
color = colors.fillColor,
|
||||
size = size.copy(
|
||||
width = valuePercentage * size.width,
|
||||
),
|
||||
cornerRadius = barCornerRadius,
|
||||
)
|
||||
}
|
||||
|
||||
// Progress knob
|
||||
drawCircle(
|
||||
color = colors.knobColor,
|
||||
center = center.copy(
|
||||
x = valuePercentage * size.width,
|
||||
),
|
||||
radius = size.minDimension * knobSize,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.jellyfin.androidtv.ui.base
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
|
||||
typealias InteractionTracker = () -> Unit
|
||||
|
||||
private val DefaultInteractionTracker: InteractionTracker = {}
|
||||
|
||||
val LocalInteractionTracker = staticCompositionLocalOf<InteractionTracker> { DefaultInteractionTracker }
|
||||
|
||||
@Composable
|
||||
fun ProvideLocalInteractionTracker(
|
||||
interactionTracker: InteractionTracker,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalInteractionTracker provides interactionTracker
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.interactionTracker(interactionTracker),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.interactionTracker(
|
||||
interactionTracker: InteractionTracker = LocalInteractionTracker.current,
|
||||
): Modifier = onPreviewKeyEvent {
|
||||
interactionTracker()
|
||||
false
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.jellyfin.androidtv.ui.base.list
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
|
||||
@Composable
|
||||
fun ListButton(
|
||||
onClick: () -> Unit,
|
||||
headingContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
colors: ListControlColors = ListControlDefaults.colors(),
|
||||
overlineContent: (@Composable () -> Unit)? = null,
|
||||
captionContent: (@Composable () -> Unit)? = null,
|
||||
leadingContent: (@Composable () -> Unit)? = null,
|
||||
trailingContent: (@Composable () -> Unit)? = null,
|
||||
footerContent: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
|
||||
|
||||
ListControl(
|
||||
headingContent = headingContent,
|
||||
modifier = modifier.combinedClickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
enabled = enabled,
|
||||
role = Role.Button,
|
||||
onClick = onClick,
|
||||
),
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
colors = colors,
|
||||
overlineContent = overlineContent,
|
||||
captionContent = captionContent,
|
||||
leadingContent = leadingContent,
|
||||
trailingContent = trailingContent,
|
||||
footerContent = footerContent,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package org.jellyfin.androidtv.ui.base.list
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.InteractionSource
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.jellyfin.androidtv.ui.base.JellyfinTheme
|
||||
import org.jellyfin.androidtv.ui.base.LocalShapes
|
||||
|
||||
object ListControlDefaults {
|
||||
@ReadOnlyComposable
|
||||
@Composable
|
||||
fun colors(
|
||||
containerColor: Color = JellyfinTheme.colorScheme.listButton,
|
||||
focusedContainerColor: Color = JellyfinTheme.colorScheme.listButtonFocused,
|
||||
) = ListControlColors(
|
||||
containerColor = containerColor,
|
||||
focusedContainerColor = focusedContainerColor,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ListControl(
|
||||
headingContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
interactionSource: InteractionSource? = null,
|
||||
colors: ListControlColors = ListControlDefaults.colors(),
|
||||
overlineContent: (@Composable () -> Unit)? = null,
|
||||
captionContent: (@Composable () -> Unit)? = null,
|
||||
leadingContent: (@Composable () -> Unit)? = null,
|
||||
trailingContent: (@Composable () -> Unit)? = null,
|
||||
footerContent: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val pressed by interactionSource.collectIsPressedAsState()
|
||||
|
||||
val backgroundColor = when {
|
||||
!enabled -> colors.containerColor
|
||||
pressed -> colors.focusedContainerColor
|
||||
focused -> colors.focusedContainerColor
|
||||
else -> colors.containerColor
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, LocalShapes.current.large)
|
||||
.clip(LocalShapes.current.large)
|
||||
.alpha(if (enabled) 1f else 0.4f),
|
||||
) {
|
||||
ListItemContent(
|
||||
headingContent = headingContent,
|
||||
overlineContent = overlineContent,
|
||||
captionContent = captionContent,
|
||||
leadingContent = leadingContent,
|
||||
trailingContent = trailingContent,
|
||||
footerContent = footerContent,
|
||||
headingStyle = JellyfinTheme.typography.listHeadline
|
||||
.copy(color = JellyfinTheme.colorScheme.listHeadline),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.jellyfin.androidtv.ui.base.list
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@Immutable
|
||||
data class ListControlColors(
|
||||
val containerColor: Color,
|
||||
val focusedContainerColor: Color,
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
package org.jellyfin.androidtv.ui.base.list
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jellyfin.androidtv.ui.base.JellyfinTheme
|
||||
import org.jellyfin.androidtv.ui.base.LocalTextStyle
|
||||
import org.jellyfin.androidtv.ui.base.ProvideTextStyle
|
||||
import org.jellyfin.design.Tokens
|
||||
|
||||
@Composable
|
||||
fun ListItemContent(
|
||||
modifier: Modifier = Modifier,
|
||||
headingContent: @Composable () -> Unit,
|
||||
overlineContent: (@Composable () -> Unit)? = null,
|
||||
captionContent: (@Composable () -> Unit)? = null,
|
||||
leadingContent: (@Composable () -> Unit)? = null,
|
||||
trailingContent: (@Composable () -> Unit)? = null,
|
||||
footerContent: (@Composable () -> Unit)? = null,
|
||||
headingStyle: TextStyle,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
// TODO: Add suitable space token for this padding
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
leadingContent?.let { content ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.sizeIn(minWidth = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
content = {
|
||||
ProvideTextStyle(LocalTextStyle.current.copy(color = JellyfinTheme.colorScheme.listCaption)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(Modifier.width(Tokens.Space.spaceMd))
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f),
|
||||
) {
|
||||
overlineContent?.let { content ->
|
||||
ProvideTextStyle(JellyfinTheme.typography.listOverline.copy(color = JellyfinTheme.colorScheme.listOverline)) {
|
||||
content()
|
||||
}
|
||||
Spacer(Modifier.height(Tokens.Space.space2xs))
|
||||
}
|
||||
|
||||
ProvideTextStyle(headingStyle) {
|
||||
headingContent()
|
||||
}
|
||||
|
||||
captionContent?.let { content ->
|
||||
Spacer(Modifier.height(Tokens.Space.spaceXs))
|
||||
ProvideTextStyle(JellyfinTheme.typography.listCaption.copy(color = JellyfinTheme.colorScheme.listCaption)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trailingContent?.let { content ->
|
||||
Spacer(Modifier.width(Tokens.Space.spaceMd))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.sizeIn(minWidth = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
content = { content() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
footerContent?.let { content ->
|
||||
Spacer(Modifier.height(Tokens.Space.spaceXs))
|
||||
|
||||
ProvideTextStyle(LocalTextStyle.current.copy(color = JellyfinTheme.colorScheme.listCaption)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.jellyfin.androidtv.ui.base.list
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jellyfin.androidtv.ui.base.JellyfinTheme
|
||||
import org.jellyfin.androidtv.ui.base.LocalTextStyle
|
||||
import org.jellyfin.androidtv.ui.base.ProvideTextStyle
|
||||
|
||||
@Composable
|
||||
fun ListMessage(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
// TODO: Add suitable space token for this padding
|
||||
.padding(12.dp),
|
||||
) {
|
||||
ProvideTextStyle(LocalTextStyle.current.copy(color = JellyfinTheme.colorScheme.listCaption)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.jellyfin.androidtv.ui.base.list
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.jellyfin.androidtv.ui.base.JellyfinTheme
|
||||
|
||||
@Composable
|
||||
fun ListSection(
|
||||
modifier: Modifier = Modifier,
|
||||
headingContent: @Composable () -> Unit,
|
||||
overlineContent: (@Composable () -> Unit)? = null,
|
||||
captionContent: (@Composable () -> Unit)? = null,
|
||||
leadingContent: (@Composable () -> Unit)? = null,
|
||||
trailingContent: (@Composable () -> Unit)? = null,
|
||||
footerContent: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
ListItemContent(
|
||||
headingContent = headingContent,
|
||||
overlineContent = overlineContent,
|
||||
captionContent = captionContent,
|
||||
leadingContent = leadingContent,
|
||||
trailingContent = trailingContent,
|
||||
footerContent = footerContent,
|
||||
headingStyle = JellyfinTheme.typography.listHeader
|
||||
.copy(color = JellyfinTheme.colorScheme.listHeader),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -26,6 +26,7 @@ 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
|
||||
import org.jellyfin.androidtv.ui.base.interactionTracker
|
||||
|
||||
object PopoverDefaults {
|
||||
val Shape: Shape = RoundedCornerShape(4.dp)
|
||||
@@ -39,11 +40,10 @@ fun Popover(
|
||||
alignment: Alignment = Alignment.TopStart,
|
||||
offset: DpOffset = DpOffset.Zero,
|
||||
shape: Shape = PopoverDefaults.Shape,
|
||||
backgroundColor: Color = JellyfinTheme.colorScheme.popover,
|
||||
backgroundColor: Color = JellyfinTheme.colorScheme.surface,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val popupPositionProvider = remember(alignment, density, offset) {
|
||||
PopoverMenuPositionProvider(
|
||||
alignment = alignment,
|
||||
@@ -60,6 +60,8 @@ fun Popover(
|
||||
)
|
||||
|
||||
if (alpha != 0f) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Popup(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties = PopupProperties(
|
||||
@@ -87,6 +89,7 @@ fun Popover(
|
||||
.wrapContentSize()
|
||||
.focusRequester(focusRequester)
|
||||
.focusGroup()
|
||||
.interactionTracker()
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
@@ -3,14 +3,51 @@ package org.jellyfin.androidtv.ui.base
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
object TypographyDefaults {
|
||||
val Default: TextStyle = TextStyle.Default
|
||||
|
||||
val ListHeader: TextStyle = Default.copy(
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp,
|
||||
fontWeight = FontWeight.W700,
|
||||
)
|
||||
val ListOverline: TextStyle = Default.copy(
|
||||
fontSize = 10.sp,
|
||||
lineHeight = 12.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
letterSpacing = 0.65.sp,
|
||||
)
|
||||
val ListHeadline: TextStyle = Default.copy(
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 28.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
)
|
||||
val ListCaption: TextStyle = Default.copy(
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 14.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
letterSpacing = 0.1.sp,
|
||||
)
|
||||
|
||||
val Badge: TextStyle = Default.copy(
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.W700,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class Typography(
|
||||
val default: TextStyle = TypographyDefaults.Default,
|
||||
val listHeader: TextStyle = TypographyDefaults.ListHeader,
|
||||
val listOverline: TextStyle = TypographyDefaults.ListOverline,
|
||||
val listHeadline: TextStyle = TypographyDefaults.ListHeadline,
|
||||
val listCaption: TextStyle = TypographyDefaults.ListCaption,
|
||||
val badge: TextStyle = TypographyDefaults.Badge
|
||||
)
|
||||
|
||||
val LocalTypography = staticCompositionLocalOf { Typography() }
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.leanback.widget.BaseGridView;
|
||||
import androidx.leanback.widget.FocusHighlight;
|
||||
import androidx.leanback.widget.OnItemViewClickedListener;
|
||||
import androidx.leanback.widget.OnItemViewSelectedListener;
|
||||
import androidx.leanback.widget.Presenter;
|
||||
@@ -39,7 +40,6 @@ import org.jellyfin.androidtv.constant.QueryType;
|
||||
import org.jellyfin.androidtv.data.model.FilterOptions;
|
||||
import org.jellyfin.androidtv.data.querying.GetUserViewsRequest;
|
||||
import org.jellyfin.androidtv.data.repository.CustomMessageRepository;
|
||||
import org.jellyfin.androidtv.data.repository.UserViewsRepository;
|
||||
import org.jellyfin.androidtv.data.service.BackgroundService;
|
||||
import org.jellyfin.androidtv.databinding.HorizontalGridBrowseBinding;
|
||||
import org.jellyfin.androidtv.databinding.PopupEmptyBinding;
|
||||
@@ -50,8 +50,6 @@ 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.ActivityDestinations;
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepository;
|
||||
import org.jellyfin.androidtv.ui.presentation.CardPresenter;
|
||||
import org.jellyfin.androidtv.ui.presentation.HorizontalGridPresenter;
|
||||
import org.jellyfin.androidtv.util.CoroutineUtils;
|
||||
@@ -74,6 +72,7 @@ import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
import kotlin.Lazy;
|
||||
import kotlinx.coroutines.flow.MutableStateFlow;
|
||||
import kotlinx.serialization.json.Json;
|
||||
import timber.log.Timber;
|
||||
|
||||
@@ -115,9 +114,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
|
||||
private final Lazy<BackgroundService> backgroundService = inject(BackgroundService.class);
|
||||
private final Lazy<PreferencesRepository> preferencesRepository = inject(PreferencesRepository.class);
|
||||
private final Lazy<UserViewsRepository> userViewsRepository = inject(UserViewsRepository.class);
|
||||
private final Lazy<CustomMessageRepository> customMessageRepository = inject(CustomMessageRepository.class);
|
||||
private final Lazy<NavigationRepository> navigationRepository = inject(NavigationRepository.class);
|
||||
private final Lazy<ItemLauncher> itemLauncher = inject(ItemLauncher.class);
|
||||
private final Lazy<KeyProcessor> keyProcessor = inject(KeyProcessor.class);
|
||||
private final Lazy<ApiClient> api = inject(ApiClient.class);
|
||||
@@ -155,7 +152,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
mCardFocusScale = getResources().getFraction(R.fraction.card_scale_focus, 1, 1);
|
||||
|
||||
if (mGridDirection.equals(GridDirection.VERTICAL))
|
||||
setGridPresenter(new VerticalGridPresenter());
|
||||
setGridPresenter(new VerticalGridPresenter(FocusHighlight.ZOOM_FACTOR_LARGE, false));
|
||||
else
|
||||
setGridPresenter(new HorizontalGridPresenter());
|
||||
|
||||
@@ -255,6 +252,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
}
|
||||
gridPresenter.setOnItemViewSelectedListener(mRowSelectedListener);
|
||||
gridPresenter.setOnItemViewClickedListener(mClickedListener);
|
||||
gridPresenter.setShadowEnabled(false);
|
||||
mGridPresenter = gridPresenter;
|
||||
}
|
||||
|
||||
@@ -267,6 +265,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
}
|
||||
gridPresenter.setOnItemViewSelectedListener(mRowSelectedListener);
|
||||
gridPresenter.setOnItemViewClickedListener(mClickedListener);
|
||||
gridPresenter.setShadowEnabled(false);
|
||||
mGridPresenter = gridPresenter;
|
||||
}
|
||||
|
||||
@@ -577,7 +576,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
mGridDirection = gridDirection;
|
||||
|
||||
if (mGridDirection.equals(GridDirection.VERTICAL) && (mGridPresenter == null || !(mGridPresenter instanceof VerticalGridPresenter))) {
|
||||
setGridPresenter(new VerticalGridPresenter());
|
||||
setGridPresenter(new VerticalGridPresenter(FocusHighlight.ZOOM_FACTOR_LARGE, false));
|
||||
} else if (mGridDirection.equals(GridDirection.HORIZONTAL) && (mGridPresenter == null || !(mGridPresenter instanceof HorizontalGridPresenter))) {
|
||||
setGridPresenter(new HorizontalGridPresenter());
|
||||
}
|
||||
@@ -608,8 +607,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
}
|
||||
|
||||
private void buildAdapter() {
|
||||
mCardPresenter = new CardPresenter(false, mImageType, mCardHeight);
|
||||
mCardPresenter.setUniformAspect(true); // make grid layouts always uniform
|
||||
mCardPresenter = new CardPresenter(false, mImageType, mCardHeight, true);
|
||||
|
||||
Timber.d("buildAdapter cardHeight <%s> getCardWidthBy <%s> chunks <%s> type <%s>", mCardHeight, (int) getCardWidthBy(mCardHeight, mImageType, mFolder), mRowDef.getChunkSize(), mRowDef.getQueryType().toString());
|
||||
|
||||
@@ -807,6 +805,8 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
mLetterButton.setContentDescription(getString(R.string.lbl_by_letter));
|
||||
binding.toolBar.addView(mLetterButton);
|
||||
|
||||
if (mFolder.getDisplayPreferencesId() != null) {
|
||||
MutableStateFlow<Boolean> settingsVisible = BrowseGridFragmentHelperKt.createSettingsVisibility(BrowseGridFragment.this);
|
||||
mSettingsButton = new ImageButton(requireContext(), null, 0, R.style.Button_Icon);
|
||||
mSettingsButton.setImageResource(R.drawable.ic_settings);
|
||||
mSettingsButton.setMaxHeight(size);
|
||||
@@ -814,12 +814,13 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
mSettingsButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
boolean allowViewSelection = userViewsRepository.getValue().allowViewSelection(mFolder.getCollectionType());
|
||||
startActivity(ActivityDestinations.INSTANCE.displayPreferences(getContext(), mFolder.getDisplayPreferencesId(), allowViewSelection));
|
||||
settingsVisible.setValue(true);
|
||||
}
|
||||
});
|
||||
mSettingsButton.setContentDescription(getString(R.string.lbl_settings));
|
||||
binding.toolBar.addView(mSettingsButton);
|
||||
BrowseGridFragmentHelperKt.addSettings(BrowseGridFragment.this, binding.settings, mFolder.getId(), mFolder.getDisplayPreferencesId(), settingsVisible);
|
||||
}
|
||||
}
|
||||
|
||||
class JumplistPopup {
|
||||
@@ -878,7 +879,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
|
||||
private void refreshCurrentItem() {
|
||||
if (mCurrentItem == null) return;
|
||||
Timber.d("Refresh item \"%s\"", mCurrentItem.getFullName(requireContext()));
|
||||
Timber.i("Refresh item \"%s\"", mCurrentItem.getFullName(requireContext()));
|
||||
ItemRowAdapterHelperKt.refreshItem(mAdapter, api.getValue(), this, mCurrentItem, () -> {
|
||||
//Now - if filtered make sure we still pass
|
||||
if (mAdapter.getFilters() == null) return null;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.jellyfin.androidtv.ui.browsing
|
||||
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.jellyfin.androidtv.ui.navigation.ProvideRouter
|
||||
import org.jellyfin.androidtv.ui.settings.Routes
|
||||
import org.jellyfin.androidtv.ui.settings.composable.SettingsDialog
|
||||
import org.jellyfin.androidtv.ui.settings.composable.SettingsRouterContent
|
||||
import org.jellyfin.androidtv.ui.settings.routes
|
||||
import java.util.UUID
|
||||
|
||||
fun BrowseGridFragment.createSettingsVisibility() = MutableStateFlow(false)
|
||||
|
||||
fun BrowseGridFragment.addSettings(
|
||||
view: ComposeView,
|
||||
itemId: UUID,
|
||||
displayPreferencesId: String,
|
||||
visible: MutableStateFlow<Boolean>,
|
||||
) {
|
||||
view.setContent {
|
||||
val isVisible by visible.collectAsState(false)
|
||||
|
||||
ProvideRouter(
|
||||
routes,
|
||||
Routes.LIBRARIES_DISPLAY,
|
||||
mapOf("itemId" to itemId.toString(), "displayPreferencesId" to displayPreferencesId)
|
||||
) {
|
||||
SettingsDialog(
|
||||
visible = isVisible,
|
||||
onDismissRequest = {
|
||||
visible.value = false
|
||||
onResume()
|
||||
}
|
||||
) {
|
||||
SettingsRouterContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import org.jellyfin.androidtv.constant.Extras;
|
||||
import org.jellyfin.androidtv.constant.LiveTvOption;
|
||||
import org.jellyfin.androidtv.constant.QueryType;
|
||||
import org.jellyfin.androidtv.data.querying.GetSeriesTimersRequest;
|
||||
import org.jellyfin.androidtv.preference.UserPreferences;
|
||||
import org.jellyfin.androidtv.ui.GridButton;
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter;
|
||||
import org.jellyfin.androidtv.ui.presentation.GridButtonPresenter;
|
||||
@@ -65,11 +64,6 @@ public class BrowseViewFragment extends EnhancedBrowseFragment {
|
||||
GetNextUpRequest getNextUpRequest = BrowsingUtils.createGetNextUpRequest(mFolder.getId());
|
||||
mRows.add(new BrowseRowDef(getString(R.string.lbl_next_up), getNextUpRequest, new ChangeTriggerType[]{ChangeTriggerType.TvPlayback}));
|
||||
|
||||
//Premieres
|
||||
if (KoinJavaComponent.<UserPreferences>get(UserPreferences.class).get(UserPreferences.Companion.getPremieresEnabled())) {
|
||||
mRows.add(new BrowseRowDef(getString(R.string.lbl_new_premieres), BrowsingUtils.createPremieresRequest(mFolder.getId()), 0, true, true, new ChangeTriggerType[]{ChangeTriggerType.TvPlayback}, QueryType.Premieres));
|
||||
}
|
||||
|
||||
//Latest content added
|
||||
mRows.add(new BrowseRowDef(getString(R.string.lbl_latest), BrowsingUtils.createLatestMediaRequest(mFolder.getId(), BaseItemKind.EPISODE, true), new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated}));
|
||||
|
||||
|
||||
@@ -248,22 +248,6 @@ object BrowsingUtils {
|
||||
sortBy = setOf(ItemSortBy.SORT_NAME),
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun createPremieresRequest(parentId: UUID) = GetItemsRequest(
|
||||
fields = ItemRepository.itemFields,
|
||||
includeItemTypes = setOf(BaseItemKind.EPISODE),
|
||||
parentId = parentId,
|
||||
indexNumber = 1,
|
||||
recursive = true,
|
||||
isMissing = false,
|
||||
imageTypeLimit = 1,
|
||||
filters = setOf(ItemFilter.IS_UNPLAYED),
|
||||
sortBy = setOf(ItemSortBy.DATE_CREATED),
|
||||
sortOrder = setOf(SortOrder.DESCENDING),
|
||||
enableTotalRecordCount = false,
|
||||
limit = 300,
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun createLastPlayedRequest(parentId: UUID) = GetItemsRequest(
|
||||
fields = ItemRepository.itemFields,
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package org.jellyfin.androidtv.ui.browsing
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.constant.GridDirection
|
||||
import org.jellyfin.androidtv.constant.ImageType
|
||||
import org.jellyfin.androidtv.constant.PosterSize
|
||||
import org.jellyfin.androidtv.preference.LibraryPreferences
|
||||
import org.jellyfin.androidtv.preference.PreferencesRepository
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.OptionsFragment
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.checkbox
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.enum
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.optionsScreen
|
||||
import org.jellyfin.preference.store.PreferenceStore
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
class DisplayPreferencesScreen : OptionsFragment() {
|
||||
private val preferencesRepository: PreferencesRepository by inject()
|
||||
private val libraryPreferences: LibraryPreferences by lazy {
|
||||
preferencesRepository.getLibraryPreferences(preferencesId!!)
|
||||
}
|
||||
|
||||
private val preferencesId by lazy { requireArguments().getString(ARG_PREFERENCES_ID) }
|
||||
private val allowViewSelection by lazy { requireArguments().getBoolean(ARG_ALLOW_VIEW_SELECTION) }
|
||||
|
||||
override val stores: Array<PreferenceStore<*, *>>
|
||||
get() = arrayOf(libraryPreferences)
|
||||
|
||||
override val screen by optionsScreen {
|
||||
setTitle(R.string.lbl_display_preferences)
|
||||
|
||||
category {
|
||||
enum<PosterSize> {
|
||||
setTitle(R.string.lbl_image_size)
|
||||
bind(libraryPreferences, LibraryPreferences.posterSize)
|
||||
}
|
||||
enum<ImageType> {
|
||||
setTitle(R.string.lbl_image_type)
|
||||
bind(libraryPreferences, LibraryPreferences.imageType)
|
||||
}
|
||||
enum<GridDirection> {
|
||||
setTitle(R.string.grid_direction)
|
||||
bind(libraryPreferences, LibraryPreferences.gridDirection)
|
||||
}
|
||||
|
||||
if (allowViewSelection) {
|
||||
checkbox {
|
||||
setTitle(R.string.enable_smart_view)
|
||||
contentOn = requireContext().getString(R.string.enable_smart_view_description)
|
||||
contentOff = contentOn
|
||||
|
||||
bind(libraryPreferences, LibraryPreferences.enableSmartScreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ARG_ALLOW_VIEW_SELECTION = "allow_view_selection"
|
||||
const val ARG_PREFERENCES_ID = "preferences_id"
|
||||
}
|
||||
}
|
||||
@@ -357,13 +357,13 @@ public class EnhancedBrowseFragment extends Fragment implements RowLoader, View.
|
||||
if (item instanceof GridButton) {
|
||||
switch (((GridButton) item).getId()) {
|
||||
case GRID:
|
||||
navigationRepository.getValue().navigate(Destinations.INSTANCE.libraryBrowser(mFolder));
|
||||
navigationRepository.getValue().navigate(Destinations.INSTANCE.libraryBrowser(mFolder, null));
|
||||
break;
|
||||
|
||||
case ALBUMS:
|
||||
mFolder = JavaCompat.copyWithDisplayPreferencesId(mFolder, mFolder.getId() + "AL");
|
||||
|
||||
navigationRepository.getValue().navigate(Destinations.INSTANCE.libraryBrowser(mFolder, BaseItemKind.MUSIC_ALBUM.getSerialName()));
|
||||
navigationRepository.getValue().navigate(Destinations.INSTANCE.libraryBrowser(mFolder, null));
|
||||
break;
|
||||
|
||||
case ALBUM_ARTISTS:
|
||||
|
||||
@@ -3,10 +3,9 @@ package org.jellyfin.androidtv.ui.browsing
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -20,16 +19,17 @@ 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.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.base.JellyfinTheme
|
||||
import org.jellyfin.androidtv.ui.base.ProvideLocalInteractionTracker
|
||||
import org.jellyfin.androidtv.ui.composable.compat.AppNavigationHost
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
|
||||
import org.jellyfin.androidtv.ui.screensaver.InAppScreensaver
|
||||
import org.jellyfin.androidtv.ui.settings.compat.MainActivitySettings
|
||||
import org.jellyfin.androidtv.ui.startup.StartupActivity
|
||||
import org.jellyfin.androidtv.util.applyTheme
|
||||
import org.jellyfin.androidtv.util.isMediaSessionKeyEvent
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import timber.log.Timber
|
||||
@@ -41,14 +41,6 @@ class MainActivity : FragmentActivity() {
|
||||
private val interactionTrackerViewModel by viewModel<InteractionTrackerViewModel>()
|
||||
private val workManager by inject<WorkManager>()
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
|
||||
private val backPressedCallback = object : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (navigationRepository.canGoBack) navigationRepository.goBack()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
applyTheme()
|
||||
|
||||
@@ -62,21 +54,28 @@ class MainActivity : FragmentActivity() {
|
||||
else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}.launchIn(lifecycleScope)
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, backPressedCallback)
|
||||
if (savedInstanceState == null && navigationRepository.canGoBack) navigationRepository.reset(clearHistory = true)
|
||||
|
||||
navigationRepository.currentAction
|
||||
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
|
||||
.onEach { action ->
|
||||
handleNavigationAction(action)
|
||||
backPressedCallback.isEnabled = navigationRepository.canGoBack
|
||||
.onEach {
|
||||
interactionTrackerViewModel.notifyInteraction(canCancel = false, userInitiated = false)
|
||||
}.launchIn(lifecycleScope)
|
||||
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
binding.background.setContent { AppBackground() }
|
||||
binding.screensaver.setContent { InAppScreensaver() }
|
||||
setContentView(binding.root)
|
||||
setContent {
|
||||
JellyfinTheme {
|
||||
ProvideLocalInteractionTracker(
|
||||
interactionTracker = { interactionTrackerViewModel.notifyInteraction(false, userInitiated = true) }
|
||||
) {
|
||||
AppBackground()
|
||||
AppNavigationHost(
|
||||
navigationRepository = navigationRepository,
|
||||
)
|
||||
InAppScreensaver()
|
||||
MainActivitySettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -112,39 +111,21 @@ class MainActivity : FragmentActivity() {
|
||||
workManager.enqueue(OneTimeWorkRequestBuilder<LeanbackChannelWorker>().build())
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
Timber.d("MainActivity stopped")
|
||||
Timber.i("MainActivity stopped")
|
||||
sessionRepository.restoreSession(destroyOnly = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNavigationAction(action: NavigationAction) {
|
||||
interactionTrackerViewModel.notifyInteraction(canCancel = true, userInitiated = false)
|
||||
|
||||
when (action) {
|
||||
is NavigationAction.NavigateFragment -> binding.contentView.navigate(action)
|
||||
NavigationAction.GoBack -> binding.contentView.goBack()
|
||||
|
||||
NavigationAction.Nothing -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
// Forward key events to fragments
|
||||
|
||||
private fun Fragment.onKeyEvent(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
var result = childFragmentManager.fragments.any { it.onKeyEvent(keyCode, event) }
|
||||
if (!result && this is View.OnKeyListener) result = onKey(currentFocus, keyCode, event)
|
||||
return result
|
||||
}
|
||||
|
||||
private fun onKeyEvent(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
// Ignore the key event that closes the screensaver
|
||||
if (interactionTrackerViewModel.visible.value) {
|
||||
interactionTrackerViewModel.notifyInteraction(canCancel = event?.action == KeyEvent.ACTION_UP, userInitiated = true)
|
||||
return true
|
||||
}
|
||||
|
||||
return supportFragmentManager.fragments
|
||||
private fun onKeyEvent(keyCode: Int, event: KeyEvent?): Boolean = supportFragmentManager.fragments
|
||||
.any { it.onKeyEvent(keyCode, event) }
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean =
|
||||
onKeyEvent(keyCode, event) || super.onKeyDown(keyCode, event)
|
||||
@@ -154,44 +135,4 @@ class MainActivity : FragmentActivity() {
|
||||
|
||||
override fun onKeyLongPress(keyCode: Int, event: KeyEvent?): Boolean =
|
||||
onKeyEvent(keyCode, event) || super.onKeyUp(keyCode, event)
|
||||
|
||||
override fun onUserInteraction() {
|
||||
super.onUserInteraction()
|
||||
|
||||
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() && interactionTrackerViewModel.visible.value) {
|
||||
interactionTrackerViewModel.notifyInteraction(canCancel = event.action == KeyEvent.ACTION_UP, userInitiated = true)
|
||||
return true
|
||||
}
|
||||
|
||||
@Suppress("RestrictedApi") // False positive
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
@Suppress("RestrictedApi") // False positive
|
||||
override fun dispatchKeyShortcutEvent(event: KeyEvent): Boolean {
|
||||
// Ignore the key event that closes the screensaver
|
||||
if (!event.isMediaSessionKeyEvent() && interactionTrackerViewModel.visible.value) {
|
||||
interactionTrackerViewModel.notifyInteraction(canCancel = event.action == KeyEvent.ACTION_UP, userInitiated = true)
|
||||
return true
|
||||
}
|
||||
|
||||
@Suppress("RestrictedApi") // False positive
|
||||
return super.dispatchKeyShortcutEvent(event)
|
||||
}
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
|
||||
// Ignore the touch event that closes the screensaver
|
||||
if (interactionTrackerViewModel.visible.value) {
|
||||
interactionTrackerViewModel.notifyInteraction(canCancel = true, userInitiated = true)
|
||||
return true
|
||||
}
|
||||
|
||||
return super.dispatchTouchEvent(ev)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.ui.unit.dp
|
||||
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
|
||||
@@ -246,16 +245,13 @@ fun BaseItemInfoRow(
|
||||
includeRuntime: Boolean,
|
||||
) {
|
||||
val userPreferences = koinInject<UserPreferences>()
|
||||
val ratingType = userPreferences[UserPreferences.defaultRatingType]
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (ratingType != RatingType.RATING_HIDDEN) {
|
||||
item.communityRating?.let { InfoRowCommunityRating(it / 10f) }
|
||||
item.criticRating?.let { InfoRowCriticRating(it / 100f) }
|
||||
}
|
||||
|
||||
when (item.type) {
|
||||
BaseItemKind.EPISODE -> {
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
package org.jellyfin.androidtv.ui.card;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.text.TextUtils;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.leanback.widget.BaseCardView;
|
||||
|
||||
import org.jellyfin.androidtv.R;
|
||||
import org.jellyfin.androidtv.databinding.ViewCardLegacyImageBinding;
|
||||
import org.jellyfin.androidtv.ui.AsyncImageView;
|
||||
import org.jellyfin.androidtv.ui.itemhandling.BaseItemDtoBaseRowItem;
|
||||
import org.jellyfin.androidtv.ui.itemhandling.BaseRowItem;
|
||||
import org.jellyfin.androidtv.util.ContextExtensionsKt;
|
||||
import org.jellyfin.androidtv.util.DateTimeExtensionsKt;
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
|
||||
import java.text.NumberFormat;
|
||||
|
||||
/**
|
||||
* Modified ImageCard with no fade on the badge
|
||||
* A card view with an {@link ImageView} as its main region.
|
||||
*/
|
||||
public class LegacyImageCardView extends BaseCardView {
|
||||
private ViewCardLegacyImageBinding binding = ViewCardLegacyImageBinding.inflate(LayoutInflater.from(getContext()), this);
|
||||
private ImageView mBanner;
|
||||
private int BANNER_SIZE = Utils.convertDpToPixel(getContext(), 50);
|
||||
private int noIconMargin = Utils.convertDpToPixel(getContext(), 5);
|
||||
private NumberFormat nf = NumberFormat.getInstance();
|
||||
|
||||
public LegacyImageCardView(Context context, boolean showInfo) {
|
||||
super(context, null, androidx.leanback.R.attr.imageCardViewStyle);
|
||||
|
||||
if (!showInfo) {
|
||||
setCardType(CARD_TYPE_MAIN_ONLY);
|
||||
}
|
||||
|
||||
binding.mainImage.setClipToOutline(true);
|
||||
|
||||
// "hack" to trigger KeyProcessor to open the menu for this item on long press
|
||||
setOnLongClickListener(v -> {
|
||||
Activity activity = ContextExtensionsKt.getActivity(getContext());
|
||||
if (activity == null) return false;
|
||||
// Make sure the view is focused so the created menu uses it as anchor
|
||||
if (!v.requestFocus()) return false;
|
||||
return activity.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MENU));
|
||||
});
|
||||
|
||||
setForeground(null);
|
||||
}
|
||||
|
||||
public void setBanner(int bannerResource) {
|
||||
if (mBanner == null) {
|
||||
mBanner = new ImageView(getContext());
|
||||
mBanner.setLayoutParams(new ViewGroup.LayoutParams(BANNER_SIZE, BANNER_SIZE));
|
||||
|
||||
((ViewGroup) getRootView()).addView(mBanner);
|
||||
}
|
||||
|
||||
mBanner.setImageResource(bannerResource);
|
||||
mBanner.setVisibility(VISIBLE);
|
||||
}
|
||||
|
||||
public final AsyncImageView getMainImageView() {
|
||||
return binding.mainImage;
|
||||
}
|
||||
|
||||
public void setPlayingIndicator(boolean playing) {
|
||||
if (playing) {
|
||||
// TODO use decent animation for equalizer icon
|
||||
binding.extraBadge.setBackgroundResource(R.drawable.ic_play);
|
||||
binding.extraBadge.setVisibility(VISIBLE);
|
||||
} else {
|
||||
binding.extraBadge.setBackgroundResource(R.drawable.blank10x10);
|
||||
}
|
||||
}
|
||||
|
||||
public void setMainImageDimensions(int width, int height) {
|
||||
setMainImageDimensions(width, height, ImageView.ScaleType.CENTER_CROP);
|
||||
}
|
||||
|
||||
public void setMainImageDimensions(int width, int height, ImageView.ScaleType scaleType) {
|
||||
ViewGroup.LayoutParams lp = binding.mainImage.getLayoutParams();
|
||||
lp.width = Math.round(width * getResources().getDisplayMetrics().density);
|
||||
lp.height = Math.round(height * getResources().getDisplayMetrics().density);
|
||||
binding.mainImage.setLayoutParams(lp);
|
||||
binding.mainImage.setScaleType(scaleType);
|
||||
if (mBanner != null) mBanner.setX(lp.width - BANNER_SIZE);
|
||||
ViewGroup.LayoutParams lp2 = binding.resumeProgress.getLayoutParams();
|
||||
lp2.width = lp.width;
|
||||
binding.resumeProgress.setLayoutParams(lp2);
|
||||
}
|
||||
|
||||
public void setTitleText(CharSequence text) {
|
||||
if (binding.title == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
binding.title.setText(text);
|
||||
setTextMaxLines();
|
||||
}
|
||||
|
||||
public void setOverlayText(String text) {
|
||||
if (getCardType() == BaseCardView.CARD_TYPE_MAIN_ONLY) {
|
||||
binding.overlayText.setText(text);
|
||||
binding.nameOverlay.setVisibility(VISIBLE);
|
||||
} else {
|
||||
binding.nameOverlay.setVisibility(GONE);
|
||||
}
|
||||
}
|
||||
|
||||
public void setOverlayInfo(BaseRowItem item) {
|
||||
if (binding.overlayText == null) return;
|
||||
|
||||
if (getCardType() == BaseCardView.CARD_TYPE_MAIN_ONLY && item.getShowCardInfoOverlay()) {
|
||||
switch (item.getBaseItem().getType()) {
|
||||
case PHOTO:
|
||||
insertCardData(item.getBaseItem().getPremiereDate() != null ? DateTimeExtensionsKt.getDateFormatter(getContext()).format(item.getBaseItem().getPremiereDate()) : item.getFullName(getContext()), R.drawable.ic_camera, true);
|
||||
break;
|
||||
case PHOTO_ALBUM:
|
||||
insertCardData(item.getFullName(getContext()), R.drawable.ic_photos, true);
|
||||
break;
|
||||
case VIDEO:
|
||||
insertCardData(item.getFullName(getContext()), R.drawable.ic_movie, true);
|
||||
break;
|
||||
case FOLDER:
|
||||
insertCardData(item.getFullName(getContext()), R.drawable.ic_folder, true);
|
||||
break;
|
||||
case PLAYLIST:
|
||||
case MUSIC_ARTIST:
|
||||
case PERSON:
|
||||
default:
|
||||
binding.overlayText.setText(item.getFullName(getContext()));
|
||||
break;
|
||||
}
|
||||
if (item instanceof BaseItemDtoBaseRowItem) {
|
||||
binding.overlayCount.setText(((BaseItemDtoBaseRowItem) item).getChildCountStr());
|
||||
} else {
|
||||
binding.overlayCount.setText(null);
|
||||
}
|
||||
binding.nameOverlay.setVisibility(VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
public void insertCardData (@Nullable String fullName, @NonNull int icon, @NonNull boolean iconVisible) {
|
||||
binding.overlayText.setText(fullName);
|
||||
if (iconVisible) {
|
||||
binding.iconImage.setImageResource(icon);
|
||||
binding.icon.setVisibility(VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
public CharSequence getTitle() {
|
||||
if (binding.title == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return binding.title.getText();
|
||||
}
|
||||
|
||||
public void setContentText(CharSequence text) {
|
||||
if (binding.contentText == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
binding.contentText.setText(text);
|
||||
setTextMaxLines();
|
||||
}
|
||||
|
||||
public CharSequence getContentText() {
|
||||
if (binding.contentText == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return binding.contentText.getText();
|
||||
}
|
||||
|
||||
public void setRating(String rating) {
|
||||
if (rating != null) {
|
||||
binding.badgeText.setText(rating);
|
||||
binding.badgeText.setVisibility(VISIBLE);
|
||||
} else {
|
||||
binding.badgeText.setText("");
|
||||
binding.badgeText.setVisibility(GONE);
|
||||
}
|
||||
}
|
||||
|
||||
public void setBadgeImage(Drawable drawable) {
|
||||
if (binding.extraBadge == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (drawable != null) {
|
||||
binding.extraBadge.setImageDrawable(drawable);
|
||||
binding.extraBadge.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.extraBadge.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasOverlappingRendering() {
|
||||
return false;
|
||||
}
|
||||
|
||||
private void setTextMaxLines() {
|
||||
if (TextUtils.isEmpty(getTitle())) {
|
||||
binding.contentText.setMaxLines(2);
|
||||
} else {
|
||||
binding.contentText.setMaxLines(1);
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(getContentText())) {
|
||||
binding.title.setMaxLines(2);
|
||||
} else {
|
||||
binding.title.setMaxLines(1);
|
||||
}
|
||||
}
|
||||
|
||||
public void clearBanner() {
|
||||
if (mBanner != null) {
|
||||
mBanner.setVisibility(GONE);
|
||||
}
|
||||
}
|
||||
|
||||
public void setUnwatchedCount(int count) {
|
||||
if (count > 0) {
|
||||
binding.unwatchedCount.setText(count > 99 ? getContext().getString(R.string.watch_count_overflow) : nf.format(count));
|
||||
binding.unwatchedCount.setVisibility(VISIBLE);
|
||||
binding.checkMark.setVisibility(INVISIBLE);
|
||||
binding.watchedIndicator.setVisibility(VISIBLE);
|
||||
} else if (count == 0) {
|
||||
binding.checkMark.setVisibility(VISIBLE);
|
||||
binding.unwatchedCount.setVisibility(INVISIBLE);
|
||||
binding.watchedIndicator.setVisibility(VISIBLE);
|
||||
} else {
|
||||
binding.watchedIndicator.setVisibility(GONE);
|
||||
}
|
||||
}
|
||||
|
||||
public void setProgress(int pct) {
|
||||
if (pct > 0) {
|
||||
binding.resumeProgress.setProgress(pct);
|
||||
binding.resumeProgress.setVisibility(VISIBLE);
|
||||
} else {
|
||||
binding.resumeProgress.setVisibility(GONE);
|
||||
}
|
||||
}
|
||||
|
||||
public void showFavIcon(boolean show) {
|
||||
binding.favIcon.setVisibility(show ? VISIBLE : GONE);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
@@ -16,13 +15,13 @@ 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.PaddingValues
|
||||
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
|
||||
@@ -36,17 +35,13 @@ 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.ProfilePicture
|
||||
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
|
||||
@@ -156,26 +151,17 @@ class UserCardView @JvmOverloads constructor(
|
||||
|
||||
UserCard(
|
||||
image = {
|
||||
if (image != null) {
|
||||
AsyncImage(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP,
|
||||
ProfilePicture(
|
||||
url = image,
|
||||
iconPadding = PaddingValues(24.dp),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} 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)
|
||||
Text(
|
||||
text = name.orEmpty(),
|
||||
maxLines = 1
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 6.dp, vertical = 8.dp)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package org.jellyfin.androidtv.ui.composable.compat
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.jellyfin.androidtv.ui.browsing.DestinationFragmentView
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationAction
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@Composable
|
||||
fun AppNavigationHost(
|
||||
modifier: Modifier = Modifier,
|
||||
navigationRepository: NavigationRepository = koinInject(),
|
||||
) {
|
||||
val factory = remember { AppNavigationHostViewFactory() }
|
||||
|
||||
val currentAction by navigationRepository.currentAction.collectAsState(NavigationAction.Nothing)
|
||||
val canGoBack by remember {
|
||||
navigationRepository.currentAction.map { navigationRepository.canGoBack }.distinctUntilChanged()
|
||||
}.collectAsState(navigationRepository.canGoBack)
|
||||
|
||||
BackHandler(canGoBack) { navigationRepository.goBack() }
|
||||
|
||||
AndroidView(
|
||||
factory = factory,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
LaunchedEffect(currentAction) {
|
||||
when (val currentAction = currentAction) {
|
||||
is NavigationAction.NavigateFragment -> factory.view.navigate(currentAction)
|
||||
NavigationAction.GoBack -> factory.view.goBack()
|
||||
|
||||
NavigationAction.Nothing -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class AppNavigationHostViewFactory : (Context) -> View {
|
||||
private var _view: DestinationFragmentView? = null
|
||||
|
||||
val view get() = requireNotNull(_view)
|
||||
|
||||
override operator fun invoke(
|
||||
context: Context
|
||||
): View = DestinationFragmentView(context).also { view ->
|
||||
_view = view
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.jellyfin.androidtv.ui.composable.item
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import org.jellyfin.androidtv.ui.base.JellyfinTheme
|
||||
|
||||
@Composable
|
||||
@Stable
|
||||
fun ItemCard(
|
||||
modifier: Modifier = Modifier,
|
||||
image: @Composable BoxScope.() -> Unit,
|
||||
overlay: (@Composable BoxScope.() -> Unit)? = null,
|
||||
shape: Shape = JellyfinTheme.shapes.medium,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(JellyfinTheme.colorScheme.surface, shape)
|
||||
) {
|
||||
image()
|
||||
|
||||
if (overlay != null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
content = overlay
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package org.jellyfin.androidtv.ui.composable.item
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.sizeIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.preference.constant.WatchedIndicatorBehavior
|
||||
import org.jellyfin.androidtv.ui.base.Badge
|
||||
import org.jellyfin.androidtv.ui.base.Icon
|
||||
import org.jellyfin.androidtv.ui.base.Seekbar
|
||||
import org.jellyfin.androidtv.ui.base.Text
|
||||
import org.jellyfin.androidtv.ui.composable.rememberPlayerProgress
|
||||
import org.jellyfin.androidtv.ui.composable.rememberQueueEntry
|
||||
import org.jellyfin.design.Tokens
|
||||
import org.jellyfin.playback.core.model.PlayState
|
||||
import org.jellyfin.playback.jellyfin.queue.baseItem
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@Composable
|
||||
@Stable
|
||||
fun ItemCardBaseItemOverlay(
|
||||
item: BaseItemDto,
|
||||
footer: (@Composable () -> Unit)? = null,
|
||||
) = Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(Tokens.Space.spaceXs)
|
||||
) {
|
||||
StateIndicator(
|
||||
item = item,
|
||||
modifier = Modifier.align(Alignment.TopStart),
|
||||
)
|
||||
|
||||
WatchIndicator(
|
||||
item = item,
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
verticalArrangement = Arrangement.spacedBy(Tokens.Space.spaceXs)
|
||||
) {
|
||||
ProgressIndicator(
|
||||
item = item,
|
||||
)
|
||||
|
||||
if (footer != null) footer()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Stable
|
||||
private fun StateIndicator(
|
||||
item: BaseItemDto,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isFavorited = item.userData?.isFavorite == true
|
||||
val isRecording = item.timerId?.takeIf { item.type == BaseItemKind.LIVE_TV_PROGRAM || item.type == BaseItemKind.PROGRAM } != null
|
||||
val isRecordingActive = item.seriesTimerId != null && isRecording
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(Tokens.Space.spaceXs)
|
||||
) {
|
||||
if (isRecording) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_record_series),
|
||||
contentDescription = null,
|
||||
tint = if (isRecordingActive) Tokens.Color.colorRed600 else Tokens.Color.colorGrey100,
|
||||
modifier = modifier
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (isFavorited) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_heart),
|
||||
contentDescription = null,
|
||||
tint = Tokens.Color.colorRed500,
|
||||
modifier = modifier
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Stable
|
||||
private fun WatchIndicator(
|
||||
item: BaseItemDto,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val userPreferences = koinInject<UserPreferences>()
|
||||
val watchedIndicatorBehavior = userPreferences[UserPreferences.watchedIndicatorBehavior]
|
||||
|
||||
if (watchedIndicatorBehavior == WatchedIndicatorBehavior.NEVER) return
|
||||
if (watchedIndicatorBehavior == WatchedIndicatorBehavior.EPISODES_ONLY && item.type != BaseItemKind.EPISODE) return
|
||||
|
||||
val isPlayed = item.userData?.played == true
|
||||
val unplayedItems = item.userData?.unplayedItemCount?.takeIf { it > 0 }
|
||||
|
||||
if (isPlayed) {
|
||||
Badge(
|
||||
modifier = modifier
|
||||
.size(24.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_watch),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
}
|
||||
} else if (unplayedItems != null) {
|
||||
if (watchedIndicatorBehavior == WatchedIndicatorBehavior.HIDE_UNWATCHED) return
|
||||
|
||||
Badge(
|
||||
modifier = modifier
|
||||
.sizeIn(minWidth = 24.dp, minHeight = 24.dp),
|
||||
) {
|
||||
Text(
|
||||
text = unplayedItems.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressIndicator(
|
||||
item: BaseItemDto,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val playbackManager = koinInject<org.jellyfin.playback.core.PlaybackManager>()
|
||||
val playState by playbackManager.state.playState.collectAsState()
|
||||
val currentQueueEntry by rememberQueueEntry(playbackManager)
|
||||
|
||||
val playedPercentage = if (playState == PlayState.PLAYING && currentQueueEntry?.baseItem?.id == item.id) {
|
||||
rememberPlayerProgress(playbackManager)
|
||||
} else {
|
||||
item.userData?.playedPercentage?.toFloat()?.div(100f)?.coerceIn(0f, 1f)?.takeIf { it > 0f && it < 1f }
|
||||
}
|
||||
|
||||
if (playedPercentage != null) {
|
||||
Box(modifier = modifier.padding(Tokens.Space.spaceXs)) {
|
||||
Seekbar(
|
||||
progress = playedPercentage,
|
||||
enabled = false,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package org.jellyfin.androidtv.ui.composable.item
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.jellyfin.androidtv.ui.base.JellyfinTheme
|
||||
import org.jellyfin.androidtv.ui.base.ProvideTextStyle
|
||||
import org.jellyfin.design.Tokens
|
||||
|
||||
@Composable
|
||||
@Stable
|
||||
fun ItemPreview(
|
||||
card: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
title: (@Composable () -> Unit)? = null,
|
||||
subtitle: (@Composable () -> Unit)? = null,
|
||||
spacing: Dp = Tokens.Space.spaceXs,
|
||||
) {
|
||||
ItemPreviewLayout(
|
||||
card = card,
|
||||
metadata = {
|
||||
ItemPreviewMetadata(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
spacing = spacing,
|
||||
)
|
||||
},
|
||||
spacing = spacing,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Stable
|
||||
private fun ItemPreviewMetadata(
|
||||
title: (@Composable () -> Unit)?,
|
||||
subtitle: (@Composable () -> Unit)?,
|
||||
spacing: Dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(spacing),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing),
|
||||
) {
|
||||
title?.let { content ->
|
||||
ProvideTextStyle(
|
||||
value = JellyfinTheme.typography.default.copy(
|
||||
color = Tokens.Color.colorGrey100,
|
||||
fontSize = 12.sp,
|
||||
),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
subtitle?.let { content ->
|
||||
ProvideTextStyle(
|
||||
value = JellyfinTheme.typography.default.copy(
|
||||
color = Tokens.Color.colorGrey300,
|
||||
fontSize = 10.sp,
|
||||
),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Stable
|
||||
fun ItemPreviewLayout(
|
||||
card: @Composable () -> Unit,
|
||||
metadata: @Composable () -> Unit,
|
||||
spacing: Dp = Tokens.Space.spaceXs,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
SubcomposeLayout(
|
||||
modifier = modifier,
|
||||
) { constraints ->
|
||||
val cardPlaceables = subcompose("card", card).map { it.measure(constraints) }
|
||||
val cardWidth = cardPlaceables.maxOf { it.width }
|
||||
val cardHeight = cardPlaceables.maxOf { it.height }
|
||||
|
||||
val childConstraints = constraints.copy(minWidth = cardWidth, maxWidth = cardWidth)
|
||||
val metadataPlaceables = subcompose("metadata", metadata).map { it.measure(childConstraints) }
|
||||
val metadataHeight = metadataPlaceables.maxOf { it.height }
|
||||
|
||||
val spacingPx = spacing.roundToPx()
|
||||
val height = cardHeight + spacingPx + metadataHeight
|
||||
|
||||
layout(cardWidth, height) {
|
||||
cardPlaceables.forEach { it.place(0, 0) }
|
||||
metadataPlaceables.forEach { it.place(0, cardHeight + spacingPx) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,20 @@ import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jellyfin.playback.core.PlaybackManager
|
||||
import org.jellyfin.playback.core.model.PlayState
|
||||
import org.jellyfin.playback.core.model.PositionInfo
|
||||
import org.jellyfin.playback.core.queue.queue
|
||||
import org.koin.compose.koinInject
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Composable
|
||||
fun rememberQueueEntry(
|
||||
@@ -22,6 +27,29 @@ fun rememberQueueEntry(
|
||||
playbackManager.queue.entry
|
||||
}.collectAsState()
|
||||
|
||||
@Composable
|
||||
fun rememberPlayerPositionInfo(
|
||||
playbackManager: PlaybackManager = koinInject(),
|
||||
precision: Duration = 1.seconds
|
||||
): MutableState<PositionInfo> {
|
||||
val playState by playbackManager.state.playState.collectAsState()
|
||||
val playing = playState == PlayState.PLAYING
|
||||
|
||||
val positionInfo = remember { mutableStateOf(playbackManager.state.positionInfo) }
|
||||
|
||||
LaunchedEffect(playing, precision) {
|
||||
val precisionMs = precision.inWholeMilliseconds
|
||||
|
||||
while (playing) {
|
||||
positionInfo.value = playbackManager.state.positionInfo
|
||||
|
||||
delay(precisionMs - (positionInfo.value.active.inWholeMilliseconds % precisionMs))
|
||||
}
|
||||
}
|
||||
|
||||
return positionInfo
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberPlayerProgress(
|
||||
playbackManager: PlaybackManager = koinInject(),
|
||||
@@ -43,7 +71,7 @@ fun rememberPlayerProgress(
|
||||
active: Duration,
|
||||
duration: Duration,
|
||||
): Float {
|
||||
val animatable = remember { Animatable(0f) }
|
||||
val animatable = remember { Animatable(0f, 0f) }
|
||||
|
||||
LaunchedEffect(playing, duration) {
|
||||
val activeMs = active.inWholeMilliseconds.toFloat()
|
||||
|
||||
@@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.onEach
|
||||
import org.jellyfin.androidtv.auth.repository.ServerRepository
|
||||
import org.jellyfin.androidtv.auth.repository.SessionRepository
|
||||
import org.jellyfin.androidtv.data.repository.NotificationsRepository
|
||||
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
|
||||
@@ -47,7 +46,6 @@ class HomeFragment : Fragment() {
|
||||
val rowsFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(rowsFocusRequester) { rowsFocusRequester.requestFocus() }
|
||||
|
||||
JellyfinTheme {
|
||||
Column {
|
||||
MainToolbar(MainToolbarActiveButton.Home)
|
||||
|
||||
@@ -77,7 +75,6 @@ class HomeFragment : Fragment() {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
@@ -9,14 +9,13 @@ import org.jellyfin.androidtv.data.querying.GetUserViewsRequest
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter
|
||||
import org.jellyfin.androidtv.ui.presentation.CardPresenter
|
||||
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter
|
||||
import org.jellyfin.androidtv.ui.presentation.UserViewCardPresenter
|
||||
|
||||
class HomeFragmentViewsRow(
|
||||
val small: Boolean,
|
||||
) : HomeFragmentRow {
|
||||
private companion object {
|
||||
val smallCardPresenter = UserViewCardPresenter(true)
|
||||
val largeCardPresenter = UserViewCardPresenter(false)
|
||||
val smallCardPresenter = CardPresenter(true, 75)
|
||||
val largeCardPresenter = CardPresenter(true, 126)
|
||||
}
|
||||
|
||||
override fun addToRowsAdapter(context: Context, cardPresenter: CardPresenter, rowsAdapter: MutableObjectAdapter<Row>) {
|
||||
|
||||
@@ -231,7 +231,7 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
|
||||
val adapter = currentRow?.adapter as? ItemRowAdapter ?: return
|
||||
val item = currentItem ?: return
|
||||
|
||||
Timber.d("Refresh item ${item.getFullName(requireContext())}")
|
||||
Timber.i("Refresh item ${item.getFullName(requireContext())}")
|
||||
adapter.refreshItem(api, this, item)
|
||||
}
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
loadItem(lastPlayedItem.getId());
|
||||
dataRefreshService.getValue().setLastPlayedItem(null); //blank this out so a detail screen we back up to doesn't also do this
|
||||
} else {
|
||||
Timber.d("Updating info after playback");
|
||||
Timber.i("Updating info after playback");
|
||||
FullDetailsFragmentHelperKt.getItem(FullDetailsFragment.this, mBaseItem.getId(), item -> {
|
||||
if (item == null) return null;
|
||||
|
||||
@@ -547,7 +547,7 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
|
||||
//Chapters
|
||||
if (mBaseItem.getChapters() != null && !mBaseItem.getChapters().isEmpty()) {
|
||||
List<ChapterItemInfo> chapters = BaseItemExtensionsKt.buildChapterItems(mBaseItem, api.getValue());
|
||||
List<ChapterItemInfo> chapters = BaseItemExtensionsKt.buildChapterItems(mBaseItem);
|
||||
ItemRowAdapter chapterAdapter = new ItemRowAdapter(requireContext(), chapters, new CardPresenter(true, 120), adapter);
|
||||
addItemRow(adapter, chapterAdapter, 2, getString(R.string.lbl_chapters));
|
||||
}
|
||||
@@ -612,6 +612,12 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
break;
|
||||
|
||||
case EPISODE:
|
||||
//Additional Parts
|
||||
if (mBaseItem.getPartCount() != null && mBaseItem.getPartCount() > 0) {
|
||||
ItemRowAdapter additionalPartsAdapter = new ItemRowAdapter(requireContext(), new GetAdditionalPartsRequest(mBaseItem.getId()), new CardPresenter(), adapter);
|
||||
addItemRow(adapter, additionalPartsAdapter, 0, getString(R.string.lbl_additional_parts));
|
||||
}
|
||||
|
||||
if (mBaseItem.getSeasonId() != null && mBaseItem.getIndexNumber() != null) {
|
||||
// query index is zero-based but episode no is not
|
||||
ItemRowAdapter nextAdapter = new ItemRowAdapter(requireContext(), BrowsingUtils.createNextEpisodesRequest(mBaseItem.getSeasonId(), mBaseItem.getIndexNumber()), 0, false, true, new CardPresenter(true, 120), adapter);
|
||||
@@ -632,7 +638,7 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
|
||||
//Chapters
|
||||
if (mBaseItem.getChapters() != null && !mBaseItem.getChapters().isEmpty()) {
|
||||
List<ChapterItemInfo> chapters = BaseItemExtensionsKt.buildChapterItems(mBaseItem, api.getValue());
|
||||
List<ChapterItemInfo> chapters = BaseItemExtensionsKt.buildChapterItems(mBaseItem);
|
||||
ItemRowAdapter chapterAdapter = new ItemRowAdapter(requireContext(), chapters, new CardPresenter(true, 120), adapter);
|
||||
addItemRow(adapter, chapterAdapter, 1, getString(R.string.lbl_chapters));
|
||||
}
|
||||
@@ -996,7 +1002,6 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
mDetailsOverviewRow.addAction(goToSeriesButton);
|
||||
}
|
||||
|
||||
if (userPreferences.getValue().get(UserPreferences.Companion.getMediaManagementEnabled())) {
|
||||
boolean deletableItem = false;
|
||||
UserDto currentUser = KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue();
|
||||
if (mBaseItem.getType() == BaseItemKind.RECORDING && currentUser.getPolicy().getEnableLiveTvManagement() && mBaseItem.getCanDelete() != null)
|
||||
@@ -1012,7 +1017,6 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
});
|
||||
mDetailsOverviewRow.addAction(deleteButton);
|
||||
}
|
||||
}
|
||||
|
||||
if (mSeriesTimerInfo != null) {
|
||||
//Settings
|
||||
|
||||
@@ -270,12 +270,10 @@ fun FullDetailsFragment.resumePlayback(v: View) {
|
||||
getString(R.string.msg_video_playback_error),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
|
||||
if (nextUpEpisode?.userData?.playbackPositionTicks == 0L) {
|
||||
} else if (nextUpEpisode.userData?.playbackPositionTicks == 0L) {
|
||||
play(nextUpEpisode, 0, false)
|
||||
} else {
|
||||
showResumeMenu(v, nextUpEpisode!!)
|
||||
showResumeMenu(v, nextUpEpisode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ public class ItemListFragment extends Fragment implements View.OnKeyListener {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProgress(long pos) {
|
||||
public void onProgress(long pos, long duration) {
|
||||
if (mCurrentlyPlayingRow != null) {
|
||||
mCurrentlyPlayingRow.updateCurrentTime(pos);
|
||||
}
|
||||
@@ -351,7 +351,7 @@ public class ItemListFragment extends Fragment implements View.OnKeyListener {
|
||||
}
|
||||
|
||||
private void play(List<BaseItemDto> items, int ndx, boolean shuffle) {
|
||||
Timber.d("play items: %d, ndx: %d, shuffle: %b", items.size(), ndx, shuffle);
|
||||
Timber.i("play items: %d, ndx: %d, shuffle: %b", items.size(), ndx, shuffle);
|
||||
|
||||
int pos = 0;
|
||||
BaseItemDto item = items.size() > 0 ? items.get(ndx) : null;
|
||||
|
||||
@@ -197,7 +197,7 @@ public class MusicFavoritesListFragment extends Fragment implements View.OnKeyLi
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProgress(long pos) {
|
||||
public void onProgress(long pos, long duration) {
|
||||
if (mCurrentlyPlayingRow != null) {
|
||||
mCurrentlyPlayingRow.updateCurrentTime(pos);
|
||||
}
|
||||
@@ -283,7 +283,7 @@ public class MusicFavoritesListFragment extends Fragment implements View.OnKeyLi
|
||||
};
|
||||
|
||||
private void play(List<BaseItemDto> items, int ndx, boolean shuffle) {
|
||||
Timber.d("play items: %d, ndx: %d, shuffle: %b", items.size(), ndx, shuffle);
|
||||
Timber.i("play items: %d, ndx: %d, shuffle: %b", items.size(), ndx, shuffle);
|
||||
|
||||
playbackLauncher.getValue().launch(requireContext(), items, 0, false, ndx, shuffle);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
package org.jellyfin.androidtv.ui.itemhandling
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.constant.ImageType
|
||||
import org.jellyfin.androidtv.util.ImageHelper
|
||||
import org.jellyfin.androidtv.util.TimeUtils
|
||||
import org.jellyfin.androidtv.util.apiclient.JellyfinImage
|
||||
import org.jellyfin.androidtv.util.apiclient.albumPrimaryImage
|
||||
import org.jellyfin.androidtv.util.apiclient.itemImages
|
||||
import org.jellyfin.androidtv.util.apiclient.parentImages
|
||||
import org.jellyfin.androidtv.util.apiclient.seriesPrimaryImage
|
||||
import org.jellyfin.androidtv.util.apiclient.seriesThumbImage
|
||||
import org.jellyfin.androidtv.util.getTimeFormatter
|
||||
import org.jellyfin.androidtv.util.locale
|
||||
import org.jellyfin.androidtv.util.sdk.getFullName
|
||||
import org.jellyfin.androidtv.util.sdk.getSubName
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import java.time.format.DateTimeFormatter
|
||||
import org.jellyfin.androidtv.constant.ImageType as BaseRowImageType
|
||||
|
||||
open class BaseItemDtoBaseRowItem @JvmOverloads constructor(
|
||||
item: BaseItemDto,
|
||||
@@ -59,26 +60,6 @@ open class BaseItemDtoBaseRowItem @JvmOverloads constructor(
|
||||
override val isFavorite get() = baseItem?.userData?.isFavorite == true
|
||||
override val isPlayed get() = baseItem?.userData?.played == true
|
||||
|
||||
val childCountStr: String?
|
||||
get() {
|
||||
// Playlist
|
||||
if (baseItem?.type == BaseItemKind.PLAYLIST) {
|
||||
val childCount = baseItem.cumulativeRunTimeTicks?.ticks?.let { duration ->
|
||||
TimeUtils.formatMillis(duration.inWholeMilliseconds)
|
||||
}
|
||||
if (childCount != null) return childCount
|
||||
}
|
||||
|
||||
// Folder
|
||||
if (baseItem?.isFolder == true && baseItem.type != BaseItemKind.MUSIC_ARTIST) {
|
||||
val childCount = baseItem.childCount
|
||||
if (childCount != null && childCount > 0) return childCount.toString()
|
||||
}
|
||||
|
||||
// Default
|
||||
return null
|
||||
}
|
||||
|
||||
override fun getCardName(context: Context) = when {
|
||||
baseItem?.type == BaseItemKind.AUDIO && baseItem.artists != null -> baseItem.artists?.joinToString(", ")
|
||||
baseItem?.type == BaseItemKind.AUDIO && baseItem.albumArtists != null -> baseItem.albumArtists?.joinToString(", ")
|
||||
@@ -120,58 +101,29 @@ open class BaseItemDtoBaseRowItem @JvmOverloads constructor(
|
||||
else -> baseItem?.getSubName(context)
|
||||
}
|
||||
|
||||
override fun getImageUrl(
|
||||
context: Context,
|
||||
imageHelper: ImageHelper,
|
||||
imageType: ImageType,
|
||||
fillWidth: Int,
|
||||
fillHeight: Int
|
||||
): String? {
|
||||
val seriesPrimaryImage = baseItem?.seriesPrimaryImage
|
||||
override fun getImage(imageType: BaseRowImageType): JellyfinImage? {
|
||||
val primaryImage = when {
|
||||
preferSeriesPoster && baseItem?.type == BaseItemKind.EPISODE -> baseItem.parentImages[ImageType.PRIMARY]
|
||||
?: baseItem.seriesPrimaryImage
|
||||
|
||||
return when {
|
||||
preferSeriesPoster && seriesPrimaryImage != null -> imageHelper.getImageUrl(seriesPrimaryImage)
|
||||
|
||||
imageType == ImageType.BANNER -> imageHelper.getBannerImageUrl(
|
||||
requireNotNull(
|
||||
baseItem
|
||||
), fillWidth, fillHeight
|
||||
)
|
||||
|
||||
imageType == ImageType.THUMB -> imageHelper.getThumbImageUrl(
|
||||
requireNotNull(
|
||||
baseItem
|
||||
), fillWidth, fillHeight
|
||||
)
|
||||
|
||||
else -> imageHelper.getPrimaryImageUrl(
|
||||
baseItem!!,
|
||||
preferParentThumb,
|
||||
null,
|
||||
fillHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getBadgeImage(
|
||||
context: Context,
|
||||
imageHelper: ImageHelper,
|
||||
) = when (baseItem?.type) {
|
||||
BaseItemKind.LIVE_TV_PROGRAM,
|
||||
BaseItemKind.PROGRAM -> when {
|
||||
baseItem.seriesTimerId != null -> R.drawable.ic_record_series_red
|
||||
baseItem.timerId != null -> R.drawable.ic_record_red
|
||||
preferParentThumb && baseItem?.type == BaseItemKind.EPISODE -> baseItem.parentImages[ImageType.THUMB]
|
||||
?: baseItem.seriesThumbImage
|
||||
|
||||
baseItem?.type == BaseItemKind.SEASON -> baseItem.seriesPrimaryImage
|
||||
baseItem?.type == BaseItemKind.PROGRAM -> baseItem.itemImages[ImageType.THUMB]
|
||||
baseItem?.type == BaseItemKind.AUDIO -> baseItem.albumPrimaryImage
|
||||
else -> null
|
||||
} ?: baseItem?.itemImages[ImageType.PRIMARY]
|
||||
|
||||
return when (imageType) {
|
||||
BaseRowImageType.BANNER -> baseItem?.itemImages[ImageType.BANNER] ?: primaryImage
|
||||
BaseRowImageType.THUMB -> baseItem?.itemImages[ImageType.THUMB] ?: primaryImage
|
||||
else -> primaryImage
|
||||
}
|
||||
}
|
||||
|
||||
else -> when {
|
||||
baseItem?.criticRating != null -> when {
|
||||
baseItem.criticRating!! > 59f -> R.drawable.ic_rt_fresh
|
||||
else -> R.drawable.ic_rt_rotten
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other is BaseItemDtoBaseRowItem) return other.baseItem == baseItem
|
||||
return super.equals(other)
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}?.let { ContextCompat.getDrawable(context, it) }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.jellyfin.androidtv.ui.itemhandling
|
||||
|
||||
import android.content.Context
|
||||
import org.jellyfin.androidtv.constant.ImageType
|
||||
import org.jellyfin.androidtv.util.ImageHelper
|
||||
import org.jellyfin.androidtv.util.apiclient.primaryImage
|
||||
import org.jellyfin.sdk.model.api.BaseItemPerson
|
||||
|
||||
class BaseItemPersonBaseRowItem(
|
||||
@@ -11,14 +11,7 @@ class BaseItemPersonBaseRowItem(
|
||||
baseRowType = BaseRowType.Person,
|
||||
staticHeight = true,
|
||||
) {
|
||||
override fun getImageUrl(
|
||||
context: Context,
|
||||
imageHelper: ImageHelper,
|
||||
imageType: ImageType,
|
||||
fillWidth: Int,
|
||||
fillHeight: Int
|
||||
) = imageHelper.getPrimaryImageUrl(person, fillHeight)
|
||||
|
||||
override fun getImage(imageType: ImageType) = person.primaryImage
|
||||
override val itemId get() = person.id
|
||||
override fun getFullName(context: Context) = person.name
|
||||
override fun getName(context: Context) = person.name
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package org.jellyfin.androidtv.ui.itemhandling
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import org.jellyfin.androidtv.constant.ImageType
|
||||
import org.jellyfin.androidtv.util.ImageHelper
|
||||
import org.jellyfin.androidtv.util.apiclient.JellyfinImage
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import java.util.UUID
|
||||
|
||||
@@ -21,19 +20,12 @@ abstract class BaseRowItem protected constructor(
|
||||
|
||||
open fun getCardName(context: Context): String? = getFullName(context)
|
||||
|
||||
open fun getImageUrl(
|
||||
context: Context,
|
||||
imageHelper: ImageHelper,
|
||||
imageType: ImageType,
|
||||
fillWidth: Int,
|
||||
fillHeight: Int,
|
||||
): String? = null
|
||||
open fun getImage(imageType: ImageType): JellyfinImage? = null
|
||||
|
||||
open fun getFullName(context: Context): String? = null
|
||||
open fun getName(context: Context): String? = null
|
||||
open fun getSubText(context: Context): String? = null
|
||||
open fun getSummary(context: Context): String? = null
|
||||
open fun getBadgeImage(context: Context, imageHelper: ImageHelper): Drawable? = null
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other is BaseRowItem) return other.itemId == itemId
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.jellyfin.androidtv.ui.itemhandling
|
||||
import android.content.Context
|
||||
import org.jellyfin.androidtv.constant.ImageType
|
||||
import org.jellyfin.androidtv.data.model.ChapterItemInfo
|
||||
import org.jellyfin.androidtv.util.ImageHelper
|
||||
import org.jellyfin.androidtv.util.TimeUtils
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
|
||||
@@ -13,14 +12,7 @@ class ChapterItemInfoBaseRowItem(
|
||||
baseRowType = BaseRowType.Chapter,
|
||||
staticHeight = true,
|
||||
) {
|
||||
override fun getImageUrl(
|
||||
context: Context,
|
||||
imageHelper: ImageHelper,
|
||||
imageType: ImageType,
|
||||
fillWidth: Int,
|
||||
fillHeight: Int
|
||||
) = chapterInfo.imagePath
|
||||
|
||||
override fun getImage(imageType: ImageType) = chapterInfo.image
|
||||
override val itemId get() = chapterInfo.itemId
|
||||
override fun getFullName(context: Context) = chapterInfo.name
|
||||
override fun getName(context: Context) = chapterInfo.name
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.jellyfin.androidtv.ui.itemhandling
|
||||
import android.content.Context
|
||||
import org.jellyfin.androidtv.constant.ImageType
|
||||
import org.jellyfin.androidtv.ui.GridButton
|
||||
import org.jellyfin.androidtv.util.ImageHelper
|
||||
|
||||
class GridButtonBaseRowItem(
|
||||
val gridButton: GridButton,
|
||||
@@ -11,14 +10,7 @@ class GridButtonBaseRowItem(
|
||||
baseRowType = BaseRowType.GridButton,
|
||||
staticHeight = true,
|
||||
) {
|
||||
override fun getImageUrl(
|
||||
context: Context,
|
||||
imageHelper: ImageHelper,
|
||||
imageType: ImageType,
|
||||
fillWidth: Int,
|
||||
fillHeight: Int
|
||||
) = gridButton.imageRes?.let { imageHelper.getResourceUrl(context, it) }
|
||||
|
||||
override fun getImage(imageType: ImageType) = null
|
||||
override fun getFullName(context: Context) = gridButton.text
|
||||
override fun getName(context: Context) = gridButton.text
|
||||
}
|
||||
|
||||
@@ -56,13 +56,13 @@ public class ItemLauncher {
|
||||
LibraryPreferences displayPreferences = preferencesRepository.getValue().getLibraryPreferences(baseItem.getDisplayPreferencesId());
|
||||
boolean enableSmartScreen = displayPreferences.get(LibraryPreferences.Companion.getEnableSmartScreen());
|
||||
|
||||
if (!enableSmartScreen) return Destinations.INSTANCE.libraryBrowser(baseItem);
|
||||
if (!enableSmartScreen) return Destinations.INSTANCE.libraryBrowser(baseItem, null);
|
||||
else return Destinations.INSTANCE.librarySmartScreen(baseItem);
|
||||
case MUSIC:
|
||||
case LIVETV:
|
||||
return Destinations.INSTANCE.librarySmartScreen(baseItem);
|
||||
default:
|
||||
return Destinations.INSTANCE.libraryBrowser(baseItem);
|
||||
return Destinations.INSTANCE.libraryBrowser(baseItem, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ public class ItemLauncher {
|
||||
case BaseItem:
|
||||
BaseItemDto baseItem = rowItem.getBaseItem();
|
||||
try {
|
||||
Timber.d("Item selected: %s (%s)", baseItem.getName(), baseItem.getType().toString());
|
||||
Timber.i("Item selected: %s (%s)", baseItem.getName(), baseItem.getType().toString());
|
||||
} catch (Exception e) {
|
||||
//swallow it
|
||||
}
|
||||
@@ -147,7 +147,7 @@ public class ItemLauncher {
|
||||
baseItem = JavaCompat.copyWithDisplayPreferencesId(baseItem, baseItem.getId().toString());
|
||||
}
|
||||
|
||||
navigationRepository.getValue().navigate(Destinations.INSTANCE.libraryBrowser(baseItem));
|
||||
navigationRepository.getValue().navigate(Destinations.INSTANCE.libraryBrowser(baseItem, null));
|
||||
} else {
|
||||
switch (rowItem.getSelectAction()) {
|
||||
|
||||
|
||||
@@ -468,18 +468,18 @@ public class ItemRowAdapter extends MutableObjectAdapter<Object> {
|
||||
return;
|
||||
}
|
||||
if (isCurrentlyRetrieving()) {
|
||||
Timber.d("Not loading more because currently retrieving");
|
||||
Timber.i("Not loading more because currently retrieving");
|
||||
return;
|
||||
}
|
||||
// This needs tobe based on the actual estimated cards on screen via type of presenter and WindowAlignmentOffsetPercent
|
||||
if (chunkSize > 0) {
|
||||
// we can use chunkSize as indicator on when to load
|
||||
if (pos >= (itemsLoaded - (chunkSize / 1.7))) {
|
||||
Timber.d("Loading more items trigger pos <%s> itemsLoaded <%s> from total <%s> with chunkSize <%s>", pos, itemsLoaded, totalItems, chunkSize);
|
||||
Timber.i("Loading more items trigger pos <%s> itemsLoaded <%s> from total <%s> with chunkSize <%s>", pos, itemsLoaded, totalItems, chunkSize);
|
||||
retrieveNext();
|
||||
}
|
||||
} else if (pos >= itemsLoaded - 20) {
|
||||
Timber.d("Loading more items trigger pos <%s> itemsLoaded <%s> from total <%s>", pos, itemsLoaded, totalItems);
|
||||
Timber.i("Loading more items trigger pos <%s> itemsLoaded <%s> from total <%s>", pos, itemsLoaded, totalItems);
|
||||
retrieveNext();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ fun <T : Any> ItemRowAdapter.setItems(
|
||||
items: Collection<T>,
|
||||
transform: (T, Int) -> BaseRowItem?,
|
||||
) {
|
||||
Timber.d("Creating items from $itemsLoaded existing and ${items.size} new, adapter size is ${size()}")
|
||||
Timber.i("Creating items from $itemsLoaded existing and ${items.size} new, adapter size is ${size()}")
|
||||
|
||||
val allItems = buildList {
|
||||
// Add current items before loaded items
|
||||
@@ -235,11 +235,10 @@ fun ItemRowAdapter.retrieveUserViews(api: ApiClient, userViewsRepository: UserVi
|
||||
|
||||
val filteredItems = response.items
|
||||
.filter { userViewsRepository.isSupported(it.collectionType) }
|
||||
.map { it.copy(displayPreferencesId = it.id.toString()) }
|
||||
|
||||
setItems(
|
||||
items = filteredItems,
|
||||
transform = { item, _ -> BaseItemDtoBaseRowItem(item) }
|
||||
transform = { item, _ -> BaseItemDtoBaseRowItem(item, staticHeight = true) }
|
||||
)
|
||||
|
||||
if (filteredItems.isEmpty()) removeRow()
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.jellyfin.androidtv.ui.itemhandling
|
||||
import android.content.Context
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.constant.ImageType
|
||||
import org.jellyfin.androidtv.util.ImageHelper
|
||||
import org.jellyfin.androidtv.util.apiclient.getSeriesOverview
|
||||
import org.jellyfin.sdk.model.api.SeriesTimerInfoDto
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
@@ -13,17 +12,7 @@ class SeriesTimerInfoDtoBaseRowItem(
|
||||
) : BaseRowItem(
|
||||
baseRowType = BaseRowType.SeriesTimer,
|
||||
) {
|
||||
override fun getImageUrl(
|
||||
context: Context,
|
||||
imageHelper: ImageHelper,
|
||||
imageType: ImageType,
|
||||
fillWidth: Int,
|
||||
fillHeight: Int
|
||||
) = imageHelper.getResourceUrl(
|
||||
context,
|
||||
R.drawable.tile_land_series_timer
|
||||
)
|
||||
|
||||
override fun getImage(imageType: ImageType) = null
|
||||
override fun getFullName(context: Context) = seriesTimerInfo.name
|
||||
override fun getName(context: Context) = seriesTimerInfo.name
|
||||
override val itemId get() = seriesTimerInfo.id?.toUUIDOrNull()
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package org.jellyfin.androidtv.ui.livetv
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.preference.SystemPreferences
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.OptionsFragment
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.OptionsItemCheckbox
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.checkbox
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.optionsScreen
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
class GuideFiltersScreen : OptionsFragment() {
|
||||
private val systemPreferences: SystemPreferences by inject()
|
||||
|
||||
override val screen by optionsScreen {
|
||||
setTitle(R.string.lbl_filters)
|
||||
|
||||
category {
|
||||
setOf(
|
||||
SystemPreferences.liveTvGuideFilterMovies to getString(R.string.lbl_movies),
|
||||
SystemPreferences.liveTvGuideFilterSeries to getString(R.string.lbl_series),
|
||||
SystemPreferences.liveTvGuideFilterNews to getString(R.string.lbl_news),
|
||||
SystemPreferences.liveTvGuideFilterKids to getString(R.string.lbl_kids),
|
||||
SystemPreferences.liveTvGuideFilterSports to getString(R.string.lbl_sports),
|
||||
SystemPreferences.liveTvGuideFilterPremiere to getString(R.string.lbl_new_only),
|
||||
).forEach { (preference, label) ->
|
||||
checkbox {
|
||||
title = label
|
||||
type = OptionsItemCheckbox.Type.SWITCH
|
||||
bind(systemPreferences, preference)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package org.jellyfin.androidtv.ui.livetv
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.preference.LiveTvPreferences
|
||||
import org.jellyfin.androidtv.preference.constant.LiveTvChannelOrder
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.OptionsFragment
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.OptionsItemCheckbox
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.checkbox
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.enum
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.optionsScreen
|
||||
import org.jellyfin.preference.store.PreferenceStore
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
class GuideOptionsScreen : OptionsFragment() {
|
||||
private val liveTvPreferences: LiveTvPreferences by inject()
|
||||
|
||||
override val stores: Array<PreferenceStore<*, *>>
|
||||
get() = arrayOf(liveTvPreferences)
|
||||
|
||||
override val screen by optionsScreen {
|
||||
setTitle(R.string.live_tv_preferences)
|
||||
|
||||
category {
|
||||
enum<LiveTvChannelOrder> {
|
||||
title = getString(R.string.lbl_sort_by)
|
||||
|
||||
bind {
|
||||
get { LiveTvChannelOrder.fromString(liveTvPreferences[LiveTvPreferences.channelOrder]) }
|
||||
set { liveTvPreferences[LiveTvPreferences.channelOrder] = it.stringValue }
|
||||
default { LiveTvChannelOrder.fromString(liveTvPreferences.getDefaultValue(LiveTvPreferences.channelOrder)) }
|
||||
}
|
||||
}
|
||||
|
||||
checkbox {
|
||||
title = getString(R.string.lbl_start_favorites)
|
||||
bind(liveTvPreferences, LiveTvPreferences.favsAtTop)
|
||||
}
|
||||
|
||||
checkbox {
|
||||
title = getString(R.string.lbl_colored_backgrounds)
|
||||
bind(liveTvPreferences, LiveTvPreferences.colorCodeGuide)
|
||||
}
|
||||
}
|
||||
|
||||
category {
|
||||
title = getString(R.string.lbl_show_indicators)
|
||||
|
||||
setOf(
|
||||
LiveTvPreferences.showHDIndicator to getString(R.string.lbl_hd_programs),
|
||||
LiveTvPreferences.showLiveIndicator to getString(R.string.lbl_live_broadcasts),
|
||||
LiveTvPreferences.showNewIndicator to getString(R.string.lbl_new_episodes),
|
||||
LiveTvPreferences.showPremiereIndicator to getString(R.string.lbl_premieres),
|
||||
LiveTvPreferences.showRepeatIndicator to getString(R.string.lbl_repeat_episodes),
|
||||
).forEach { (preference, label) ->
|
||||
checkbox {
|
||||
title = label
|
||||
type = OptionsItemCheckbox.Type.SWITCH
|
||||
bind(liveTvPreferences, preference)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,6 @@ import org.jellyfin.androidtv.ui.ObservableHorizontalScrollView;
|
||||
import org.jellyfin.androidtv.ui.ObservableScrollView;
|
||||
import org.jellyfin.androidtv.ui.ProgramGridCell;
|
||||
import org.jellyfin.androidtv.ui.ScrollViewListener;
|
||||
import org.jellyfin.androidtv.ui.navigation.ActivityDestinations;
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackLauncher;
|
||||
import org.jellyfin.androidtv.util.CoroutineUtils;
|
||||
import org.jellyfin.androidtv.util.DateTimeExtensionsKt;
|
||||
@@ -59,6 +58,7 @@ import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import kotlin.Lazy;
|
||||
import kotlinx.coroutines.flow.MutableStateFlow;
|
||||
import timber.log.Timber;
|
||||
|
||||
public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.OnKeyListener {
|
||||
@@ -105,6 +105,8 @@ public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.O
|
||||
private final Lazy<PlaybackHelper> playbackHelper = inject(PlaybackHelper.class);
|
||||
private final Lazy<ImageHelper> imageHelper = inject(ImageHelper.class);
|
||||
private final Lazy<PlaybackLauncher> playbackLauncher = inject(PlaybackLauncher.class);
|
||||
private MutableStateFlow<Boolean> showOptions;
|
||||
private MutableStateFlow<Boolean> showFilterOptions;
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
@@ -129,20 +131,22 @@ public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.O
|
||||
mSpinner = binding.spinner;
|
||||
mSpinner.setVisibility(View.VISIBLE);
|
||||
|
||||
showFilterOptions = LiveTvGuideFragmentHelperKt.addSettingsFilters(this, binding);
|
||||
View mFilterButton = binding.filterButton;
|
||||
mFilterButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
showFilterOptions();
|
||||
showFilterOptions.setValue(true);
|
||||
}
|
||||
});
|
||||
mFilterButton.setContentDescription(getString(R.string.lbl_filters));
|
||||
|
||||
showOptions = LiveTvGuideFragmentHelperKt.addSettingsOptions(this, binding);
|
||||
View mOptionsButton = binding.optionsButton;
|
||||
mOptionsButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
showOptions();
|
||||
showOptions.setValue(true);
|
||||
}
|
||||
});
|
||||
mOptionsButton.setContentDescription(getString(R.string.lbl_other_options));
|
||||
@@ -317,7 +321,7 @@ public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.O
|
||||
switch (keyCode) {
|
||||
case KeyEvent.KEYCODE_MENU:
|
||||
// bring up filter selection
|
||||
showFilterOptions();
|
||||
showFilterOptions.setValue(true);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_ENTER:
|
||||
case KeyEvent.KEYCODE_DPAD_CENTER:
|
||||
@@ -451,16 +455,6 @@ public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.O
|
||||
mDetailPopup.show(mImage, mTitle.getLeft(), mTitle.getTop() - 10);
|
||||
}
|
||||
|
||||
public void showFilterOptions() {
|
||||
startActivity(ActivityDestinations.INSTANCE.liveTvGuideFilterPreferences(getContext()));
|
||||
TvManager.forceReload();
|
||||
}
|
||||
|
||||
public void showOptions() {
|
||||
startActivity(ActivityDestinations.INSTANCE.liveTvGuideOptionPreferences(getContext()));
|
||||
TvManager.forceReload();
|
||||
}
|
||||
|
||||
public void displayChannels(int start, int max) {
|
||||
int end = start + max;
|
||||
if (end > mAllChannels.size()) {
|
||||
|
||||
@@ -2,15 +2,24 @@ package org.jellyfin.androidtv.ui.livetv
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.data.model.DataRefreshService
|
||||
import org.jellyfin.androidtv.data.repository.ItemMutationRepository
|
||||
import org.jellyfin.androidtv.databinding.LiveTvGuideBinding
|
||||
import org.jellyfin.androidtv.ui.GuideChannelHeader
|
||||
import org.jellyfin.androidtv.ui.navigation.ProvideRouter
|
||||
import org.jellyfin.androidtv.ui.settings.Routes
|
||||
import org.jellyfin.androidtv.ui.settings.composable.SettingsDialog
|
||||
import org.jellyfin.androidtv.ui.settings.composable.SettingsRouterContent
|
||||
import org.jellyfin.androidtv.ui.settings.routes
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
@@ -74,3 +83,55 @@ fun LiveTvGuideFragment.refreshSelectedProgram() {
|
||||
detailUpdateInternal();
|
||||
}
|
||||
}
|
||||
|
||||
fun LiveTvGuideFragment.addSettingsOptions(binding: LiveTvGuideBinding): MutableStateFlow<Boolean> {
|
||||
val visible = MutableStateFlow(false)
|
||||
|
||||
binding.settingsOptions.setContent {
|
||||
val isVisible by visible.collectAsState(false)
|
||||
|
||||
ProvideRouter(
|
||||
routes,
|
||||
Routes.LIVETV_GUIDE_OPTIONS
|
||||
) {
|
||||
SettingsDialog(
|
||||
visible = isVisible,
|
||||
onDismissRequest = {
|
||||
visible.value = false
|
||||
TvManager.forceReload()
|
||||
doLoad()
|
||||
}
|
||||
) {
|
||||
SettingsRouterContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visible
|
||||
}
|
||||
|
||||
fun LiveTvGuideFragment.addSettingsFilters(binding: LiveTvGuideBinding): MutableStateFlow<Boolean> {
|
||||
val visible = MutableStateFlow(false)
|
||||
|
||||
binding.settingsFilters.setContent {
|
||||
val isVisible by visible.collectAsState(false)
|
||||
|
||||
ProvideRouter(
|
||||
routes,
|
||||
Routes.LIVETV_GUIDE_FILTERS
|
||||
) {
|
||||
SettingsDialog(
|
||||
visible = isVisible,
|
||||
onDismissRequest = {
|
||||
visible.value = false
|
||||
TvManager.forceReload()
|
||||
doLoad()
|
||||
}
|
||||
) {
|
||||
SettingsRouterContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visible
|
||||
}
|
||||
|
||||
@@ -3,40 +3,11 @@ package org.jellyfin.androidtv.ui.navigation
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.os.bundleOf
|
||||
import org.jellyfin.androidtv.ui.browsing.DisplayPreferencesScreen
|
||||
import org.jellyfin.androidtv.ui.livetv.GuideFiltersScreen
|
||||
import org.jellyfin.androidtv.ui.livetv.GuideOptionsScreen
|
||||
import org.jellyfin.androidtv.ui.playback.ExternalPlayerActivity
|
||||
import org.jellyfin.androidtv.ui.preference.PreferencesActivity
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.OptionsFragment
|
||||
import org.jellyfin.androidtv.ui.preference.screen.UserPreferencesScreen
|
||||
import org.jellyfin.androidtv.ui.startup.StartupActivity
|
||||
import kotlin.time.Duration
|
||||
|
||||
object ActivityDestinations {
|
||||
private inline fun <reified T : OptionsFragment> preferenceIntent(
|
||||
context: Context,
|
||||
vararg screenArguments: Pair<String, Any?>
|
||||
) = Intent(context, PreferencesActivity::class.java).apply {
|
||||
putExtras(
|
||||
bundleOf(
|
||||
PreferencesActivity.EXTRA_SCREEN to T::class.qualifiedName,
|
||||
PreferencesActivity.EXTRA_SCREEN_ARGS to bundleOf(*screenArguments),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun userPreferences(context: Context) = preferenceIntent<UserPreferencesScreen>(context)
|
||||
fun displayPreferences(context: Context, displayPreferencesId: String, allowViewSelection: Boolean) =
|
||||
preferenceIntent<DisplayPreferencesScreen>(
|
||||
context,
|
||||
DisplayPreferencesScreen.ARG_PREFERENCES_ID to displayPreferencesId,
|
||||
DisplayPreferencesScreen.ARG_ALLOW_VIEW_SELECTION to allowViewSelection,
|
||||
)
|
||||
|
||||
fun liveTvGuideFilterPreferences(context: Context) = preferenceIntent<GuideFiltersScreen>(context)
|
||||
fun liveTvGuideOptionPreferences(context: Context) = preferenceIntent<GuideOptionsScreen>(context)
|
||||
|
||||
fun externalPlayer(context: Context, position: Duration = Duration.ZERO) = Intent(context, ExternalPlayerActivity::class.java).apply {
|
||||
putExtras(
|
||||
bundleOf(
|
||||
|
||||
@@ -39,50 +39,45 @@ object Destinations {
|
||||
|
||||
// Browsing
|
||||
// TODO only pass item id instead of complete JSON to browsing destinations
|
||||
fun libraryBrowser(item: BaseItemDto) = fragmentDestination<BrowseGridFragment>(
|
||||
Extras.Folder to Json.Default.encodeToString(item),
|
||||
)
|
||||
|
||||
// TODO only pass item id instead of complete JSON to browsing destinations
|
||||
fun libraryBrowser(item: BaseItemDto, includeType: String) =
|
||||
fun libraryBrowser(item: BaseItemDto, includeType: String? = null) =
|
||||
fragmentDestination<BrowseGridFragment>(
|
||||
Extras.Folder to Json.Default.encodeToString(item),
|
||||
Extras.Folder to Json.encodeToString(item),
|
||||
Extras.IncludeType to includeType,
|
||||
)
|
||||
|
||||
// TODO only pass item id instead of complete JSON to browsing destinations
|
||||
fun librarySmartScreen(item: BaseItemDto) = fragmentDestination<BrowseViewFragment>(
|
||||
Extras.Folder to Json.Default.encodeToString(item),
|
||||
Extras.Folder to Json.encodeToString(item),
|
||||
)
|
||||
|
||||
// TODO only pass item id instead of complete JSON to browsing destinations
|
||||
fun collectionBrowser(item: BaseItemDto) = fragmentDestination<CollectionFragment>(
|
||||
Extras.Folder to Json.Default.encodeToString(item),
|
||||
Extras.Folder to Json.encodeToString(item),
|
||||
)
|
||||
|
||||
// TODO only pass item id instead of complete JSON to browsing destinations
|
||||
fun folderBrowser(item: BaseItemDto) = fragmentDestination<GenericFolderFragment>(
|
||||
Extras.Folder to Json.Default.encodeToString(item),
|
||||
Extras.Folder to Json.encodeToString(item),
|
||||
)
|
||||
|
||||
// TODO only pass item id instead of complete JSON to browsing destinations
|
||||
fun libraryByGenres(item: BaseItemDto, includeType: String) =
|
||||
fragmentDestination<ByGenreFragment>(
|
||||
Extras.Folder to Json.Default.encodeToString(item),
|
||||
Extras.Folder to Json.encodeToString(item),
|
||||
Extras.IncludeType to includeType,
|
||||
)
|
||||
|
||||
// TODO only pass item id instead of complete JSON to browsing destinations
|
||||
fun libraryByLetter(item: BaseItemDto, includeType: String) =
|
||||
fragmentDestination<ByLetterFragment>(
|
||||
Extras.Folder to Json.Default.encodeToString(item),
|
||||
Extras.Folder to Json.encodeToString(item),
|
||||
Extras.IncludeType to includeType,
|
||||
)
|
||||
|
||||
// TODO only pass item id instead of complete JSON to browsing destinations
|
||||
fun librarySuggestions(item: BaseItemDto) =
|
||||
fragmentDestination<SuggestedMoviesFragment>(
|
||||
Extras.Folder to Json.Default.encodeToString(item),
|
||||
Extras.Folder to Json.encodeToString(item),
|
||||
)
|
||||
|
||||
// Item details
|
||||
@@ -95,14 +90,14 @@ object Destinations {
|
||||
fragmentDestination<FullDetailsFragment>(
|
||||
"ItemId" to item.toString(),
|
||||
"ChannelId" to channel.toString(),
|
||||
"ProgramInfo" to Json.Default.encodeToString(programInfo),
|
||||
"ProgramInfo" to Json.encodeToString(programInfo),
|
||||
)
|
||||
|
||||
// TODO only pass item id instead of complete JSON to browsing destinations
|
||||
fun seriesTimerDetails(item: UUID, seriesTimer: SeriesTimerInfoDto) =
|
||||
fragmentDestination<FullDetailsFragment>(
|
||||
"ItemId" to item.toString(),
|
||||
"SeriesTimer" to Json.Default.encodeToString(seriesTimer),
|
||||
"SeriesTimer" to Json.encodeToString(seriesTimer),
|
||||
)
|
||||
|
||||
fun itemList(item: UUID) = fragmentDestination<ItemListFragment>(
|
||||
|
||||
@@ -68,7 +68,7 @@ class NavigationRepositoryImpl(
|
||||
override val currentAction = _currentAction.asSharedFlow()
|
||||
|
||||
override fun navigate(destination: Destination, replace: Boolean) {
|
||||
Timber.d("Navigating to $destination (via navigate function)")
|
||||
Timber.i("Navigating to $destination (via navigate function)")
|
||||
val action = when (destination) {
|
||||
is Destination.Fragment -> NavigationAction.NavigateFragment(destination, true, replace, false)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ class NavigationRepositoryImpl(
|
||||
override fun goBack(): Boolean {
|
||||
if (fragmentHistory.empty()) return false
|
||||
|
||||
Timber.d("Navigating back")
|
||||
Timber.i("Navigating back")
|
||||
fragmentHistory.pop()
|
||||
_currentAction.tryEmit(NavigationAction.GoBack)
|
||||
return true
|
||||
@@ -94,7 +94,7 @@ class NavigationRepositoryImpl(
|
||||
fragmentHistory.clear()
|
||||
val actualDestination = destination ?: defaultDestination
|
||||
_currentAction.tryEmit(NavigationAction.NavigateFragment(actualDestination, true, false, clearHistory))
|
||||
Timber.d("Navigating to $actualDestination (via reset, clearHistory=$clearHistory)")
|
||||
Timber.i("Navigating to $actualDestination (via reset, clearHistory=$clearHistory)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
120
app/src/main/java/org/jellyfin/androidtv/ui/navigation/router.kt
Normal file
120
app/src/main/java/org/jellyfin/androidtv/ui/navigation/router.kt
Normal file
@@ -0,0 +1,120 @@
|
||||
package org.jellyfin.androidtv.ui.navigation
|
||||
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.scene.Scene
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.navigation3.ui.defaultPopTransitionSpec
|
||||
import androidx.navigation3.ui.defaultTransitionSpec
|
||||
import androidx.navigationevent.NavigationEvent
|
||||
|
||||
typealias RouteParameters = Map<String, String>
|
||||
typealias RouteComposable = @Composable ((context: RouteContext) -> Unit)
|
||||
|
||||
data class RouteContext(
|
||||
val route: String,
|
||||
val parameters: RouteParameters,
|
||||
)
|
||||
|
||||
class Router(
|
||||
val routes: Map<String, RouteComposable>,
|
||||
val backStack: SnapshotStateList<RouteContext>,
|
||||
) {
|
||||
// Route resolving
|
||||
|
||||
fun resolve(route: String): RouteComposable? = routes[route]
|
||||
fun verifyRoute(route: String, parameters: RouteParameters = emptyMap()) = require(resolve(route) != null) { "Invalid route $route" }
|
||||
|
||||
// Route manipulation
|
||||
|
||||
fun push(route: String, parameters: RouteParameters = emptyMap()) {
|
||||
verifyRoute(route, parameters)
|
||||
|
||||
val context = RouteContext(route, parameters)
|
||||
backStack.add(context)
|
||||
}
|
||||
|
||||
fun replace(route: String, parameters: RouteParameters = emptyMap()) {
|
||||
verifyRoute(route, parameters)
|
||||
|
||||
val context = RouteContext(route, parameters)
|
||||
backStack.removeLastOrNull()
|
||||
backStack.add(context)
|
||||
}
|
||||
|
||||
fun back() {
|
||||
backStack.removeLastOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
val LocalRouter = compositionLocalOf<Router> { error("No router provided") }
|
||||
val LocalRouterTransitionScope = compositionLocalOf<SharedTransitionScope> { error("No router transition scope provided") }
|
||||
|
||||
@Composable
|
||||
fun ProvideRouter(
|
||||
routes: Map<String, RouteComposable>,
|
||||
defaultRoute: String,
|
||||
defaultRouteParameters: RouteParameters = emptyMap(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val backStack = remember { mutableStateListOf(RouteContext(defaultRoute, defaultRouteParameters)) }
|
||||
val router = remember(routes, backStack) {
|
||||
Router(
|
||||
routes = routes,
|
||||
backStack = backStack,
|
||||
)
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalRouter provides router,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RouterContent(
|
||||
router: Router = LocalRouter.current,
|
||||
fallbackRoute: String = "/",
|
||||
transitionSpec: AnimatedContentTransitionScope<Scene<RouteContext>>.() -> ContentTransform = defaultTransitionSpec(),
|
||||
popTransitionSpec: AnimatedContentTransitionScope<Scene<RouteContext>>.() -> ContentTransform = defaultPopTransitionSpec(),
|
||||
predictivePopTransitionSpec: AnimatedContentTransitionScope<Scene<RouteContext>>.(@NavigationEvent.SwipeEdge Int) -> ContentTransform = { popTransitionSpec() },
|
||||
) {
|
||||
SharedTransitionLayout {
|
||||
CompositionLocalProvider(LocalRouterTransitionScope provides this@SharedTransitionLayout) {
|
||||
NavDisplay(
|
||||
backStack = router.backStack,
|
||||
onBack = { router.back() },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
),
|
||||
transitionSpec = transitionSpec,
|
||||
popTransitionSpec = popTransitionSpec,
|
||||
predictivePopTransitionSpec = predictivePopTransitionSpec,
|
||||
entryProvider = { backStackEntry ->
|
||||
NavEntry(backStackEntry) {
|
||||
val route = backStackEntry.route
|
||||
val composable = router.resolve(route)
|
||||
if (composable == null) {
|
||||
val fallbackComposable = router.resolve(fallbackRoute)
|
||||
?: error("Unknown route $route, fallback $fallbackRoute is invalid")
|
||||
val context = backStackEntry.copy(route = fallbackRoute)
|
||||
fallbackComposable(context)
|
||||
} else {
|
||||
composable(backStackEntry)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
|
||||
interface AudioEventListener {
|
||||
fun onPlaybackStateChange(newState: PlaybackController.PlaybackState, currentItem: BaseItemDto?) = Unit
|
||||
fun onProgress(pos: Long) = Unit
|
||||
fun onProgress(pos: Long, duration: Long) = Unit
|
||||
fun onQueueStatusChanged(hasQueue: Boolean) = Unit
|
||||
fun onQueueReplaced() = Unit
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ public class AudioNowPlayingFragment extends Fragment {
|
||||
private TextView mCurrentNdx;
|
||||
private TextView mCurrentPos;
|
||||
private TextView mRemainingTime;
|
||||
private int mCurrentDuration;
|
||||
private RowsSupportFragment mRowsFragment;
|
||||
private ArrayObjectAdapter mRowsAdapter;
|
||||
private PositionableListRowPresenter mAudioQueuePresenter;
|
||||
@@ -239,7 +238,7 @@ public class AudioNowPlayingFragment extends Fragment {
|
||||
|
||||
// load the item duration and set the position to 0 since it won't be set elsewhere until playback is initialized
|
||||
if (!mediaManager.getValue().isAudioPlayerInitialized())
|
||||
setCurrentTime(0);
|
||||
setCurrentTime(0, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -258,8 +257,8 @@ public class AudioNowPlayingFragment extends Fragment {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProgress(long pos) {
|
||||
setCurrentTime(pos);
|
||||
public void onProgress(long pos, long duration) {
|
||||
setCurrentTime(pos, duration);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -354,17 +353,19 @@ public class AudioNowPlayingFragment extends Fragment {
|
||||
mAlbumTitle.setText(null);
|
||||
}
|
||||
mCurrentNdx.setText(getString(R.string.lbl_now_playing_track, mediaManager.getValue().getCurrentAudioQueueDisplayPosition(), mediaManager.getValue().getCurrentAudioQueueDisplaySize()));
|
||||
mCurrentDuration = ((Long) ((item.getRunTimeTicks() != null ? item.getRunTimeTicks() : 0) / 10000)).intValue();
|
||||
addGenres(mGenreRow);
|
||||
backgroundService.getValue().setBackground(item);
|
||||
}
|
||||
}
|
||||
|
||||
public void setCurrentTime(long time) {
|
||||
// Round the current time as otherwise the time played and time remaining will not be in sync
|
||||
public void setCurrentTime(long time, long duration) {
|
||||
// Round the time to seconds so both the position and remaining times are in sync
|
||||
time = Math.round(time / 1000L) * 1000L;
|
||||
|
||||
mCurrentPos.setText(TimeUtils.formatMillis(time));
|
||||
mRemainingTime.setText(mCurrentDuration > 0 ? "-" + TimeUtils.formatMillis(mCurrentDuration - time) : "");
|
||||
|
||||
if (duration == 0L) mRemainingTime.setText(null);
|
||||
else mRemainingTime.setText("-" + TimeUtils.formatMillis(duration - time));
|
||||
}
|
||||
|
||||
private void addGenres(TextView textView) {
|
||||
|
||||
@@ -37,8 +37,8 @@ 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.playback.jellyfin.lyrics.lyrics
|
||||
import org.jellyfin.playback.jellyfin.lyrics.lyricsFlow
|
||||
import org.jellyfin.playback.jellyfin.queue.baseItem
|
||||
import org.jellyfin.playback.jellyfin.queue.baseItemFlow
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
|
||||
@@ -7,7 +7,6 @@ import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.media.AudioManager;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.view.KeyEvent;
|
||||
@@ -698,7 +697,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
@Override
|
||||
public void onStop() {
|
||||
super.onStop();
|
||||
Timber.d("Stopping!");
|
||||
Timber.i("Stopping!");
|
||||
|
||||
if (leanbackOverlayFragment != null)
|
||||
leanbackOverlayFragment.setOnKeyInterceptListener(null);
|
||||
@@ -706,7 +705,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
// end playback from here if this fragment belongs to the current session.
|
||||
// if it doesn't, playback has already been stopped elsewhere, and the references to this have been replaced
|
||||
if (playbackControllerContainer.getValue().getPlaybackController() != null && playbackControllerContainer.getValue().getPlaybackController().getFragment() == this) {
|
||||
Timber.d("this fragment belongs to the current session, ending it");
|
||||
Timber.i("this fragment belongs to the current session, ending it");
|
||||
playbackControllerContainer.getValue().getPlaybackController().endPlayback();
|
||||
}
|
||||
|
||||
@@ -1283,7 +1282,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
|
||||
if (chapters != null && !chapters.isEmpty()) {
|
||||
// create chapter row for later use
|
||||
ItemRowAdapter chapterAdapter = new ItemRowAdapter(requireContext(), BaseItemExtensionsKt.buildChapterItems(item, api.getValue()), new CardPresenter(true, 110), new MutableObjectAdapter<Row>());
|
||||
ItemRowAdapter chapterAdapter = new ItemRowAdapter(requireContext(), BaseItemExtensionsKt.buildChapterItems(item), new CardPresenter(true, 110), new MutableObjectAdapter<Row>());
|
||||
chapterAdapter.Retrieve();
|
||||
if (mChapterRow != null) mPopupRowAdapter.remove(mChapterRow);
|
||||
mChapterRow = new ListRow(new HeaderItem(requireContext().getString(R.string.chapters)), chapterAdapter);
|
||||
@@ -1340,10 +1339,8 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
WindowCompat.getInsetsController(requireActivity().getWindow(), requireActivity().getWindow().getDecorView()).show(WindowInsetsCompat.Type.systemBars());
|
||||
|
||||
// Reset display mode
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
WindowManager.LayoutParams params = getActivity().getWindow().getAttributes();
|
||||
params.preferredDisplayModeId = 0;
|
||||
getActivity().getWindow().setAttributes(params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,12 +159,12 @@ fun CustomPlaybackOverlayFragment.recordProgram(program: BaseItemDto, isSeries:
|
||||
lifecycleScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val defaultTimer by api.liveTvApi.getDefaultTimer()
|
||||
val defaultTimer by api.liveTvApi.getDefaultTimer(program.id.toString())
|
||||
|
||||
if (isSeries) {
|
||||
api.liveTvApi.createSeriesTimer(defaultTimer.copy(programId = program.id.toString()))
|
||||
api.liveTvApi.createSeriesTimer(defaultTimer)
|
||||
} else {
|
||||
api.liveTvApi.createTimer(defaultTimer.asTimerInfoDto().copy(programId = program.id.toString()))
|
||||
api.liveTvApi.createTimer(defaultTimer.asTimerInfoDto())
|
||||
}
|
||||
}
|
||||
}.fold(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user