mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-11-09 08:38:36 +01:00
Compare commits
483 commits
81e5f31401
...
365f8f8ada
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
365f8f8ada | ||
|
|
170989c31e | ||
|
|
32a56e8387 | ||
|
|
ffdebd43c3 | ||
|
|
8effd2b1e7 | ||
|
|
61f7f70d36 | ||
|
|
62965eae48 | ||
|
|
7af7bc0aa7 | ||
|
|
45c1ceca27 | ||
|
|
2fb295cbed | ||
|
|
190c6be41a | ||
|
|
af6a3b9916 | ||
|
|
a60433dbbf | ||
|
|
f181770870 | ||
|
|
923f0b7ea3 | ||
|
|
391a38e777 | ||
|
|
cd0128e1db | ||
|
|
0db4d45dbc | ||
|
|
584c3566d6 | ||
|
|
f79c0bdfe7 | ||
|
|
bad47eb638 | ||
|
|
8a570b89fb | ||
|
|
7bd1e993d0 | ||
|
|
c0f6293ea7 | ||
|
|
6647cf338c | ||
|
|
3598e3010b | ||
|
|
60a0d633bd | ||
|
|
5ee6b31018 | ||
|
|
1e86616d2a | ||
|
|
7b39854621 | ||
|
|
8e050a0830 | ||
|
|
114df65de3 | ||
|
|
d6b1d77c28 | ||
|
|
6e12b0c776 | ||
|
|
026f8c7592 | ||
|
|
2ade8173ff | ||
|
|
8c2cbcb7b2 | ||
|
|
de6cf7fb59 | ||
|
|
c8d202c3fa | ||
|
|
7f587b9f50 | ||
|
|
cf83266e4a | ||
|
|
0398f3f689 | ||
|
|
cbebc9e63c | ||
|
|
e73d0961ab | ||
|
|
5a9e5c406f | ||
|
|
c0401914a7 | ||
|
|
0b275a2daa | ||
|
|
8931bf5d7a | ||
|
|
4f4e13723c | ||
|
|
db7cfaee9b | ||
|
|
048a5f35b6 | ||
|
|
9602b4f3f0 | ||
|
|
d6eb23d29f | ||
|
|
f3239c7b15 | ||
|
|
f7c391ad54 | ||
|
|
02163ba47c | ||
|
|
98fba1340f | ||
|
|
074eab22f3 | ||
|
|
4a118b6ddb | ||
|
|
616565f671 | ||
|
|
6486bd19ed | ||
|
|
9c56965c82 | ||
|
|
0b995b5404 | ||
|
|
4a567cbcf4 | ||
|
|
1778bdb34c | ||
|
|
39239dd444 | ||
|
|
1f3a861c47 | ||
|
|
826bbad49c | ||
|
|
6fbf6209a1 | ||
|
|
cedca9b2b1 | ||
|
|
5363db0b80 | ||
|
|
cc2e7dfdda | ||
|
|
fdde986029 | ||
|
|
786b95a393 | ||
|
|
f91bca18ff | ||
|
|
2b9af1e8bc | ||
|
|
088c276129 | ||
|
|
bdef33c973 | ||
|
|
ec67d51936 | ||
|
|
adf299e1ee | ||
|
|
f84ea0e758 | ||
|
|
f00622be13 | ||
|
|
18174b6e91 | ||
|
|
e1a6fcc2f5 | ||
|
|
6ab1f96aca | ||
|
|
7cef2204ea | ||
|
|
28d38258b4 | ||
|
|
97b8c1f2e6 | ||
|
|
797170d758 | ||
|
|
41443dd85d | ||
|
|
1978947b25 | ||
|
|
f06dba281d | ||
|
|
9acc8251e2 | ||
|
|
df3d6e2a78 | ||
|
|
78a3f3b929 | ||
|
|
4882ff87e1 | ||
|
|
ad2a7109f7 | ||
|
|
e48a8e089f | ||
|
|
c26fa32aa9 | ||
|
|
7d0dd26f42 | ||
|
|
4b927e6b7b | ||
|
|
39523aa6e2 | ||
|
|
ed204ef7e9 | ||
|
|
b064af7c4e | ||
|
|
c4264a3263 | ||
|
|
197af604d6 | ||
|
|
f89d752d2c | ||
|
|
4e53cbaa7f | ||
|
|
22a949bc76 | ||
|
|
007ac4801c | ||
|
|
4b5d44ef1c | ||
|
|
8ea73b6f3a | ||
|
|
40d01b54ef | ||
|
|
2033f1857a | ||
|
|
894e0db459 | ||
|
|
14a1f2e110 | ||
|
|
84185530c9 | ||
|
|
ea28a2d58f | ||
|
|
d463dbf876 | ||
|
|
0e70fe030a | ||
|
|
e2a5f30ed5 | ||
|
|
3cbb738af8 | ||
|
|
88fbae9867 | ||
|
|
8144b674e2 | ||
|
|
a0ee9ce45c | ||
|
|
0cb2d10455 | ||
|
|
486cf527bd | ||
|
|
9def1b964b | ||
|
|
e6f93cd809 | ||
|
|
c10e410fd3 | ||
|
|
884735df60 | ||
|
|
f7509767b0 | ||
|
|
fc9d2287d7 | ||
|
|
b12b078293 | ||
|
|
fe943fef4e | ||
|
|
2110d846c1 | ||
|
|
ba52915cf0 | ||
|
|
51ab39ed3b | ||
|
|
54966f9124 | ||
|
|
e6304f79a2 | ||
|
|
548ec09af3 | ||
|
|
c6d81dada8 | ||
|
|
1c0e9a5853 | ||
|
|
de30c2ccac | ||
|
|
5219b22fc7 | ||
|
|
bd1a6c44ee | ||
|
|
e94e82479b | ||
|
|
5f44f870df | ||
|
|
2745e76b82 | ||
|
|
c1abb57cd2 | ||
|
|
a0966dd05a | ||
|
|
454c0212b6 | ||
|
|
7581b189aa | ||
|
|
fceb5f81a7 | ||
|
|
d7d63f52c3 | ||
|
|
6ad96865e7 | ||
|
|
7c6ab39436 | ||
|
|
1874b5babe | ||
|
|
89933c83f9 | ||
|
|
c1b055db3a | ||
|
|
145f716da4 | ||
|
|
836706fffa | ||
|
|
2ac7a3d83f | ||
|
|
e24ef7783e | ||
|
|
45f3cc7665 | ||
|
|
99c5ce2fdf | ||
|
|
ab400fd7fb | ||
|
|
708ba9dfab | ||
|
|
712fbcc9b8 | ||
|
|
e3afc3c583 | ||
|
|
d73cf5e9e8 | ||
|
|
46347abbc7 | ||
|
|
45f05fbc69 | ||
|
|
24792279cc | ||
|
|
fe39318e04 | ||
|
|
ea13c5c5ad | ||
|
|
50e306baa1 | ||
|
|
0b94cdb509 | ||
|
|
3830a9eb01 | ||
|
|
a26d0932b5 | ||
|
|
291ac6c50c | ||
|
|
22b04ff855 | ||
|
|
ec97a52ffc | ||
|
|
05560697a3 | ||
|
|
10a7008ad2 | ||
|
|
c4ac8b6a65 | ||
|
|
1c3e269718 | ||
|
|
9988974001 | ||
|
|
43e86e4769 | ||
|
|
c393bc9697 | ||
|
|
a6887c0826 | ||
|
|
a335dbcbc5 | ||
|
|
4d7ac8f759 | ||
|
|
327a49f1d7 | ||
|
|
e7efe473c8 | ||
|
|
aa5c65b4a2 | ||
|
|
857d1505ed | ||
|
|
2807c92a35 | ||
|
|
00d37eb726 | ||
|
|
4eba026541 | ||
|
|
fbe0458dad | ||
|
|
a959049c77 | ||
|
|
f80bfdc886 | ||
|
|
28cf4c6443 | ||
|
|
5eddaacf4e | ||
|
|
28b909ed79 | ||
|
|
912551e74b | ||
|
|
964b3b5068 | ||
|
|
e539551e70 | ||
|
|
d61cba4101 | ||
|
|
ad241ca7e8 | ||
|
|
0d2524b359 | ||
|
|
76b5aa12ab | ||
|
|
ca27e94d33 | ||
|
|
8ac955c44d | ||
|
|
7e837b4d45 | ||
|
|
12640a9fd1 | ||
|
|
48455bc13f | ||
|
|
b86e454db6 | ||
|
|
c03abe4511 | ||
|
|
8747dd30b7 | ||
|
|
7cc7ecb61e | ||
|
|
b98dab8945 | ||
|
|
ad7b53ec15 | ||
|
|
a1082bd732 | ||
|
|
7074123bc1 | ||
|
|
fbeb192ad7 | ||
|
|
d0d996fda7 | ||
|
|
42155bdab0 | ||
|
|
05f8456177 | ||
|
|
4f05b6ffab | ||
|
|
477e4c921d | ||
|
|
4ed689064e | ||
|
|
dcb304641d | ||
|
|
d969e73bd0 | ||
|
|
4633209fb3 | ||
|
|
a1b5182e6a | ||
|
|
e41d0032f5 | ||
|
|
36369afb70 | ||
|
|
4dde8b80cc | ||
|
|
50f2afc5c4 | ||
|
|
9a6749a0d3 | ||
|
|
5820398b4a | ||
|
|
6c8be55c29 | ||
|
|
25bbc7c418 | ||
|
|
eedca7331a | ||
|
|
09e835a374 | ||
|
|
e7a54c8012 | ||
|
|
0964322ab9 | ||
|
|
a4e62fb532 | ||
|
|
b3cc7c424d | ||
|
|
6827201bdc | ||
|
|
c198f5c2b6 | ||
|
|
043dfb61bd | ||
|
|
c7a5ec61a3 | ||
|
|
b96d50d166 | ||
|
|
08444c2509 | ||
|
|
aa83ee1382 | ||
|
|
320c9acc8a | ||
|
|
63893894d0 | ||
|
|
8abbbabcd5 | ||
|
|
f7348a6c7a | ||
|
|
75f1f06345 | ||
|
|
7ba5c83f33 | ||
|
|
ab993b0d96 | ||
|
|
920af648be | ||
|
|
07592b15b4 | ||
|
|
d301cde016 | ||
|
|
527c9e3705 | ||
|
|
f0ab44be88 | ||
|
|
a71b40feab | ||
|
|
49c14aaa6c | ||
|
|
c906431b0d | ||
|
|
0cacb4e4ed | ||
|
|
92a17f2b84 | ||
|
|
15592f87aa | ||
|
|
ea96feca74 | ||
|
|
7c22ce7ce7 | ||
|
|
171371fb54 | ||
|
|
7a44d2c553 | ||
|
|
96dceefcd8 | ||
|
|
dc1e74fc84 | ||
|
|
78291b6dd9 | ||
|
|
b841f5d021 | ||
|
|
4c557f3ced | ||
|
|
15f8398610 | ||
|
|
4739ab73eb | ||
|
|
7da1b0b510 | ||
|
|
1f214ebf87 | ||
|
|
1459ac7b77 | ||
|
|
35a0804e73 | ||
|
|
781efcbb8d | ||
|
|
613032ebfe | ||
|
|
2de2746f77 | ||
|
|
124b587285 | ||
|
|
8bae9c7f23 | ||
|
|
2463841663 | ||
|
|
438c9dd704 | ||
|
|
79cb93db08 | ||
|
|
bf43a41405 | ||
|
|
f16a7bb6d6 | ||
|
|
ad1e5ec205 | ||
|
|
9954365c6f | ||
|
|
e7b0be2660 | ||
|
|
4171d0b861 | ||
|
|
26a23d673b | ||
|
|
cf3c00a1e1 | ||
|
|
d2bf5cd1e2 | ||
|
|
5bc874a9ff | ||
|
|
00490b14ec | ||
|
|
48005ce67a | ||
|
|
0a4ab7a547 | ||
|
|
2aae7eed38 | ||
|
|
b1f9c32a19 | ||
|
|
4f5cd3e7b1 | ||
|
|
35c4d13f8f | ||
|
|
3cc2bf2dd1 | ||
|
|
ed97fc3009 | ||
|
|
a350fa2017 | ||
|
|
39c030c1d5 | ||
|
|
c2aab06c0b | ||
|
|
84f6035103 | ||
|
|
46b2f2839f | ||
|
|
3f60fa7550 | ||
|
|
077b7e4570 | ||
|
|
f74306a12c | ||
|
|
26124a5348 | ||
|
|
d481aea85a | ||
|
|
f2287a85e8 | ||
|
|
35e88e5f2a | ||
|
|
e0143c0e26 | ||
|
|
ab47868b6c | ||
|
|
b93e12681e | ||
|
|
b57a6d7b00 | ||
|
|
b4c6d40edc | ||
|
|
182c258c03 | ||
|
|
30ec17e50f | ||
|
|
422ac89575 | ||
|
|
517b5b0507 | ||
|
|
db4b4b8489 | ||
|
|
63d4e8e80d | ||
|
|
eea9a61064 | ||
|
|
f1a205a465 | ||
|
|
52e5aa41d2 | ||
|
|
662ef66319 | ||
|
|
a3de7c330a | ||
|
|
dfafed5801 | ||
|
|
d7d283c188 | ||
|
|
b887507301 | ||
|
|
c8cf918116 | ||
|
|
b2f1cf1455 | ||
|
|
818692a405 | ||
|
|
4f9abd0edc | ||
|
|
44d7880468 | ||
|
|
e69afe1848 | ||
|
|
94a568ae95 | ||
|
|
56455344ed | ||
|
|
ac45a7b64a | ||
|
|
c6bbd0a2de | ||
|
|
d0f44513f6 | ||
|
|
a33670ef11 | ||
|
|
587c289775 | ||
|
|
5390332e0f | ||
|
|
2c62d36cbe | ||
|
|
54163bc37f | ||
|
|
c5a0f266d9 | ||
|
|
22c00fea1b | ||
|
|
5cef952797 | ||
|
|
013755208d | ||
|
|
f6e00c7820 | ||
|
|
1d7ddd713b | ||
|
|
06d7c85027 | ||
|
|
0e62b9582c | ||
|
|
ad25a1eb7c | ||
|
|
1c9081951a | ||
|
|
00338c5c11 | ||
|
|
083d4e4fa8 | ||
|
|
21e720c420 | ||
|
|
a8ade9171e | ||
|
|
f5ce9745ba | ||
|
|
e5887dfa15 | ||
|
|
8a684545ac | ||
|
|
d705a3ea01 | ||
|
|
552c3b5500 | ||
|
|
899e3decc2 | ||
|
|
7422f15bff | ||
|
|
f21b29fe1b | ||
|
|
c945e1a472 | ||
|
|
77d7571276 | ||
|
|
4431757705 | ||
|
|
30edde5bd2 | ||
|
|
d85f188236 | ||
|
|
ac18bdf285 | ||
|
|
501ecf89b0 | ||
|
|
db401962d0 | ||
|
|
c2f2f13146 | ||
|
|
0cb0d899a1 | ||
|
|
200b2cd06c | ||
|
|
52f11cc405 | ||
|
|
dc5792e5ce | ||
|
|
5568ded067 | ||
|
|
8245b49cbc | ||
|
|
78195740a3 | ||
|
|
77f944dd99 | ||
|
|
cb2724e881 | ||
|
|
eac606f1dd | ||
|
|
bcc84a99a4 | ||
|
|
2cdf5a1738 | ||
|
|
ef1a8184da | ||
|
|
d06ae90f1d | ||
|
|
8adfa3f312 | ||
|
|
818e14c53a | ||
|
|
127f3c74c2 | ||
|
|
115799c6c6 | ||
|
|
f88f78116b | ||
|
|
b522320e97 | ||
|
|
cb779c82ff | ||
|
|
cd263fadd3 | ||
|
|
060641ece3 | ||
|
|
afe26f3db4 | ||
|
|
222c6c4924 | ||
|
|
66459bb2fc | ||
|
|
e80d689603 | ||
|
|
8168a07dc6 | ||
|
|
1ce07862af | ||
|
|
1a90b480eb | ||
|
|
7f43642467 | ||
|
|
5c5c5cd202 | ||
|
|
376ae48aeb | ||
|
|
4a29672569 | ||
|
|
8ba6b1c613 | ||
|
|
26b64bd636 | ||
|
|
e177f36212 | ||
|
|
d5d0f99474 | ||
|
|
27ebf9d621 | ||
|
|
61d07a9d29 | ||
|
|
bedbde3ebb | ||
|
|
06c99b2aa1 | ||
|
|
891cbc8ae3 | ||
|
|
21619c5e10 | ||
|
|
379da64442 | ||
|
|
37c02e2491 | ||
|
|
6a18e30314 | ||
|
|
ae49e977ce | ||
|
|
8e0798ee16 | ||
|
|
2afac3a1a5 | ||
|
|
3fd2cbbbd6 | ||
|
|
0a1f2a5fef | ||
|
|
e17ae3eb8a | ||
|
|
ddbec0da73 | ||
|
|
8534c58426 | ||
|
|
fa5cc99c3b | ||
|
|
38b4a8c4de | ||
|
|
f7f0ea9cca | ||
|
|
01bd3249fa | ||
|
|
93d601e7bf | ||
|
|
b49926f80b | ||
|
|
a4ee1e5175 | ||
|
|
d1b8c09615 | ||
|
|
99fab9a383 | ||
|
|
2a002f3d11 | ||
|
|
01081b1985 | ||
|
|
be1b0c0a35 | ||
|
|
33dd630a59 | ||
|
|
8a5988b95e | ||
|
|
9e15db4143 | ||
|
|
ac2e1e8f64 | ||
|
|
5cc9d3697d | ||
|
|
5fb207ce08 | ||
|
|
5375c279ab | ||
|
|
0035367c83 | ||
|
|
7bd555aaa7 | ||
|
|
2cc25d068d | ||
|
|
e2a6ae6be4 | ||
|
|
0842c35e34 | ||
|
|
a0869ca6e4 | ||
|
|
a1b372667b | ||
|
|
471dbd62db | ||
|
|
5ae452155e | ||
|
|
6c64470c28 | ||
|
|
68836bb834 | ||
|
|
7660c53e3a | ||
|
|
273a6138c6 |
372 changed files with 28989 additions and 10358 deletions
11
.github/ISSUE_TEMPLATE/00-bug.yml
vendored
11
.github/ISSUE_TEMPLATE/00-bug.yml
vendored
|
|
@ -1,6 +1,5 @@
|
||||||
name: "Bug Report"
|
name: "Bug Report"
|
||||||
description: Report a bug
|
description: Report a bug
|
||||||
title: "<title>"
|
|
||||||
labels: [
|
labels: [
|
||||||
"bug"
|
"bug"
|
||||||
]
|
]
|
||||||
|
|
@ -23,6 +22,16 @@ body:
|
||||||
3. See error
|
3. See error
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
- type: dropdown
|
||||||
|
id: experimental
|
||||||
|
attributes:
|
||||||
|
label: Experimental settings
|
||||||
|
description: Do you have experiment settings turn on?
|
||||||
|
options:
|
||||||
|
- "Yes"
|
||||||
|
- "No"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
id: app-version
|
id: app-version
|
||||||
attributes:
|
attributes:
|
||||||
|
|
|
||||||
21
.github/ISSUE_TEMPLATE/01-playback.yml
vendored
21
.github/ISSUE_TEMPLATE/01-playback.yml
vendored
|
|
@ -1,6 +1,5 @@
|
||||||
name: "Playback Problem"
|
name: "Playback Problem"
|
||||||
description: Report an issue with playback
|
description: Report an issue with playback
|
||||||
title: "<title>"
|
|
||||||
labels: [
|
labels: [
|
||||||
"bug", "playback"
|
"bug", "playback"
|
||||||
]
|
]
|
||||||
|
|
@ -12,12 +11,14 @@ body:
|
||||||
description: Please enter a description of the playback issue
|
description: Please enter a description of the playback issue
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: dropdown
|
||||||
id: mediainfo
|
id: experimental
|
||||||
attributes:
|
attributes:
|
||||||
label: "Media info"
|
label: Experimental settings
|
||||||
description: Please enter details about what media you are trying to play (video or audio codecs, container, etc)
|
description: Do you have experiment settings turn on?
|
||||||
placeholder: Use mediainfo or "Send media info to server" in the app to gather this
|
options:
|
||||||
|
- "Yes"
|
||||||
|
- "No"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
|
@ -32,6 +33,14 @@ body:
|
||||||
- External App (please specify)
|
- External App (please specify)
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: mediainfo
|
||||||
|
attributes:
|
||||||
|
label: "Media info"
|
||||||
|
description: Please enter details about what media you are trying to play (video or audio codecs, container, etc)
|
||||||
|
placeholder: Use mediainfo or "Send media info to server" in the app to gather this
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
id: app-version
|
id: app-version
|
||||||
attributes:
|
attributes:
|
||||||
|
|
|
||||||
8
.github/actions/setup/action.yml
vendored
8
.github/actions/setup/action.yml
vendored
|
|
@ -6,11 +6,11 @@ runs:
|
||||||
steps:
|
steps:
|
||||||
# Setup the SDKs
|
# Setup the SDKs
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||||
with:
|
with:
|
||||||
python-version: '3.14'
|
python-version: '3.14'
|
||||||
- name: Setup JDK
|
- name: Setup JDK
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6
|
||||||
with:
|
with:
|
||||||
distribution: zulu
|
distribution: zulu
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|
@ -21,9 +21,9 @@ runs:
|
||||||
echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV
|
echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV
|
||||||
echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV
|
echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v4
|
uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4
|
||||||
with:
|
with:
|
||||||
packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}"
|
packages: "platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}"
|
||||||
- name: Add NDK to path
|
- name: Add NDK to path
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
9
.github/workflows/main.yml
vendored
9
.github/workflows/main.yml
vendored
|
|
@ -16,7 +16,7 @@ concurrency:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
BRANCH_NAME: ${{ github.ref_name }}
|
BRANCH_NAME: ${{ github.ref_name }}
|
||||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
|
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
@ -25,7 +25,7 @@ jobs:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the code
|
- name: Checkout the code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Need the tags to build
|
fetch-depth: 0 # Need the tags to build
|
||||||
- name: Setup
|
- name: Setup
|
||||||
|
|
@ -54,7 +54,8 @@ jobs:
|
||||||
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
|
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
|
||||||
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
|
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
|
||||||
run: |
|
run: |
|
||||||
./gradlew clean assembleDefaultRelease assembleDefaultDebug --no-daemon
|
./gradlew clean assembleDefaultDebug
|
||||||
|
./gradlew assembleDefaultRelease
|
||||||
|
|
||||||
- name: Verify signatures
|
- name: Verify signatures
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -76,7 +77,7 @@ jobs:
|
||||||
echo "SHA256 checksums:"
|
echo "SHA256 checksums:"
|
||||||
find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum
|
find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum
|
||||||
- name: Upload mapping files
|
- name: Upload mapping files
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
with:
|
with:
|
||||||
name: mapping
|
name: mapping
|
||||||
path: |
|
path: |
|
||||||
|
|
|
||||||
10
.github/workflows/pr.yml
vendored
10
.github/workflows/pr.yml
vendored
|
|
@ -9,29 +9,29 @@ defaults:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
BUILD_DIRS_ARTIFACT: build-dirs
|
BUILD_DIRS_ARTIFACT: build-dirs
|
||||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
|
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pre-commit:
|
pre-commit:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the code
|
- name: Checkout the code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Need the tags to build
|
fetch-depth: 0 # Need the tags to build
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||||
with:
|
with:
|
||||||
python-version: '3.14'
|
python-version: '3.14'
|
||||||
- name: Run pre-commit
|
- name: Run pre-commit
|
||||||
uses: pre-commit/action@v3.0.1
|
uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
|
||||||
|
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: pre-commit
|
needs: pre-commit
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the code
|
- name: Checkout the code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Need the tags to build
|
fetch-depth: 0 # Need the tags to build
|
||||||
- name: Setup
|
- name: Setup
|
||||||
|
|
|
||||||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
|
|
@ -11,7 +11,7 @@ defaults:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
APK_SHORT_NAME: Wholphin.apk
|
APK_SHORT_NAME: Wholphin.apk
|
||||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
|
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'"
|
||||||
TAG_NAME: ${{ github.ref_name }}
|
TAG_NAME: ${{ github.ref_name }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
@ -21,7 +21,7 @@ jobs:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the code
|
- name: Checkout the code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Need the tags to build
|
fetch-depth: 0 # Need the tags to build
|
||||||
- name: Setup
|
- name: Setup
|
||||||
|
|
@ -36,7 +36,8 @@ jobs:
|
||||||
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
|
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
|
||||||
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
|
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
|
||||||
run: |
|
run: |
|
||||||
./gradlew clean bundleAppstoreRelease bundleFiretvRelease
|
./gradlew clean bundleAppstoreRelease
|
||||||
|
./gradlew bundleFiretvRelease
|
||||||
./gradlew assembleDefaultRelease
|
./gradlew assembleDefaultRelease
|
||||||
- name: Verify signatures
|
- name: Verify signatures
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -58,14 +59,14 @@ jobs:
|
||||||
echo "SHA256 checksums:"
|
echo "SHA256 checksums:"
|
||||||
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum
|
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum
|
||||||
- name: Upload AAB
|
- name: Upload AAB
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
with:
|
with:
|
||||||
name: AAB
|
name: AAB
|
||||||
path: |
|
path: |
|
||||||
app/build/outputs/bundle/**/*.aab
|
app/build/outputs/bundle/**/*.aab
|
||||||
compression-level: 0
|
compression-level: 0
|
||||||
- name: Upload mapping files
|
- name: Upload mapping files
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
with:
|
with:
|
||||||
name: mapping
|
name: mapping
|
||||||
path: |
|
path: |
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ If you want to include these in a local build, see the [instructions here](https
|
||||||
|
|
||||||
You can also build the extensions locally from https://github.com/damontecres/wholphin-extensions and include them in `app/libs`. The gradle build dependency resolution prefers these local files over fetching from the remote maven registry.
|
You can also build the extensions locally from https://github.com/damontecres/wholphin-extensions and include them in `app/libs`. The gradle build dependency resolution prefers these local files over fetching from the remote maven registry.
|
||||||
|
|
||||||
|
Finally, if no `wholphin-mpv` implementation is found, `wholphin-mpv-stub` will be used. This allows the app to compile, but any runtime usage of MPV will throw an exception.
|
||||||
|
|
||||||
## Code organization
|
## Code organization
|
||||||
|
|
||||||
Code is split into several packages:
|
Code is split into several packages:
|
||||||
|
|
@ -95,3 +97,9 @@ Some of the models, such as `GetItemsFilter` or `ViewOptions`, are stored as JSO
|
||||||
These settings are similar to Room ones and are usually stored per user. These can be accessed via `KeyValueService`. The values are `@Serializable` objects saved as JSON.
|
These settings are similar to Room ones and are usually stored per user. These can be accessed via `KeyValueService`. The values are `@Serializable` objects saved as JSON.
|
||||||
|
|
||||||
This should be used sparingly.
|
This should be used sparingly.
|
||||||
|
|
||||||
|
#### Experimental
|
||||||
|
|
||||||
|
Some settings are considered "experimental". These may cause instabilities or may need further testing. They may be changed or removed any time.
|
||||||
|
|
||||||
|
These are stored in the `ExperimentalPreferences` object in `AppPreferences`. Accessing them should always be gated with `ExperimentalPreferences.enabled`. There are a few helper functions for this purpose.
|
||||||
|
|
|
||||||
168
Intents.md
Normal file
168
Intents.md
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
# Intents
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> This feature is experimental and the exact behaviors and/or parameters may change at any time
|
||||||
|
|
||||||
|
Wholphin accepts [intents](https://developer.android.com/guide/components/intents-filters) to load or play specified media.
|
||||||
|
|
||||||
|
Parameters can be specified as "extras" (eg `--es query value`) or as query parameters to the data URI (eg `-d wholphin://search?query=value`).
|
||||||
|
|
||||||
|
UUID parameters can be specified with or without hyphens.
|
||||||
|
|
||||||
|
If using data URI query parameters on the command line, you need to escape ampersands: `itemId=...\&userId=...`
|
||||||
|
|
||||||
|
## Server/user switching
|
||||||
|
|
||||||
|
You can choose a server and user to switch to with the `serverId` and `userId` UUID parameters. If the profile is not protected, the app will switch to it.
|
||||||
|
|
||||||
|
If not specified, the app will use the current user if auto sign in is enabled.
|
||||||
|
|
||||||
|
## Search
|
||||||
|
|
||||||
|
Action: `android.intent.action.SEARCH`
|
||||||
|
|
||||||
|
Shorthand action: `search`
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- `query` - Optional, prepopulate the search field
|
||||||
|
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
Full format
|
||||||
|
```bash
|
||||||
|
adb shell am start \
|
||||||
|
-a android.intent.action.SEARCH \
|
||||||
|
-n 'com.github.damontecres.wholphin/.MainActivity' \
|
||||||
|
--es query "Avengers"
|
||||||
|
```
|
||||||
|
|
||||||
|
URI based
|
||||||
|
```bash
|
||||||
|
adb shell am start -d 'wholphin://search?query=Avengers'
|
||||||
|
```
|
||||||
|
|
||||||
|
Multi-word query:
|
||||||
|
```bash
|
||||||
|
# Whole shell command is wrapped in double quotes
|
||||||
|
adb shell "am start \
|
||||||
|
-a android.intent.action.SEARCH \
|
||||||
|
-n 'com.github.damontecres.wholphin/.MainActivity' \
|
||||||
|
--es query 'Avengers Endgame'"
|
||||||
|
|
||||||
|
adb shell am start -d 'wholphin://search?query=Avengers+Endgame'
|
||||||
|
adb shell am start -d 'wholphin://search?query=Avengers%20Endgame'
|
||||||
|
```
|
||||||
|
|
||||||
|
## View
|
||||||
|
|
||||||
|
This opens the app to the specified item such as an episode or movie
|
||||||
|
|
||||||
|
Action: `android.intent.action.VIEW`
|
||||||
|
|
||||||
|
Shorthand action: `view`
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- `itemId` - Optional, the UUID of the media item, if not set, goes to home screen
|
||||||
|
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
Full format
|
||||||
|
```bash
|
||||||
|
adb shell am start \
|
||||||
|
-a android.intent.action.VIEW \
|
||||||
|
-n 'com.github.damontecres.wholphin/.MainActivity' \
|
||||||
|
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d"
|
||||||
|
```
|
||||||
|
|
||||||
|
URI based
|
||||||
|
```bash
|
||||||
|
adb shell am start -d 'wholphin://view?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Playback
|
||||||
|
|
||||||
|
Action: `com.github.damontecres.wholphin.PLAYBACK`
|
||||||
|
|
||||||
|
Shorthand action: `play`
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- `itemId` - Required, the UUID of the media item
|
||||||
|
- `position` - Optional, the start position for playback in milliseconds
|
||||||
|
- `shuffle` - Optional, whether to shuffle the resulting items
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
Full format
|
||||||
|
```bash
|
||||||
|
adb shell am start \
|
||||||
|
-a com.github.damontecres.wholphin.PLAYBACK \
|
||||||
|
-n 'com.github.damontecres.wholphin/.MainActivity' \
|
||||||
|
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d"
|
||||||
|
```
|
||||||
|
|
||||||
|
URI based
|
||||||
|
```bash
|
||||||
|
adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d'
|
||||||
|
```
|
||||||
|
|
||||||
|
Start at 4m30s:
|
||||||
|
```bash
|
||||||
|
adb shell am start \
|
||||||
|
-a com.github.damontecres.wholphin.PLAYBACK \
|
||||||
|
-n 'com.github.damontecres.wholphin/.MainActivity' \
|
||||||
|
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \
|
||||||
|
--el position 270000
|
||||||
|
|
||||||
|
adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d\&position=270000'
|
||||||
|
```
|
||||||
|
|
||||||
|
Shuffle a series (`itemId` is the Series ID):
|
||||||
|
```bash
|
||||||
|
adb shell am start \
|
||||||
|
-a com.github.damontecres.wholphin.PLAYBACK \
|
||||||
|
-n 'com.github.damontecres.wholphin/.MainActivity' \
|
||||||
|
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \
|
||||||
|
--ez shuffle true
|
||||||
|
|
||||||
|
adb shell am start -d 'wholphin://play?itemId=5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d\&shuffle=true'
|
||||||
|
```
|
||||||
|
|
||||||
|
## More examples
|
||||||
|
|
||||||
|
### Switch user
|
||||||
|
|
||||||
|
Full format
|
||||||
|
```bash
|
||||||
|
# Switch user, go to item
|
||||||
|
adb shell am start \
|
||||||
|
-a android.intent.action.VIEW \
|
||||||
|
-n 'com.github.damontecres.wholphin/.MainActivity' \
|
||||||
|
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \
|
||||||
|
--es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \
|
||||||
|
--es userId "5c8575e0-468d-44fb-8c70-00aa79160587"
|
||||||
|
|
||||||
|
# Switch user, go to home screen
|
||||||
|
adb shell am start \
|
||||||
|
-a android.intent.action.VIEW \
|
||||||
|
-n 'com.github.damontecres.wholphin/.MainActivity' \
|
||||||
|
--es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \
|
||||||
|
--es userId "5c8575e0-468d-44fb-8c70-00aa79160587"
|
||||||
|
```
|
||||||
|
|
||||||
|
URI based
|
||||||
|
```bash
|
||||||
|
adb shell am start -d 'wholphin://view?itemId=66c80aaac9c17d3761782d2205520229\&serverId=341880f9-ca88-4038-8718-f6b2407950b1\&userId=5c8575e0-468d-44fb-8c70-00aa79160587'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debug build
|
||||||
|
|
||||||
|
Debug builds have a different package, so you must specify the component by its full name:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb shell am start \
|
||||||
|
-a android.intent.action.VIEW \
|
||||||
|
-n 'com.github.damontecres.wholphin.debug/com.github.damontecres.wholphin.MainActivity' \
|
||||||
|
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d"
|
||||||
|
```
|
||||||
|
|
@ -103,7 +103,7 @@ Requires Android 6+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` or `10.11.x
|
||||||
|
|
||||||
The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue!
|
The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue!
|
||||||
|
|
||||||
Seerr integration is tested with `v3.2.0`. Older versions may not work.
|
Seerr integration is generally tested with the latest stable version. Older versions may not work as expected. The Seerr server should configured with integration to your Jellyfin server for full compatibility.
|
||||||
|
|
||||||
## Contributions
|
## Contributions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,21 @@ plugins {
|
||||||
alias(libs.plugins.openapi.generator)
|
alias(libs.plugins.openapi.generator)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false
|
val isCI = providers.environmentVariable("CI").orElse("false").map { it.toBoolean() }
|
||||||
val shouldSign = isCI && System.getenv("KEY_ALIAS") != null
|
val shouldSign =
|
||||||
val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists()
|
isCI.zip(
|
||||||
val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists()
|
providers.environmentVariable("KEY_ALIAS").orElse("").map { it.isNotBlank() },
|
||||||
val mpvModuleExists = project.file("libs/wholphin-mpv-release.aar").exists()
|
) { isCI, hasKey ->
|
||||||
val extensionsRepoActive = project.hasProperty("WholphinExtensionsUsername")
|
isCI && hasKey
|
||||||
|
}
|
||||||
|
val ffmpegModuleExists =
|
||||||
|
providers.provider { project.file("libs/lib-decoder-ffmpeg-release.aar").exists() }
|
||||||
|
val av1ModuleExists =
|
||||||
|
providers.provider { project.file("libs/lib-decoder-av1-release.aar").exists() }
|
||||||
|
val mpvModuleExists =
|
||||||
|
providers.provider { project.file("libs/wholphin-mpv-release.aar").exists() }
|
||||||
|
val extensionsRepoActive =
|
||||||
|
providers.provider { project.hasProperty("WholphinExtensionsUsername") }
|
||||||
|
|
||||||
// See https://issuetracker.google.com/issues/402800800
|
// See https://issuetracker.google.com/issues/402800800
|
||||||
val isBuildingBundle =
|
val isBuildingBundle =
|
||||||
|
|
@ -50,17 +59,22 @@ kotlin {
|
||||||
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
|
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
|
||||||
jvmTarget = JvmTarget.JVM_11
|
jvmTarget = JvmTarget.JVM_11
|
||||||
javaParameters = true
|
javaParameters = true
|
||||||
|
|
||||||
|
// Workaround for https://github.com/google/ksp/issues/2964
|
||||||
|
moduleName = "${project.group}_${project.name}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Provider<String>.getInt() = get().toInt()
|
||||||
|
|
||||||
configure<ApplicationExtension> {
|
configure<ApplicationExtension> {
|
||||||
namespace = "com.github.damontecres.wholphin"
|
namespace = "com.github.damontecres.wholphin"
|
||||||
compileSdk = 36
|
compileSdk = libs.versions.compileSdk.getInt()
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.github.damontecres.wholphin"
|
applicationId = "com.github.damontecres.wholphin"
|
||||||
minSdk = 23
|
minSdk = libs.versions.minSdk.getInt()
|
||||||
targetSdk = 36
|
targetSdk = libs.versions.targetSdk.getInt()
|
||||||
versionCode = gitTags.trim().lines().size
|
versionCode = gitTags.trim().lines().size
|
||||||
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
|
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
|
||||||
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
|
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
|
||||||
|
|
@ -69,7 +83,7 @@ configure<ApplicationExtension> {
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
if (shouldSign) {
|
if (shouldSign.get()) {
|
||||||
create("ci") {
|
create("ci") {
|
||||||
file("ci.keystore").writeBytes(
|
file("ci.keystore").writeBytes(
|
||||||
Base64.getDecoder().decode(System.getenv("SIGNING_KEY")),
|
Base64.getDecoder().decode(System.getenv("SIGNING_KEY")),
|
||||||
|
|
@ -95,7 +109,7 @@ configure<ApplicationExtension> {
|
||||||
"proguard-rules.pro",
|
"proguard-rules.pro",
|
||||||
)
|
)
|
||||||
isDebuggable = false
|
isDebuggable = false
|
||||||
if (shouldSign) {
|
if (shouldSign.get()) {
|
||||||
signingConfig = signingConfigs.getByName("ci")
|
signingConfig = signingConfigs.getByName("ci")
|
||||||
} else {
|
} else {
|
||||||
val localPropertiesFile = project.rootProject.file("local.properties")
|
val localPropertiesFile = project.rootProject.file("local.properties")
|
||||||
|
|
@ -166,7 +180,7 @@ configure<ApplicationExtension> {
|
||||||
isEnable = !isBuildingBundle.get()
|
isEnable = !isBuildingBundle.get()
|
||||||
|
|
||||||
reset()
|
reset()
|
||||||
include("armeabi-v7a", "arm64-v8a")
|
include("armeabi-v7a", "arm64-v8a", "x86_64")
|
||||||
isUniversalApk = true
|
isUniversalApk = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -323,7 +337,6 @@ dependencies {
|
||||||
implementation(libs.androidx.room.common.jvm)
|
implementation(libs.androidx.room.common.jvm)
|
||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
|
||||||
implementation(libs.androidx.hilt.navigation.compose)
|
implementation(libs.androidx.hilt.navigation.compose)
|
||||||
implementation(libs.androidx.preference.ktx)
|
implementation(libs.androidx.preference.ktx)
|
||||||
implementation(libs.androidx.room.testing)
|
implementation(libs.androidx.room.testing)
|
||||||
|
|
@ -351,40 +364,39 @@ dependencies {
|
||||||
implementation(libs.kache)
|
implementation(libs.kache)
|
||||||
implementation(libs.kache.file)
|
implementation(libs.kache.file)
|
||||||
|
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
|
||||||
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
||||||
|
|
||||||
if (ffmpegModuleExists) {
|
if (ffmpegModuleExists.get()) {
|
||||||
logger.info("Using local ffmpeg decoder")
|
logger.info("Using local ffmpeg decoder")
|
||||||
implementation(files("libs/lib-decoder-ffmpeg-release.aar"))
|
implementation(files("libs/lib-decoder-ffmpeg-release.aar"))
|
||||||
} else if (extensionsRepoActive) {
|
} else if (extensionsRepoActive.get()) {
|
||||||
logger.info("Using prebuilt ffmpeg decoder")
|
logger.info("Using prebuilt ffmpeg decoder")
|
||||||
implementation(libs.wholphin.extensions.ffmpeg)
|
implementation(libs.wholphin.extensions.ffmpeg)
|
||||||
} else {
|
} else {
|
||||||
logger.warn("Media3 ffmpeg decoder was NOT found")
|
logger.warn("Media3 ffmpeg decoder was NOT found")
|
||||||
}
|
}
|
||||||
if (av1ModuleExists) {
|
if (av1ModuleExists.get()) {
|
||||||
logger.info("Using local av1 decoder")
|
logger.info("Using local av1 decoder")
|
||||||
implementation(files("libs/lib-decoder-av1-release.aar"))
|
implementation(files("libs/lib-decoder-av1-release.aar"))
|
||||||
} else if (extensionsRepoActive) {
|
} else if (extensionsRepoActive.get()) {
|
||||||
logger.info("Using prebuilt av1 decoder")
|
logger.info("Using prebuilt av1 decoder")
|
||||||
implementation(libs.wholphin.extensions.av1)
|
implementation(libs.wholphin.extensions.av1)
|
||||||
} else {
|
} else {
|
||||||
logger.warn("Media3 av1 decoder was NOT found")
|
logger.warn("Media3 av1 decoder was NOT found")
|
||||||
}
|
}
|
||||||
if (mpvModuleExists) {
|
if (mpvModuleExists.get()) {
|
||||||
logger.info("Using local libMPV build")
|
logger.info("Using local libMPV build")
|
||||||
implementation(files("libs/wholphin-mpv-release.aar"))
|
implementation(files("libs/wholphin-mpv-release.aar"))
|
||||||
} else if (extensionsRepoActive) {
|
} else if (extensionsRepoActive.get()) {
|
||||||
logger.info("Using prebuilt libMPV")
|
logger.info("Using prebuilt libMPV")
|
||||||
implementation(libs.wholphin.extensions.mpv)
|
implementation(libs.wholphin.extensions.mpv)
|
||||||
} else {
|
} else {
|
||||||
logger.warn("libMPV was NOT found")
|
logger.warn("libMPV was NOT found, using stub library")
|
||||||
|
implementation(project(":wholphin-mpv-stub"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
testImplementation(libs.mockk.android)
|
testImplementation(libs.mockk.android)
|
||||||
testImplementation(libs.mockk.agent)
|
testImplementation(libs.mockk.agent)
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
|
@ -392,7 +404,8 @@ dependencies {
|
||||||
testImplementation(libs.robolectric)
|
testImplementation(libs.robolectric)
|
||||||
testImplementation(libs.hilt.android.testing)
|
testImplementation(libs.hilt.android.testing)
|
||||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.mockk.android)
|
androidTestImplementation(libs.mockk.android)
|
||||||
androidTestImplementation(libs.hilt.android.testing)
|
androidTestImplementation(libs.hilt.android.testing)
|
||||||
androidTestImplementation(libs.androidx.ui.test.manifest)
|
androidTestImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
app/proguard-rules.pro
vendored
13
app/proguard-rules.pro
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
-keepattributes SourceFile,LineNumberTable
|
-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
-keep class com.github.damontecres.wholphin.util.mpv.* { *; }
|
-keep class com.github.damontecres.wholphin.mpv.MPVLib { *; }
|
||||||
|
|
||||||
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; }
|
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; }
|
||||||
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
|
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
|
||||||
|
|
@ -11,3 +11,14 @@
|
||||||
|
|
||||||
# TODO investigate using smaller scope
|
# TODO investigate using smaller scope
|
||||||
-keep class com.google.common.cache.** { *; }
|
-keep class com.google.common.cache.** { *; }
|
||||||
|
|
||||||
|
# Media3SubtitleOverride uses reflection to access/modify these fields
|
||||||
|
-keep class androidx.media3.ui.SubtitleView {
|
||||||
|
private androidx.media3.ui.SubtitleView$Output output;
|
||||||
|
}
|
||||||
|
-keep class androidx.media3.ui.CanvasSubtitleOutput {
|
||||||
|
private final java.util.List painters;
|
||||||
|
}
|
||||||
|
-keep class androidx.media3.ui.SubtitlePainter {
|
||||||
|
private final float outlineWidth;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,720 @@
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 34,
|
||||||
|
"identityHash": "278e485064fc9f184a2181599f0f8efb",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "servers",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "version",
|
||||||
|
"columnName": "version",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastUsed",
|
||||||
|
"columnName": "lastUsed",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "users",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "rowId",
|
||||||
|
"columnName": "rowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serverId",
|
||||||
|
"columnName": "serverId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "accessToken",
|
||||||
|
"columnName": "accessToken",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "pin",
|
||||||
|
"columnName": "pin",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "requireLogin",
|
||||||
|
"columnName": "requireLogin",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastUsed",
|
||||||
|
"columnName": "lastUsed",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uiLanguage",
|
||||||
|
"columnName": "uiLanguage",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_users_id_serverId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id",
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_users_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_users_serverId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "servers",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "ItemPlayback",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "rowId",
|
||||||
|
"columnName": "rowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sourceId",
|
||||||
|
"columnName": "sourceId",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "audioIndex",
|
||||||
|
"columnName": "audioIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subtitleIndex",
|
||||||
|
"columnName": "subtitleIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_ItemPlayback_userId_itemId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "NavDrawerPinnedItem",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "order",
|
||||||
|
"columnName": "order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "-1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "LibraryDisplayInfo",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sort",
|
||||||
|
"columnName": "sort",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "direction",
|
||||||
|
"columnName": "direction",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "filter",
|
||||||
|
"columnName": "filter",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "'{}'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "viewOptions",
|
||||||
|
"columnName": "viewOptions",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_LibraryDisplayInfo_userId_itemId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "playback_effects",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "jellyfinUserRowId",
|
||||||
|
"columnName": "jellyfinUserRowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.rotation",
|
||||||
|
"columnName": "rotation",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.brightness",
|
||||||
|
"columnName": "brightness",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.contrast",
|
||||||
|
"columnName": "contrast",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.saturation",
|
||||||
|
"columnName": "saturation",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.hue",
|
||||||
|
"columnName": "hue",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.red",
|
||||||
|
"columnName": "red",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.green",
|
||||||
|
"columnName": "green",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.blue",
|
||||||
|
"columnName": "blue",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.blur",
|
||||||
|
"columnName": "blur",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"jellyfinUserRowId",
|
||||||
|
"itemId",
|
||||||
|
"type"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "PlaybackLanguageChoice",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "seriesId",
|
||||||
|
"columnName": "seriesId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "audioLanguage",
|
||||||
|
"columnName": "audioLanguage",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subtitleLanguage",
|
||||||
|
"columnName": "subtitleLanguage",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subtitlesDisabled",
|
||||||
|
"columnName": "subtitlesDisabled",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"seriesId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "ItemTrackModification",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "trackIndex",
|
||||||
|
"columnName": "trackIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "delayMs",
|
||||||
|
"columnName": "delayMs",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId",
|
||||||
|
"trackIndex"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "seerr_servers",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "version",
|
||||||
|
"columnName": "version",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_seerr_servers_url",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "seerr_users",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "jellyfinUserRowId",
|
||||||
|
"columnName": "jellyfinUserRowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serverId",
|
||||||
|
"columnName": "serverId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "authMethod",
|
||||||
|
"columnName": "authMethod",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "username",
|
||||||
|
"columnName": "username",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "password",
|
||||||
|
"columnName": "password",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "credential",
|
||||||
|
"columnName": "credential",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"jellyfinUserRowId",
|
||||||
|
"serverId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "seerr_servers",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"jellyfinUserRowId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "RememberedTab",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "index",
|
||||||
|
"columnName": "index",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_RememberedTab_userId_itemId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '278e485064fc9f184a2181599f0f8efb')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,741 @@
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 35,
|
||||||
|
"identityHash": "ff9ade58d4ac39e36335ac0df1405a5f",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "servers",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "version",
|
||||||
|
"columnName": "version",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastUsed",
|
||||||
|
"columnName": "lastUsed",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "users",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "rowId",
|
||||||
|
"columnName": "rowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serverId",
|
||||||
|
"columnName": "serverId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "accessToken",
|
||||||
|
"columnName": "accessToken",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "pin",
|
||||||
|
"columnName": "pin",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "requireLogin",
|
||||||
|
"columnName": "requireLogin",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastUsed",
|
||||||
|
"columnName": "lastUsed",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uiLanguage",
|
||||||
|
"columnName": "uiLanguage",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "appPreferences.preferredAudioLanguage",
|
||||||
|
"columnName": "preferredAudioLanguage",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "appPreferences.preferredSubtitleLanguage",
|
||||||
|
"columnName": "preferredSubtitleLanguage",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "appPreferences.subtitleMode",
|
||||||
|
"columnName": "subtitleMode",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "'USE_USER_PROFILE'"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_users_id_serverId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id",
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_users_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_users_serverId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "servers",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "ItemPlayback",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "rowId",
|
||||||
|
"columnName": "rowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sourceId",
|
||||||
|
"columnName": "sourceId",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "audioIndex",
|
||||||
|
"columnName": "audioIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subtitleIndex",
|
||||||
|
"columnName": "subtitleIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_ItemPlayback_userId_itemId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "NavDrawerPinnedItem",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "order",
|
||||||
|
"columnName": "order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "-1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "LibraryDisplayInfo",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sort",
|
||||||
|
"columnName": "sort",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "direction",
|
||||||
|
"columnName": "direction",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "filter",
|
||||||
|
"columnName": "filter",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "'{}'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "viewOptions",
|
||||||
|
"columnName": "viewOptions",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_LibraryDisplayInfo_userId_itemId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "playback_effects",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "jellyfinUserRowId",
|
||||||
|
"columnName": "jellyfinUserRowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.rotation",
|
||||||
|
"columnName": "rotation",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.brightness",
|
||||||
|
"columnName": "brightness",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.contrast",
|
||||||
|
"columnName": "contrast",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.saturation",
|
||||||
|
"columnName": "saturation",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.hue",
|
||||||
|
"columnName": "hue",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.red",
|
||||||
|
"columnName": "red",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.green",
|
||||||
|
"columnName": "green",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.blue",
|
||||||
|
"columnName": "blue",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "videoFilter.blur",
|
||||||
|
"columnName": "blur",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"jellyfinUserRowId",
|
||||||
|
"itemId",
|
||||||
|
"type"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "PlaybackLanguageChoice",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "seriesId",
|
||||||
|
"columnName": "seriesId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "audioLanguage",
|
||||||
|
"columnName": "audioLanguage",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subtitleLanguage",
|
||||||
|
"columnName": "subtitleLanguage",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subtitlesDisabled",
|
||||||
|
"columnName": "subtitlesDisabled",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"seriesId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "ItemTrackModification",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "trackIndex",
|
||||||
|
"columnName": "trackIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "delayMs",
|
||||||
|
"columnName": "delayMs",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId",
|
||||||
|
"trackIndex"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "seerr_servers",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "version",
|
||||||
|
"columnName": "version",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_seerr_servers_url",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "seerr_users",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "jellyfinUserRowId",
|
||||||
|
"columnName": "jellyfinUserRowId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serverId",
|
||||||
|
"columnName": "serverId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "authMethod",
|
||||||
|
"columnName": "authMethod",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "username",
|
||||||
|
"columnName": "username",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "password",
|
||||||
|
"columnName": "password",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "credential",
|
||||||
|
"columnName": "credential",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"jellyfinUserRowId",
|
||||||
|
"serverId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "seerr_servers",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"jellyfinUserRowId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "RememberedTab",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"columnName": "userId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemId",
|
||||||
|
"columnName": "itemId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "index",
|
||||||
|
"columnName": "index",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_RememberedTab_userId_itemId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"userId",
|
||||||
|
"itemId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "users",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"rowId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ff9ade58d4ac39e36335ac0df1405a5f')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
android:installLocation="auto">
|
android:installLocation="auto">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
@ -51,13 +52,21 @@
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize">
|
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|density">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<action android:name="android.intent.action.SEARCH" />
|
||||||
|
<action android:name="com.github.damontecres.wholphin.PLAYBACK" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:scheme="wholphin" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
|
@ -27,6 +28,8 @@ import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
|
|
@ -35,6 +38,8 @@ import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
|
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
|
||||||
import com.github.damontecres.wholphin.services.DeviceProfileService
|
import com.github.damontecres.wholphin.services.DeviceProfileService
|
||||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.services.IntentResult
|
||||||
|
import com.github.damontecres.wholphin.services.IntentService
|
||||||
import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService
|
import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
|
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
|
||||||
|
|
@ -45,6 +50,7 @@ import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||||
import com.github.damontecres.wholphin.services.SuggestionsSchedulerService
|
import com.github.damontecres.wholphin.services.SuggestionsSchedulerService
|
||||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||||
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.services.UserSwitchListener
|
import com.github.damontecres.wholphin.services.UserSwitchListener
|
||||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||||
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
|
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
|
||||||
|
|
@ -52,19 +58,20 @@ import com.github.damontecres.wholphin.ui.CoilConfig
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
import com.github.damontecres.wholphin.ui.collectLatestIn
|
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
|
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors
|
||||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
import com.github.damontecres.wholphin.util.DebugLogTree
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
|
import com.github.damontecres.wholphin.util.requestSerializersModule
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
@ -72,14 +79,15 @@ import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
@ -89,6 +97,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var userPreferencesDataStore: DataStore<AppPreferences>
|
lateinit var userPreferencesDataStore: DataStore<AppPreferences>
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var userPreferencesService: UserPreferencesService
|
||||||
|
|
||||||
@AuthOkHttpClient
|
@AuthOkHttpClient
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var okHttpClient: OkHttpClient
|
lateinit var okHttpClient: OkHttpClient
|
||||||
|
|
@ -137,11 +148,16 @@ class MainActivity : AppCompatActivity() {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var screensaverService: ScreensaverService
|
lateinit var screensaverService: ScreensaverService
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var intentService: IntentService
|
||||||
|
|
||||||
private var signInAuto = true
|
private var signInAuto = true
|
||||||
|
private var playerBackend: PlayerBackend? = null
|
||||||
|
|
||||||
private val json =
|
private val json =
|
||||||
Json {
|
Json {
|
||||||
classDiscriminator = "_type"
|
classDiscriminator = "_type"
|
||||||
|
serializersModule = requestSerializersModule
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
|
@ -152,37 +168,51 @@ class MainActivity : AppCompatActivity() {
|
||||||
lifecycle.addObserver(playbackLifecycleObserver)
|
lifecycle.addObserver(playbackLifecycleObserver)
|
||||||
|
|
||||||
val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK)
|
val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK)
|
||||||
|
val restoredBackStack =
|
||||||
if (backStackStr != null) {
|
if (backStackStr != null) {
|
||||||
|
try {
|
||||||
|
json.decodeFromString<List<Destination>>(backStackStr)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
// Best-effort: a previously persisted back stack we can no longer decode
|
||||||
|
// must not crash startup; fall back to a fresh start destination.
|
||||||
|
Timber.w(ex, "Could not restore back stack; starting fresh")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (restoredBackStack != null) {
|
||||||
Timber.d("Restoring back stack")
|
Timber.d("Restoring back stack")
|
||||||
var backStack = json.decodeFromString<List<Destination>>(backStackStr)
|
var backStack = restoredBackStack
|
||||||
|
|
||||||
if (!playExternalViewModel.launched.value) {
|
if (!playExternalViewModel.launched.value) {
|
||||||
val lastDest = backStack.lastOrNull()
|
val lastDest = backStack.lastOrNull()
|
||||||
if (lastDest.isPlayback) {
|
if (lastDest.isPlayback) {
|
||||||
Timber.v("Restoring back stack with playback")
|
Timber.v("Restoring back stack without playback")
|
||||||
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
|
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
navigationManager.backStack = NavBackStack(*backStack.toTypedArray())
|
navigationManager.backStack = NavBackStack(*backStack.toTypedArray())
|
||||||
} else {
|
} else {
|
||||||
val startDestination = intent?.let(::extractDestination) ?: Destination.Home()
|
navigationManager.backStack = NavBackStack(Destination.Home())
|
||||||
navigationManager.backStack = NavBackStack(startDestination)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.serverRepository.currentUserFlow.collectLatestIn(lifecycleScope) { user ->
|
viewModel.serverRepository.currentUserFlow
|
||||||
|
.onEach { user ->
|
||||||
|
withContext(WholphinDispatchers.Main) {
|
||||||
if (user?.hasPin == true) {
|
if (user?.hasPin == true) {
|
||||||
window?.setFlags(
|
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
WindowManager.LayoutParams.FLAG_SECURE,
|
|
||||||
WindowManager.LayoutParams.FLAG_SECURE,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}.catch { ex ->
|
||||||
|
Timber.e(ex, "Error with settings flag secure")
|
||||||
|
}.launchIn(lifecycleScope)
|
||||||
|
|
||||||
screensaverService.keepScreenOn
|
screensaverService.keepScreenOn
|
||||||
.onEach { keepScreenOn ->
|
.onEach { keepScreenOn ->
|
||||||
Timber.v("keepScreenOn: %s", keepScreenOn)
|
Timber.v("keepScreenOn: %s", keepScreenOn)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
if (keepScreenOn) {
|
if (keepScreenOn) {
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -193,14 +223,21 @@ class MainActivity : AppCompatActivity() {
|
||||||
Timber.e(ex, "Error with keepScreenOn")
|
Timber.e(ex, "Error with keepScreenOn")
|
||||||
}.launchIn(lifecycleScope)
|
}.launchIn(lifecycleScope)
|
||||||
|
|
||||||
viewModel.appStart()
|
userPreferencesDataStore.data.collectLatestIn(lifecycleScope) { prefs ->
|
||||||
|
signInAuto = prefs.signInAutomatically
|
||||||
|
playerBackend = prefs.playbackPreferences.playerBackend
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.appStart(intent)
|
||||||
setContent {
|
setContent {
|
||||||
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
|
MaterialTheme(colorScheme = PurpleThemeColors.darkScheme) {
|
||||||
if (appPreferences == null) {
|
Surface(Modifier.fillMaxSize()) {
|
||||||
|
val userPreferences by userPreferencesService.flow.collectAsState(null)
|
||||||
|
if (userPreferences == null) {
|
||||||
// Show loading page if it is taking a while to get app preferences
|
// Show loading page if it is taking a while to get app preferences
|
||||||
var showLoading by remember { mutableStateOf(false) }
|
var showLoading by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
delay(500)
|
delay(500.milliseconds)
|
||||||
Timber.i("Showing loading page")
|
Timber.i("Showing loading page")
|
||||||
showLoading = true
|
showLoading = true
|
||||||
}
|
}
|
||||||
|
|
@ -214,11 +251,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
LoadingPage()
|
LoadingPage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
appPreferences?.let { appPreferences ->
|
userPreferences?.let { userPreferences ->
|
||||||
LaunchedEffect(appPreferences.signInAutomatically) {
|
val appPreferences = userPreferences.appPreferences
|
||||||
signInAuto = appPreferences.signInAutomatically
|
|
||||||
}
|
|
||||||
CoilConfig(
|
CoilConfig(
|
||||||
prefs = appPreferences,
|
prefs = appPreferences,
|
||||||
okHttpClient = okHttpClient,
|
okHttpClient = okHttpClient,
|
||||||
|
|
@ -237,7 +272,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
MainContent(
|
MainContent(
|
||||||
backStack = setupNavigationManager.backStack,
|
backStack = setupNavigationManager.backStack,
|
||||||
navigationManager = navigationManager,
|
navigationManager = navigationManager,
|
||||||
appPreferences = appPreferences,
|
userPreferences = userPreferences,
|
||||||
backdropService = backdropService,
|
backdropService = backdropService,
|
||||||
screensaverService = screensaverService,
|
screensaverService = screensaverService,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|
@ -248,9 +283,12 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||||
if (screensaverService.state.value.show) {
|
if (screensaverService.state.value.run { show || showDim }) {
|
||||||
screensaverService.stop(false)
|
screensaverService.stop(false)
|
||||||
screensaverService.pulse()
|
screensaverService.pulse()
|
||||||
return true
|
return true
|
||||||
|
|
@ -263,6 +301,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
Timber.d("onResume")
|
Timber.d("onResume")
|
||||||
|
viewModel.appResume()
|
||||||
lifecycleScope.launchDefault {
|
lifecycleScope.launchDefault {
|
||||||
screensaverService.pulse()
|
screensaverService.pulse()
|
||||||
}
|
}
|
||||||
|
|
@ -271,7 +310,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
override fun onRestart() {
|
override fun onRestart() {
|
||||||
super.onRestart()
|
super.onRestart()
|
||||||
Timber.d("onRestart")
|
Timber.d("onRestart")
|
||||||
viewModel.appStart()
|
viewModel.appStart(null)
|
||||||
if (!playExternalViewModel.launched.value) {
|
if (!playExternalViewModel.launched.value) {
|
||||||
// If restarting during playback that is not external, go back a page
|
// If restarting during playback that is not external, go back a page
|
||||||
val lastDest = navigationManager.backStack.lastOrNull()
|
val lastDest = navigationManager.backStack.lastOrNull()
|
||||||
|
|
@ -318,10 +357,14 @@ class MainActivity : AppCompatActivity() {
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
super.onSaveInstanceState(outState)
|
super.onSaveInstanceState(outState)
|
||||||
Timber.d("onSaveInstanceState")
|
Timber.d("onSaveInstanceState")
|
||||||
|
try {
|
||||||
val str = json.encodeToString(navigationManager.backStack.toList())
|
val str = json.encodeToString(navigationManager.backStack.toList())
|
||||||
outState.putString(KEY_BACK_STACK, str)
|
outState.putString(KEY_BACK_STACK, str)
|
||||||
val playerBackend =
|
} catch (ex: Exception) {
|
||||||
runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend
|
// Best-effort: some destinations carry types we can't serialize; skip persisting
|
||||||
|
// the back stack rather than crashing in onSaveInstanceState.
|
||||||
|
Timber.w(ex, "Could not persist back stack; skipping")
|
||||||
|
}
|
||||||
outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER)
|
outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -337,67 +380,34 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
super.onConfigurationChanged(newConfig)
|
super.onConfigurationChanged(newConfig)
|
||||||
Timber.d("onConfigurationChanged")
|
Timber.d("onConfigurationChanged: newConfig=%s", newConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
Timber.v("onNewIntent")
|
Timber.v("onNewIntent")
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
extractDestination(intent)?.let {
|
viewModel.appStart(intent)
|
||||||
navigationManager.replace(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun extractDestination(intent: Intent): Destination? =
|
|
||||||
intent.let {
|
|
||||||
val itemId =
|
|
||||||
it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull()
|
|
||||||
val type =
|
|
||||||
it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull)
|
|
||||||
if (itemId != null && type != null) {
|
|
||||||
val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull()
|
|
||||||
val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull()
|
|
||||||
val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1)
|
|
||||||
val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1)
|
|
||||||
if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) {
|
|
||||||
Destination.SeriesOverview(
|
|
||||||
itemId = seriesId,
|
|
||||||
type = BaseItemKind.SERIES,
|
|
||||||
seasonEpisode =
|
|
||||||
SeasonEpisodeIds(
|
|
||||||
seasonId = seasonId,
|
|
||||||
seasonNumber = seasonNumber,
|
|
||||||
episodeId = itemId,
|
|
||||||
episodeNumber = episodeNumber,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Destination.MediaItem(itemId, type)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun changeDisplayMode(modeId: Int) {
|
fun changeDisplayMode(modeId: Int) {
|
||||||
lifecycleScope.launch(Dispatchers.Main + ExceptionHandler(autoToast = true)) {
|
lifecycleScope.launch(WholphinDispatchers.Main + ExceptionHandler(autoToast = true)) {
|
||||||
|
try {
|
||||||
val attrs = window.attributes
|
val attrs = window.attributes
|
||||||
if (attrs.preferredDisplayModeId != modeId) {
|
if (attrs.preferredDisplayModeId != modeId) {
|
||||||
Timber.d("Switch preferredDisplayModeId to %s", modeId)
|
Timber.d("Switch preferredDisplayModeId to %s", modeId)
|
||||||
window.attributes = attrs.apply { preferredDisplayModeId = modeId }
|
window.attributes = attrs.apply { preferredDisplayModeId = modeId }
|
||||||
}
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error switching preferredDisplayModeId to %s", modeId)
|
||||||
|
Toast
|
||||||
|
.makeText(this@MainActivity, "Error changing display mode", Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val INTENT_ITEM_ID = "itemId"
|
|
||||||
const val INTENT_ITEM_TYPE = "itemType"
|
|
||||||
const val INTENT_SERIES_ID = "seriesId"
|
|
||||||
const val INTENT_EPISODE_NUMBER = "epNum"
|
|
||||||
const val INTENT_SEASON_NUMBER = "seaNum"
|
|
||||||
const val INTENT_SEASON_ID = "seaId"
|
|
||||||
|
|
||||||
private const val KEY_BACK_STACK = "backStack"
|
private const val KEY_BACK_STACK = "backStack"
|
||||||
private const val KEY_EXTERNAL_PLAYER = "extPlayer"
|
private const val KEY_EXTERNAL_PLAYER = "extPlayer"
|
||||||
|
|
||||||
|
|
@ -413,13 +423,69 @@ class MainActivityViewModel
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val preferences: DataStore<AppPreferences>,
|
private val preferences: DataStore<AppPreferences>,
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
private val navigationManager: SetupNavigationManager,
|
private val setupNavigationManager: SetupNavigationManager,
|
||||||
|
private val navigationManager: NavigationManager,
|
||||||
private val deviceProfileService: DeviceProfileService,
|
private val deviceProfileService: DeviceProfileService,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
private val appUpgradeHandler: AppUpgradeHandler,
|
private val appUpgradeHandler: AppUpgradeHandler,
|
||||||
|
private val intentService: IntentService,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
fun appStart() {
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
fun appStart(intent: Intent?) {
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
|
mutex.withLock {
|
||||||
|
try {
|
||||||
|
val result = intent?.let { intentService.parseIntent(intent) }
|
||||||
|
when (result) {
|
||||||
|
is IntentResult.Error -> {
|
||||||
|
val current = serverRepository.current.value
|
||||||
|
val destination =
|
||||||
|
if (current != null) {
|
||||||
|
SetupDestination.UserList(current.server)
|
||||||
|
} else {
|
||||||
|
SetupDestination.ServerList
|
||||||
|
}
|
||||||
|
setupNavigationManager.navigateTo(destination)
|
||||||
|
Timber.e("Error parsing intent: %s", result.message)
|
||||||
|
showToast(context, result.message)
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
|
||||||
|
is IntentResult.Target -> {
|
||||||
|
val current = serverRepository.current.value
|
||||||
|
if (current != null) {
|
||||||
|
Timber.i("Received valid intent, switching to AppContent")
|
||||||
|
|
||||||
|
if (result.addHomeToBackStack) {
|
||||||
|
navigationManager.reloadHome()
|
||||||
|
} else {
|
||||||
|
navigationManager.backStack.clear()
|
||||||
|
}
|
||||||
|
navigationManager.backStack.addAll(result.destinations)
|
||||||
|
|
||||||
|
setupNavigationManager.navigateTo(
|
||||||
|
SetupDestination.AppContent(current),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// This should never happen, but just reset app state if it does
|
||||||
|
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||||
|
Timber.e("Error parsing intent, no user is active")
|
||||||
|
showToast(context, "An error occurred parsing the intent")
|
||||||
|
}
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
|
||||||
|
IntentResult.NoOp,
|
||||||
|
null,
|
||||||
|
-> {
|
||||||
|
// No-op, proceed below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error parsing intent")
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val needUpgrade = appUpgradeHandler.needUpgrade()
|
val needUpgrade = appUpgradeHandler.needUpgrade()
|
||||||
if (needUpgrade) {
|
if (needUpgrade) {
|
||||||
|
|
@ -436,9 +502,9 @@ class MainActivityViewModel
|
||||||
val prefs =
|
val prefs =
|
||||||
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||||
val profileProtected =
|
val profileProtected =
|
||||||
serverRepository.current.value?.user?.let {
|
serverRepository.current.value
|
||||||
it.hasPin || it.requireLogin
|
?.user
|
||||||
} == true
|
?.isProtected == true
|
||||||
if (prefs.signInAutomatically && !profileProtected) {
|
if (prefs.signInAutomatically && !profileProtected) {
|
||||||
val current =
|
val current =
|
||||||
serverRepository.restoreSession(
|
serverRepository.restoreSession(
|
||||||
|
|
@ -446,35 +512,48 @@ class MainActivityViewModel
|
||||||
prefs.currentUserId?.toUUIDOrNull(),
|
prefs.currentUserId?.toUUIDOrNull(),
|
||||||
)
|
)
|
||||||
if (current != null) {
|
if (current != null) {
|
||||||
if (current.user.hasPin || current.user.requireLogin) {
|
if (current.user.isProtected) {
|
||||||
navigationManager.navigateTo(SetupDestination.UserList(current.server))
|
setupNavigationManager.navigateTo(
|
||||||
|
SetupDestination.UserList(
|
||||||
|
current.server,
|
||||||
|
),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
// Restored
|
// Restored
|
||||||
navigationManager.navigateTo(SetupDestination.AppContent(current))
|
setupNavigationManager.navigateTo(
|
||||||
|
SetupDestination.AppContent(
|
||||||
|
current,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Did not restore
|
// Did not restore
|
||||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
navigationManager.navigateTo(SetupDestination.Loading)
|
setupNavigationManager.navigateTo(SetupDestination.Loading)
|
||||||
backdropService.clearBackdrop()
|
backdropService.clearBackdrop()
|
||||||
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
|
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
|
||||||
if (currentServerId != null) {
|
if (currentServerId != null) {
|
||||||
val currentServer =
|
val currentServer =
|
||||||
serverRepository.serverDao.getServer(currentServerId)?.server
|
serverRepository.serverDao.getServer(currentServerId)?.server
|
||||||
if (currentServer != null) {
|
if (currentServer != null) {
|
||||||
navigationManager.navigateTo(SetupDestination.UserList(currentServer))
|
setupNavigationManager.navigateTo(
|
||||||
|
SetupDestination.UserList(
|
||||||
|
currentServer,
|
||||||
|
),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Error during appStart")
|
Timber.e(ex, "Error during appStart")
|
||||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
|
|
@ -482,6 +561,19 @@ class MainActivityViewModel
|
||||||
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
|
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun appResume() {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
mutex.withLock {
|
||||||
|
try {
|
||||||
|
Timber.v("Updating userDto")
|
||||||
|
serverRepository.updateUserDto()
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.w(ex, "Error during appResume")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val Destination?.isPlayback: Boolean
|
private val Destination?.isPlayback: Boolean
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
|
|
@ -27,7 +28,6 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
|
@ -44,12 +44,12 @@ import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
|
||||||
fun MainContent(
|
fun MainContent(
|
||||||
backStack: MutableList<SetupDestination>,
|
backStack: MutableList<SetupDestination>,
|
||||||
navigationManager: NavigationManager,
|
navigationManager: NavigationManager,
|
||||||
appPreferences: AppPreferences,
|
userPreferences: UserPreferences,
|
||||||
backdropService: BackdropService,
|
backdropService: BackdropService,
|
||||||
screensaverService: ScreensaverService,
|
screensaverService: ScreensaverService,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val preferences by rememberUpdatedState(UserPreferences(appPreferences))
|
val preferences by rememberUpdatedState(userPreferences)
|
||||||
Surface(
|
Surface(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
|
|
@ -59,7 +59,7 @@ fun MainContent(
|
||||||
// val backStack = rememberNavBackStack(SetupDestination.Loading)
|
// val backStack = rememberNavBackStack(SetupDestination.Loading)
|
||||||
// setupNavigationManager.backStack = backStack
|
// setupNavigationManager.backStack = backStack
|
||||||
val interfaceCustomization =
|
val interfaceCustomization =
|
||||||
remember(appPreferences) { InterfaceCustomization(appPreferences) }
|
remember(userPreferences.appPreferences) { InterfaceCustomization(userPreferences.appPreferences) }
|
||||||
CompositionLocalProvider(LocalInterfaceCustomization provides interfaceCustomization) {
|
CompositionLocalProvider(LocalInterfaceCustomization provides interfaceCustomization) {
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
|
|
@ -105,7 +105,7 @@ fun MainContent(
|
||||||
mutableStateOf(true)
|
mutableStateOf(true)
|
||||||
}
|
}
|
||||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||||
if (!appPreferences.signInAutomatically) {
|
if (!userPreferences.appPreferences.signInAutomatically) {
|
||||||
showContent = false
|
showContent = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -137,12 +137,28 @@ fun MainContent(
|
||||||
val screenSaverState by screensaverService.state.collectAsState()
|
val screenSaverState by screensaverService.state.collectAsState()
|
||||||
if (screenSaverState.enabled || screenSaverState.enabledTemp) {
|
if (screenSaverState.enabled || screenSaverState.enabledTemp) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
screenSaverState.show,
|
visible = screenSaverState.show,
|
||||||
Modifier.fillMaxSize(),
|
enter = ScreensaverService.enterAnimation,
|
||||||
|
exit = ScreensaverService.exitAnimation,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
AppScreensaver(appPreferences, Modifier.fillMaxSize())
|
AppScreensaver(userPreferences.appPreferences, Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = screenSaverState.showDim || (screenSaverState.dimEnabled && screenSaverState.show),
|
||||||
|
enter = ScreensaverService.enterAnimation,
|
||||||
|
exit = ScreensaverService.exitAnimation,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
val alpha =
|
||||||
|
userPreferences.appPreferences.interfacePreferences.screensaverPreference.dimPercent / 100f
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = alpha)),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import org.acra.ReportField
|
||||||
import org.acra.config.dialog
|
import org.acra.config.dialog
|
||||||
import org.acra.data.StringFormat
|
import org.acra.data.StringFormat
|
||||||
import org.acra.ktx.initAcra
|
import org.acra.ktx.initAcra
|
||||||
|
import org.jellyfin.sdk.Jellyfin
|
||||||
|
import org.jellyfin.sdk.model.ServerVersion
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -120,5 +122,7 @@ class WholphinApplication :
|
||||||
companion object {
|
companion object {
|
||||||
lateinit var instance: WholphinApplication
|
lateinit var instance: WholphinApplication
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package com.github.damontecres.wholphin
|
package com.github.damontecres.wholphin
|
||||||
|
|
||||||
import android.service.dreams.DreamService
|
import android.service.dreams.DreamService
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
|
@ -9,6 +11,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.ComposeView
|
import androidx.compose.ui.platform.ComposeView
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
|
|
@ -104,6 +107,7 @@ class WholphinDreamService :
|
||||||
ProvideLocalClock {
|
ProvideLocalClock {
|
||||||
val screensaverPrefs = prefs.interfacePreferences.screensaverPreference
|
val screensaverPrefs = prefs.interfacePreferences.screensaverPreference
|
||||||
val currentItem by itemFlow.collectAsState(null)
|
val currentItem by itemFlow.collectAsState(null)
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
AppScreensaverContent(
|
AppScreensaverContent(
|
||||||
currentItem = currentItem,
|
currentItem = currentItem,
|
||||||
showClock = screensaverPrefs.showClock,
|
showClock = screensaverPrefs.showClock,
|
||||||
|
|
@ -111,6 +115,15 @@ class WholphinDreamService :
|
||||||
animate = screensaverPrefs.animate,
|
animate = screensaverPrefs.animate,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
if (screensaverPrefs.dimEnabled) {
|
||||||
|
val alpha = screensaverPrefs.dimPercent / 100f
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = alpha)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||||
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
||||||
import com.github.damontecres.wholphin.data.model.PlaybackEffect
|
import com.github.damontecres.wholphin.data.model.PlaybackEffect
|
||||||
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||||
|
import com.github.damontecres.wholphin.data.model.RememberedTab
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrServer
|
import com.github.damontecres.wholphin.data.model.SeerrServer
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrUser
|
import com.github.damontecres.wholphin.data.model.SeerrUser
|
||||||
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||||
|
|
@ -46,8 +47,9 @@ import java.util.UUID
|
||||||
ItemTrackModification::class,
|
ItemTrackModification::class,
|
||||||
SeerrServer::class,
|
SeerrServer::class,
|
||||||
SeerrUser::class,
|
SeerrUser::class,
|
||||||
|
RememberedTab::class,
|
||||||
],
|
],
|
||||||
version = 33,
|
version = 35,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
autoMigrations = [
|
autoMigrations = [
|
||||||
AutoMigration(3, 4),
|
AutoMigration(3, 4),
|
||||||
|
|
@ -64,6 +66,8 @@ import java.util.UUID
|
||||||
AutoMigration(30, 31),
|
AutoMigration(30, 31),
|
||||||
AutoMigration(31, 32),
|
AutoMigration(31, 32),
|
||||||
AutoMigration(32, 33),
|
AutoMigration(32, 33),
|
||||||
|
AutoMigration(33, 34),
|
||||||
|
AutoMigration(34, 35),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
|
|
@ -81,6 +85,8 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun seerrServerDao(): SeerrServerDao
|
abstract fun seerrServerDao(): SeerrServerDao
|
||||||
|
|
||||||
abstract fun playbackEffectDao(): PlaybackEffectDao
|
abstract fun playbackEffectDao(): PlaybackEffectDao
|
||||||
|
|
||||||
|
abstract fun rememberedTabDao(): RememberedTabDao
|
||||||
}
|
}
|
||||||
|
|
||||||
class Converters {
|
class Converters {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ sealed interface ExtrasItem {
|
||||||
val subtitle: String?
|
val subtitle: String?
|
||||||
val isPlayed: Boolean
|
val isPlayed: Boolean
|
||||||
val playedPercentage: Double
|
val playedPercentage: Double
|
||||||
|
val aspectRatio: Float?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents multiple extras of the same type
|
* Represents multiple extras of the same type
|
||||||
|
|
@ -39,6 +40,7 @@ sealed interface ExtrasItem {
|
||||||
override val title: String,
|
override val title: String,
|
||||||
override val subtitle: String,
|
override val subtitle: String,
|
||||||
override val isPlayed: Boolean,
|
override val isPlayed: Boolean,
|
||||||
|
override val aspectRatio: Float?,
|
||||||
) : ExtrasItem {
|
) : ExtrasItem {
|
||||||
override val destination: Destination =
|
override val destination: Destination =
|
||||||
Destination.ItemGrid(
|
Destination.ItemGrid(
|
||||||
|
|
@ -82,6 +84,8 @@ sealed interface ExtrasItem {
|
||||||
get() = item.played
|
get() = item.played
|
||||||
override val playedPercentage
|
override val playedPercentage
|
||||||
get() = item.data.userData?.playedPercentage ?: 0.0
|
get() = item.data.userData?.playedPercentage ?: 0.0
|
||||||
|
override val aspectRatio: Float?
|
||||||
|
get() = item.aspectRatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,18 +96,18 @@ sealed interface ExtrasItem {
|
||||||
val ExtraType.stringRes: Int
|
val ExtraType.stringRes: Int
|
||||||
get() =
|
get() =
|
||||||
when (this) {
|
when (this) {
|
||||||
ExtraType.UNKNOWN -> R.string.other_extras
|
ExtraType.UNKNOWN -> R.string.other_extras_title
|
||||||
ExtraType.CLIP -> R.string.clips
|
ExtraType.CLIP -> R.string.clips_title
|
||||||
ExtraType.TRAILER -> R.string.trailers
|
ExtraType.TRAILER -> R.string.trailers_title
|
||||||
ExtraType.BEHIND_THE_SCENES -> R.string.behind_the_scenes
|
ExtraType.BEHIND_THE_SCENES -> R.string.behind_the_scenes_title
|
||||||
ExtraType.DELETED_SCENE -> R.string.deleted_scenes
|
ExtraType.DELETED_SCENE -> R.string.deleted_scenes_title
|
||||||
ExtraType.INTERVIEW -> R.string.interviews
|
ExtraType.INTERVIEW -> R.string.interviews_title
|
||||||
ExtraType.SCENE -> R.string.scenes
|
ExtraType.SCENE -> R.string.scenes_title
|
||||||
ExtraType.SAMPLE -> R.string.samples
|
ExtraType.SAMPLE -> R.string.samples_title
|
||||||
ExtraType.THEME_SONG -> R.string.theme_songs
|
ExtraType.THEME_SONG -> R.string.theme_songs_title
|
||||||
ExtraType.THEME_VIDEO -> R.string.theme_videos
|
ExtraType.THEME_VIDEO -> R.string.theme_videos_title
|
||||||
ExtraType.FEATURETTE -> R.string.featurettes
|
ExtraType.FEATURETTE -> R.string.featurettes_title
|
||||||
ExtraType.SHORT -> R.string.shorts
|
ExtraType.SHORT -> R.string.shorts_title
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
import kotlinx.coroutines.Dispatchers
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
|
|
@ -19,6 +19,9 @@ import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for [ItemPlaybackDao] to save [ItemPlayback] objects and also convert them into [ChosenStreams]
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ItemPlaybackRepository
|
class ItemPlaybackRepository
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -28,6 +31,13 @@ class ItemPlaybackRepository
|
||||||
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
|
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
|
||||||
private val streamChoiceService: StreamChoiceService,
|
private val streamChoiceService: StreamChoiceService,
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* Get the chosen source & stream for the given item
|
||||||
|
*
|
||||||
|
* This will be either the explicit choice from the user ([ItemPlayback]) or the default choices
|
||||||
|
*
|
||||||
|
* @see getChosenItemFromPlayback
|
||||||
|
*/
|
||||||
suspend fun getSelectedTracks(
|
suspend fun getSelectedTracks(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
|
|
@ -40,6 +50,11 @@ class ItemPlaybackRepository
|
||||||
return getChosenItemFromPlayback(item, itemPlayback, plc, prefs)
|
return getChosenItemFromPlayback(item, itemPlayback, plc, prefs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the chosen source & stream for the given item
|
||||||
|
*
|
||||||
|
* This will be either the explicit choice from the provided [ItemPlayback] & [PlaybackLanguageChoice] or the default choices if they are null
|
||||||
|
*/
|
||||||
fun getChosenItemFromPlayback(
|
fun getChosenItemFromPlayback(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
itemPlayback: ItemPlayback?,
|
itemPlayback: ItemPlayback?,
|
||||||
|
|
@ -86,11 +101,14 @@ class ItemPlaybackRepository
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the choice for a specific media source for an item
|
||||||
|
*/
|
||||||
suspend fun savePlayVersion(
|
suspend fun savePlayVersion(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
sourceId: UUID,
|
sourceId: UUID,
|
||||||
): ItemPlayback? =
|
): ItemPlayback? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(WholphinDispatchers.IO) {
|
||||||
serverRepository.currentUser?.let { user ->
|
serverRepository.currentUser?.let { user ->
|
||||||
val itemPlayback =
|
val itemPlayback =
|
||||||
ItemPlayback(
|
ItemPlayback(
|
||||||
|
|
@ -103,6 +121,11 @@ class ItemPlaybackRepository
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the chosen track (index & type) for an item
|
||||||
|
*
|
||||||
|
* Additionally, if the item is part of a TV Series, store the language as the preferred one for this series
|
||||||
|
*/
|
||||||
suspend fun saveTrackSelection(
|
suspend fun saveTrackSelection(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
itemPlayback: ItemPlayback?,
|
itemPlayback: ItemPlayback?,
|
||||||
|
|
@ -180,6 +203,9 @@ class ItemPlaybackRepository
|
||||||
return toSave.copy(rowId = id)
|
return toSave.copy(rowId = id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for [ItemPlaybackDao.getTrackModifications] to get the [ItemTrackModification] for an item, if any
|
||||||
|
*/
|
||||||
suspend fun getTrackModifications(
|
suspend fun getTrackModifications(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
trackIndex: Int,
|
trackIndex: Int,
|
||||||
|
|
@ -188,6 +214,9 @@ class ItemPlaybackRepository
|
||||||
itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex)
|
itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and save a [ItemTrackModification] via [ItemPlaybackDao.saveItem]
|
||||||
|
*/
|
||||||
suspend fun saveTrackModifications(
|
suspend fun saveTrackModifications(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
trackIndex: Int,
|
trackIndex: Int,
|
||||||
|
|
@ -206,6 +235,9 @@ class ItemPlaybackRepository
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the user's source/stream choices for an item
|
||||||
|
*/
|
||||||
suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) {
|
suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) {
|
||||||
Timber.d("deleteChosenStreams: %s", chosenStreams)
|
Timber.d("deleteChosenStreams: %s", chosenStreams)
|
||||||
chosenStreams?.plc?.let {
|
chosenStreams?.plc?.let {
|
||||||
|
|
@ -219,6 +251,9 @@ class ItemPlaybackRepository
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Info about which media source and streams the user/app has chosen for playback
|
||||||
|
*/
|
||||||
data class ChosenStreams(
|
data class ChosenStreams(
|
||||||
val itemPlayback: ItemPlayback?,
|
val itemPlayback: ItemPlayback?,
|
||||||
val plc: PlaybackLanguageChoice?,
|
val plc: PlaybackLanguageChoice?,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import androidx.room.Update
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
|
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
|
|
@ -56,6 +57,12 @@ interface JellyfinServerDao {
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
): JellyfinUser?
|
): JellyfinUser?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId")
|
||||||
|
fun getUserFlow(
|
||||||
|
serverId: UUID,
|
||||||
|
userId: UUID,
|
||||||
|
): Flow<JellyfinUser?>
|
||||||
|
|
||||||
@Query("DELETE FROM servers WHERE id = :serverId")
|
@Query("DELETE FROM servers WHERE id = :serverId")
|
||||||
fun deleteServer(serverId: UUID)
|
fun deleteServer(serverId: UUID)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package com.github.damontecres.wholphin.data
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import com.github.damontecres.wholphin.data.model.RememberedTab
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface RememberedTabDao {
|
||||||
|
@Query("SELECT * from RememberedTab WHERE userId=:userId AND itemId=:itemId")
|
||||||
|
suspend fun getRememberedTab(
|
||||||
|
userId: Int,
|
||||||
|
itemId: String,
|
||||||
|
): RememberedTab?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun save(item: RememberedTab): Long
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun saveAll(items: List<RememberedTab>)
|
||||||
|
}
|
||||||
|
|
@ -9,13 +9,17 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
|
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import org.jellyfin.sdk.Jellyfin
|
import org.jellyfin.sdk.Jellyfin
|
||||||
|
|
@ -56,7 +60,18 @@ class ServerRepository
|
||||||
val currentServer: JellyfinServer? get() = _current.value?.server
|
val currentServer: JellyfinServer? get() = _current.value?.server
|
||||||
val currentServerFlow: Flow<JellyfinServer?> get() = _current.map { it?.server }
|
val currentServerFlow: Flow<JellyfinServer?> get() = _current.map { it?.server }
|
||||||
val currentUser: JellyfinUser? get() = _current.value?.user
|
val currentUser: JellyfinUser? get() = _current.value?.user
|
||||||
val currentUserFlow: Flow<JellyfinUser?> get() = _current.map { it?.user }
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
val currentUserFlow: Flow<JellyfinUser?>
|
||||||
|
get() =
|
||||||
|
_current
|
||||||
|
.flatMapLatest { user ->
|
||||||
|
if (user?.user != null) {
|
||||||
|
serverDao.getUserFlow(user.user.serverId, user.user.id)
|
||||||
|
} else {
|
||||||
|
flow { emit(null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a server to the app database and updated the [ApiClient] to the server's URL
|
* Adds a server to the app database and updated the [ApiClient] to the server's URL
|
||||||
|
|
@ -109,7 +124,7 @@ class ServerRepository
|
||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
val currentUser = CurrentUser(updatedServer, updatedUser)
|
val currentUser = CurrentUser(updatedServer, updatedUser)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
_current.value = currentUser
|
_current.value = currentUser
|
||||||
_currentUserDto.value = userDto
|
_currentUserDto.value = userDto
|
||||||
}
|
}
|
||||||
|
|
@ -222,7 +237,7 @@ class ServerRepository
|
||||||
|
|
||||||
suspend fun removeUser(user: JellyfinUser) {
|
suspend fun removeUser(user: JellyfinUser) {
|
||||||
if (current.value?.user?.id == user.id) {
|
if (current.value?.user?.id == user.id) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
_current.value = null
|
_current.value = null
|
||||||
}
|
}
|
||||||
userPreferencesDataStore.updateData {
|
userPreferencesDataStore.updateData {
|
||||||
|
|
@ -241,7 +256,7 @@ class ServerRepository
|
||||||
|
|
||||||
suspend fun removeServer(server: JellyfinServer) {
|
suspend fun removeServer(server: JellyfinServer) {
|
||||||
if (current.value?.server?.id == server.id) {
|
if (current.value?.server?.id == server.id) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
_current.value = null
|
_current.value = null
|
||||||
}
|
}
|
||||||
userPreferencesDataStore.updateData {
|
userPreferencesDataStore.updateData {
|
||||||
|
|
@ -298,6 +313,16 @@ class ServerRepository
|
||||||
response.content
|
response.content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update [currentUserDto] by querying the server
|
||||||
|
*/
|
||||||
|
suspend fun updateUserDto() {
|
||||||
|
val userDto by apiClient.userApi.getCurrentUser()
|
||||||
|
_currentUserDto.update {
|
||||||
|
if (it?.id == userDto.id && currentUser?.id == userDto.id) userDto else it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun getServerSharedPreferences(context: Context): SharedPreferences =
|
fun getServerSharedPreferences(context: Context): SharedPreferences =
|
||||||
context.getSharedPreferences(
|
context.getSharedPreferences(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,287 @@
|
||||||
|
package com.github.damontecres.wholphin.data.filter
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.SearchApi
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.SearchApi.CertificationModeDiscoverTvGet
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.DiscoverMoviesGet200Response
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.DiscoverTvGet200Response
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.TvShowStatus
|
||||||
|
import com.github.damontecres.wholphin.ui.data.flip
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-M-d")
|
||||||
|
|
||||||
|
enum class DiscoverSort(
|
||||||
|
val key: String,
|
||||||
|
@param:StringRes val stringRes: Int,
|
||||||
|
val tvKey: String = key,
|
||||||
|
) {
|
||||||
|
POPULARITY("popularity", R.string.popularity),
|
||||||
|
RELEASE_DATE("release_date", R.string.sort_by_date_released, "first_air_date"),
|
||||||
|
TMDB_VOTE("vote_average", R.string.community_rating),
|
||||||
|
ALPHABETICAL("original_title", R.string.sort_by_name),
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SortOrder.key get() =
|
||||||
|
when (this) {
|
||||||
|
SortOrder.ASCENDING -> "asc"
|
||||||
|
SortOrder.DESCENDING -> "desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DiscoverSortAndDirection(
|
||||||
|
val sort: DiscoverSort = DiscoverSort.POPULARITY,
|
||||||
|
val direction: SortOrder = SortOrder.DESCENDING,
|
||||||
|
) {
|
||||||
|
fun key(isTv: Boolean) = if (isTv) "${sort.tvKey}.${direction.key}" else "${sort.key}.${direction.key}"
|
||||||
|
|
||||||
|
fun flip() = copy(direction = direction.flip())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DiscoverFilter(
|
||||||
|
// /api/v1/languages
|
||||||
|
val language: String? = null,
|
||||||
|
val genreIds: List<Int>? = null,
|
||||||
|
val networkIds: List<Int>? = null,
|
||||||
|
// /api/v1/search/keyword
|
||||||
|
val keywordIds: List<Int>? = null,
|
||||||
|
val excludeKeywordIds: List<Int>? = null,
|
||||||
|
val sortBy: DiscoverSortAndDirection? = null,
|
||||||
|
// val firstAirDateGte: LocalDate? = null,
|
||||||
|
// val firstAirDateLte: LocalDate? = null,
|
||||||
|
val withRuntimeGte: Duration? = null,
|
||||||
|
val withRuntimeLte: Duration? = null,
|
||||||
|
val voteAverageGte: Int? = null,
|
||||||
|
val voteAverageLte: Int? = null,
|
||||||
|
val voteCountGte: Int? = null,
|
||||||
|
val voteCountLte: Int? = null,
|
||||||
|
// /api/v1/watchproviders/regions
|
||||||
|
val watchRegion: String? = null, // US
|
||||||
|
// networkIds?
|
||||||
|
// val watchProviders: String? = null,
|
||||||
|
val status: List<TvShowStatus>? = null,
|
||||||
|
val certification: List<String>? = null,
|
||||||
|
// val certificationGte: String? = null,
|
||||||
|
// val certificationLte: String? = null,
|
||||||
|
// val certificationCountry: String? = null, // US
|
||||||
|
// val certificationMatchExact: Boolean? = null,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Returns how many of filters are actually being used in this
|
||||||
|
*/
|
||||||
|
fun countFilters(filterOptions: List<DiscoverFilterBy<*>>): Int {
|
||||||
|
var count = 0
|
||||||
|
filterOptions.forEach {
|
||||||
|
if (it.get(this) != null) count++
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all the values for the given filters
|
||||||
|
*/
|
||||||
|
fun delete(filterOptions: List<DiscoverFilterBy<*>>): DiscoverFilter {
|
||||||
|
var newFilter = this
|
||||||
|
filterOptions.forEach {
|
||||||
|
newFilter = it.set(null, newFilter)
|
||||||
|
}
|
||||||
|
return newFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun discoverTv(
|
||||||
|
searchApi: SearchApi,
|
||||||
|
page: Int,
|
||||||
|
): DiscoverTvGet200Response =
|
||||||
|
searchApi.discoverTvGet(
|
||||||
|
page = page,
|
||||||
|
language = language,
|
||||||
|
genre = genreIds?.joinToString(",") { it.toString() },
|
||||||
|
network = null,
|
||||||
|
keywords = keywordIds?.joinToString(",") { it.toString() },
|
||||||
|
excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() },
|
||||||
|
sortBy = sortBy?.key(true),
|
||||||
|
// firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) },
|
||||||
|
// firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) },
|
||||||
|
withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(),
|
||||||
|
withRuntimeLte = withRuntimeLte?.inWholeMinutes?.toInt(),
|
||||||
|
voteAverageGte = voteAverageGte,
|
||||||
|
voteAverageLte = voteAverageLte,
|
||||||
|
voteCountGte = voteCountGte,
|
||||||
|
voteCountLte = voteCountLte,
|
||||||
|
// TODO
|
||||||
|
// watchRegion = watchRegion,
|
||||||
|
watchRegion = networkIds?.let { "US" },
|
||||||
|
watchProviders = networkIds?.joinToString(",") { it.toString() },
|
||||||
|
status = status?.joinToString("|") { it.ordinal.toString() },
|
||||||
|
certification = certification?.joinToString("|"),
|
||||||
|
// certificationGte = certificationGte,
|
||||||
|
// certificationLte = certificationLte,
|
||||||
|
certificationCountry = certification?.let { "US" },
|
||||||
|
certificationMode = certification?.let { CertificationModeDiscoverTvGet.EXACT },
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun discoverMovies(
|
||||||
|
searchApi: SearchApi,
|
||||||
|
page: Int,
|
||||||
|
): DiscoverMoviesGet200Response =
|
||||||
|
searchApi.discoverMoviesGet(
|
||||||
|
page = page,
|
||||||
|
language = language,
|
||||||
|
genre = genreIds?.joinToString(",") { it.toString() },
|
||||||
|
// network = null,
|
||||||
|
keywords = keywordIds?.joinToString(",") { it.toString() },
|
||||||
|
excludeKeywords = excludeKeywordIds?.joinToString(",") { it.toString() },
|
||||||
|
sortBy = sortBy?.key(false),
|
||||||
|
// firstAirDateGte = firstAirDateGte?.let { dateFormatter.format(it) },
|
||||||
|
// firstAirDateLte = firstAirDateLte?.let { dateFormatter.format(it) },
|
||||||
|
withRuntimeGte = withRuntimeGte?.inWholeMinutes?.toInt(),
|
||||||
|
withRuntimeLte = withRuntimeLte?.inWholeMinutes?.toInt(),
|
||||||
|
voteAverageGte = voteAverageGte,
|
||||||
|
voteAverageLte = voteAverageLte,
|
||||||
|
voteCountGte = voteCountGte,
|
||||||
|
voteCountLte = voteCountLte,
|
||||||
|
// TODO
|
||||||
|
// watchRegion = watchRegion,
|
||||||
|
watchRegion = networkIds?.let { "US" },
|
||||||
|
watchProviders = networkIds?.joinToString(",") { it.toString() },
|
||||||
|
// status = status,
|
||||||
|
certification = certification?.joinToString("|"),
|
||||||
|
// certificationGte = certificationGte,
|
||||||
|
// certificationLte = certificationLte,
|
||||||
|
certificationCountry = certification?.let { "US" },
|
||||||
|
certificationMode = certification?.let { SearchApi.CertificationModeDiscoverMoviesGet.EXACT },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val discoverMovieFilters =
|
||||||
|
listOf(
|
||||||
|
DiscoverMovieGenreFilter,
|
||||||
|
DiscoverMovieStudiosFilter,
|
||||||
|
DiscoverRatingFilter,
|
||||||
|
DiscoverMovieContentRatingFilter,
|
||||||
|
)
|
||||||
|
|
||||||
|
val discoverTvFilters =
|
||||||
|
listOf(
|
||||||
|
DiscoverTvGenreFilter,
|
||||||
|
DiscoverTvStudiosFilter,
|
||||||
|
DiscoverRatingFilter,
|
||||||
|
DiscoverTvContentRatingFilter,
|
||||||
|
DiscoverTvStatusFilter,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A way to filter discover
|
||||||
|
*
|
||||||
|
* Gets and sets values within a [DiscoverFilter]
|
||||||
|
*/
|
||||||
|
sealed interface DiscoverFilterBy<T> : FilterBy<DiscoverFilter, T>
|
||||||
|
|
||||||
|
data object DiscoverMovieGenreFilter : DiscoverFilterBy<List<Int>> {
|
||||||
|
override val stringRes: Int = R.string.genres
|
||||||
|
|
||||||
|
override val supportMultiple: Boolean = true
|
||||||
|
|
||||||
|
override fun get(filter: DiscoverFilter): List<Int>? = filter.genreIds
|
||||||
|
|
||||||
|
override fun set(
|
||||||
|
value: List<Int>?,
|
||||||
|
filter: DiscoverFilter,
|
||||||
|
): DiscoverFilter = filter.copy(genreIds = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
data object DiscoverTvGenreFilter : DiscoverFilterBy<List<Int>> {
|
||||||
|
override val stringRes: Int = R.string.genres
|
||||||
|
|
||||||
|
override val supportMultiple: Boolean = true
|
||||||
|
|
||||||
|
override fun get(filter: DiscoverFilter): List<Int>? = filter.genreIds
|
||||||
|
|
||||||
|
override fun set(
|
||||||
|
value: List<Int>?,
|
||||||
|
filter: DiscoverFilter,
|
||||||
|
): DiscoverFilter = filter.copy(genreIds = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
data object DiscoverMovieStudiosFilter : DiscoverFilterBy<List<Int>> {
|
||||||
|
override val stringRes: Int = R.string.studios
|
||||||
|
|
||||||
|
override val supportMultiple: Boolean = true
|
||||||
|
|
||||||
|
override fun get(filter: DiscoverFilter): List<Int>? = filter.networkIds
|
||||||
|
|
||||||
|
override fun set(
|
||||||
|
value: List<Int>?,
|
||||||
|
filter: DiscoverFilter,
|
||||||
|
): DiscoverFilter = filter.copy(networkIds = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
data object DiscoverTvStudiosFilter : DiscoverFilterBy<List<Int>> {
|
||||||
|
override val stringRes: Int = R.string.studios
|
||||||
|
|
||||||
|
override val supportMultiple: Boolean = true
|
||||||
|
|
||||||
|
override fun get(filter: DiscoverFilter): List<Int>? = filter.networkIds
|
||||||
|
|
||||||
|
override fun set(
|
||||||
|
value: List<Int>?,
|
||||||
|
filter: DiscoverFilter,
|
||||||
|
): DiscoverFilter = filter.copy(networkIds = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
data object DiscoverTvStatusFilter : DiscoverFilterBy<List<TvShowStatus>> {
|
||||||
|
override val stringRes: Int = R.string.production_status
|
||||||
|
|
||||||
|
override val supportMultiple: Boolean = true
|
||||||
|
|
||||||
|
override fun get(filter: DiscoverFilter): List<TvShowStatus>? = filter.status
|
||||||
|
|
||||||
|
override fun set(
|
||||||
|
value: List<TvShowStatus>?,
|
||||||
|
filter: DiscoverFilter,
|
||||||
|
): DiscoverFilter = filter.copy(status = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
data object DiscoverMovieContentRatingFilter : DiscoverFilterBy<List<String>> {
|
||||||
|
override val stringRes: Int = R.string.official_rating
|
||||||
|
|
||||||
|
override val supportMultiple: Boolean = true
|
||||||
|
|
||||||
|
override fun get(filter: DiscoverFilter): List<String>? = filter.certification
|
||||||
|
|
||||||
|
override fun set(
|
||||||
|
value: List<String>?,
|
||||||
|
filter: DiscoverFilter,
|
||||||
|
): DiscoverFilter = filter.copy(certification = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
data object DiscoverTvContentRatingFilter : DiscoverFilterBy<List<String>> {
|
||||||
|
override val stringRes: Int = R.string.official_rating
|
||||||
|
|
||||||
|
override val supportMultiple: Boolean = true
|
||||||
|
|
||||||
|
override fun get(filter: DiscoverFilter): List<String>? = filter.certification
|
||||||
|
|
||||||
|
override fun set(
|
||||||
|
value: List<String>?,
|
||||||
|
filter: DiscoverFilter,
|
||||||
|
): DiscoverFilter = filter.copy(certification = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
data object DiscoverRatingFilter : DiscoverFilterBy<Int> {
|
||||||
|
override val stringRes: Int = R.string.community_rating
|
||||||
|
|
||||||
|
override val supportMultiple: Boolean = true
|
||||||
|
|
||||||
|
override fun get(filter: DiscoverFilter): Int? = filter.voteAverageGte
|
||||||
|
|
||||||
|
override fun set(
|
||||||
|
value: Int?,
|
||||||
|
filter: DiscoverFilter,
|
||||||
|
): DiscoverFilter = filter.copy(voteAverageGte = value)
|
||||||
|
}
|
||||||
|
|
@ -77,24 +77,29 @@ val DefaultPlaylistItemsOptions =
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A way to filter libraries
|
* Abstract way to filter data
|
||||||
*
|
|
||||||
* Gets and sets values within a [GetItemsFilter]
|
|
||||||
*/
|
*/
|
||||||
sealed interface ItemFilterBy<T> {
|
sealed interface FilterBy<FilterType, DataType> {
|
||||||
@get:StringRes
|
@get:StringRes
|
||||||
val stringRes: Int
|
val stringRes: Int
|
||||||
|
|
||||||
val supportMultiple: Boolean
|
val supportMultiple: Boolean
|
||||||
|
|
||||||
fun get(filter: GetItemsFilter): T?
|
fun get(filter: FilterType): DataType?
|
||||||
|
|
||||||
fun set(
|
fun set(
|
||||||
value: T?,
|
value: DataType?,
|
||||||
filter: GetItemsFilter,
|
filter: FilterType,
|
||||||
): GetItemsFilter
|
): FilterType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A way to filter jellyfin libraries
|
||||||
|
*
|
||||||
|
* Gets and sets values within a [GetItemsFilter]
|
||||||
|
*/
|
||||||
|
sealed interface ItemFilterBy<T> : FilterBy<GetItemsFilter, T>
|
||||||
|
|
||||||
data object GenreFilter : ItemFilterBy<List<UUID>> {
|
data object GenreFilter : ItemFilterBy<List<UUID>> {
|
||||||
override val stringRes: Int = R.string.genres
|
override val stringRes: Int = R.string.genres
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package com.github.damontecres.wholphin.data.filter
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||||
|
|
||||||
|
interface SearchFilter<T : SearchFilter<T>> {
|
||||||
|
/**
|
||||||
|
* Returns how many of filters are actually being used in this [GetItemsFilter]
|
||||||
|
*/
|
||||||
|
fun countFilters(filterOptions: List<FilterBy<T, *>>): Int {
|
||||||
|
var count = 0
|
||||||
|
filterOptions.forEach {
|
||||||
|
if (it.get(this as T) != null) count++
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all the values for the given filters
|
||||||
|
*/
|
||||||
|
fun delete(filterOptions: List<FilterBy<T, *>>): T {
|
||||||
|
var newFilter = this
|
||||||
|
filterOptions.forEach {
|
||||||
|
newFilter = it.set(null, newFilter as T)
|
||||||
|
}
|
||||||
|
return newFilter as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,13 +5,18 @@ import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.WholphinApplication
|
||||||
import com.github.damontecres.wholphin.ui.abbreviateNumber
|
import com.github.damontecres.wholphin.ui.abbreviateNumber
|
||||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||||
import com.github.damontecres.wholphin.ui.detail.music.artistsString
|
import com.github.damontecres.wholphin.ui.detail.music.artistsString
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||||
import com.github.damontecres.wholphin.ui.dot
|
import com.github.damontecres.wholphin.ui.dot
|
||||||
import com.github.damontecres.wholphin.ui.formatDateTime
|
import com.github.damontecres.wholphin.ui.formatDateTime
|
||||||
|
import com.github.damontecres.wholphin.ui.formatDuration
|
||||||
|
import com.github.damontecres.wholphin.ui.formatEpisodeNumber
|
||||||
import com.github.damontecres.wholphin.ui.getDateFormatter
|
import com.github.damontecres.wholphin.ui.getDateFormatter
|
||||||
|
import com.github.damontecres.wholphin.ui.joinNotBlank
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.playback.playable
|
import com.github.damontecres.wholphin.ui.playback.playable
|
||||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||||
|
|
@ -19,11 +24,13 @@ import com.github.damontecres.wholphin.ui.seasonEpisode
|
||||||
import com.github.damontecres.wholphin.ui.seasonEpisodePadded
|
import com.github.damontecres.wholphin.ui.seasonEpisodePadded
|
||||||
import com.github.damontecres.wholphin.ui.seriesProductionYears
|
import com.github.damontecres.wholphin.ui.seriesProductionYears
|
||||||
import com.github.damontecres.wholphin.ui.timeRemaining
|
import com.github.damontecres.wholphin.ui.timeRemaining
|
||||||
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -48,7 +55,7 @@ data class BaseItem(
|
||||||
get() = type.playable
|
get() = type.playable
|
||||||
|
|
||||||
override val sortName: String
|
override val sortName: String
|
||||||
get() = data.sortName ?: data.name ?: ""
|
get() = data.alphabetSortName
|
||||||
|
|
||||||
val type get() = data.type
|
val type get() = data.type
|
||||||
|
|
||||||
|
|
@ -56,14 +63,14 @@ data class BaseItem(
|
||||||
|
|
||||||
val title get() = if (type == BaseItemKind.EPISODE) data.seriesName else name
|
val title get() = if (type == BaseItemKind.EPISODE) data.seriesName else name
|
||||||
|
|
||||||
val subtitle
|
val subtitle: String? by lazy {
|
||||||
get() =
|
|
||||||
when (type) {
|
when (type) {
|
||||||
BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name
|
BaseItemKind.EPISODE -> listOf(data.seasonEpisode, name).joinNotBlank(" - ")
|
||||||
BaseItemKind.SERIES -> data.seriesProductionYears
|
BaseItemKind.SERIES -> data.seriesProductionYears
|
||||||
BaseItemKind.AUDIO -> listOfNotNull(data.album, artistsString).joinToString(" - ")
|
BaseItemKind.AUDIO -> listOf(data.album, artistsString).joinNotBlank(" - ")
|
||||||
else -> data.productionYear?.toString()
|
else -> data.productionYear?.toString()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val subtitleLong: String? by lazy {
|
val subtitleLong: String? by lazy {
|
||||||
if (type == BaseItemKind.EPISODE) {
|
if (type == BaseItemKind.EPISODE) {
|
||||||
|
|
@ -71,7 +78,7 @@ data class BaseItem(
|
||||||
add(data.seasonEpisodePadded)
|
add(data.seasonEpisodePadded)
|
||||||
add(data.name)
|
add(data.name)
|
||||||
add(data.premiereDate?.let { formatDateTime(it) })
|
add(data.premiereDate?.let { formatDateTime(it) })
|
||||||
}.filterNotNull().joinToString(" - ")
|
}.joinNotBlank(" - ")
|
||||||
} else {
|
} else {
|
||||||
data.productionYear?.toString()
|
data.productionYear?.toString()
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +98,12 @@ data class BaseItem(
|
||||||
|
|
||||||
val favorite get() = data.userData?.isFavorite ?: false
|
val favorite get() = data.userData?.isFavorite ?: false
|
||||||
|
|
||||||
val timeRemainingOrRuntime: Duration? get() = data.timeRemaining ?: data.runTimeTicks?.ticks
|
val timeRemainingOrRuntime: Duration?
|
||||||
|
get() =
|
||||||
|
when (type) {
|
||||||
|
BaseItemKind.PROGRAM -> null
|
||||||
|
else -> data.timeRemaining ?: data.runTimeTicks?.ticks
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contains pre computed UI elements that would be expensive to create on the main thread
|
* Contains pre computed UI elements that would be expensive to create on the main thread
|
||||||
|
|
@ -100,7 +112,7 @@ data class BaseItem(
|
||||||
val ui =
|
val ui =
|
||||||
BaseItemUi(
|
BaseItemUi(
|
||||||
episodeCornerText =
|
episodeCornerText =
|
||||||
data.indexNumber?.let { "E$it" }
|
data.indexNumber?.let { formatEpisodeNumber(it) }
|
||||||
?: data.premiereDate?.let(::formatDateTime),
|
?: data.premiereDate?.let(::formatDateTime),
|
||||||
episodeUnplayedCornerText =
|
episodeUnplayedCornerText =
|
||||||
if (type == BaseItemKind.SERIES ||
|
if (type == BaseItemKind.SERIES ||
|
||||||
|
|
@ -108,7 +120,7 @@ data class BaseItem(
|
||||||
type == BaseItemKind.EPISODE ||
|
type == BaseItemKind.EPISODE ||
|
||||||
type == BaseItemKind.BOX_SET
|
type == BaseItemKind.BOX_SET
|
||||||
) {
|
) {
|
||||||
data.indexNumber?.let { "E$it" }
|
data.indexNumber?.let { formatEpisodeNumber(it) }
|
||||||
?: data.userData
|
?: data.userData
|
||||||
?.unplayedItemCount
|
?.unplayedItemCount
|
||||||
?.takeIf { it > 0 }
|
?.takeIf { it > 0 }
|
||||||
|
|
@ -136,16 +148,36 @@ data class BaseItem(
|
||||||
} else if (type == BaseItemKind.BOX_SET) {
|
} else if (type == BaseItemKind.BOX_SET) {
|
||||||
data.productionYear?.let { add(it.toString()) }
|
data.productionYear?.let { add(it.toString()) }
|
||||||
data.childCount?.let { add("$it items") }
|
data.childCount?.let { add("$it items") }
|
||||||
|
} else if (type == BaseItemKind.PROGRAM) {
|
||||||
|
data.channelName?.let(::add)
|
||||||
|
if (data.isSeries == true) {
|
||||||
|
// TV episode
|
||||||
|
data.seasonEpisode?.let(::add)
|
||||||
|
data.premiereDate?.let {
|
||||||
|
add(getDateFormatter().format(it))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data.productionYear?.let { add(it.toString()) }
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
data.productionYear?.let { add(it.toString()) }
|
data.productionYear?.let { add(it.toString()) }
|
||||||
}
|
}
|
||||||
data.runTimeTicks
|
data.runTimeTicks
|
||||||
?.ticks
|
?.ticks
|
||||||
?.roundMinutes
|
?.roundMinutes
|
||||||
?.let { add(it.toString()) }
|
?.takeIf { it > Duration.ZERO }
|
||||||
|
?.let { add(WholphinApplication.instance.resources.formatDuration(it)) }
|
||||||
data.timeRemaining
|
data.timeRemaining
|
||||||
?.roundMinutes
|
?.roundMinutes
|
||||||
?.let { add("$it left") }
|
?.let {
|
||||||
|
val resources = WholphinApplication.instance.resources
|
||||||
|
add(
|
||||||
|
resources.getString(
|
||||||
|
R.string.time_left,
|
||||||
|
resources.formatDuration(it),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
details.forEachIndexed { index, string ->
|
details.forEachIndexed { index, string ->
|
||||||
append(string)
|
append(string)
|
||||||
|
|
@ -197,25 +229,38 @@ data class BaseItem(
|
||||||
*/
|
*/
|
||||||
fun destination(index: Int? = null): Destination {
|
fun destination(index: Int? = null): Destination {
|
||||||
if (destinationOverride != null) return destinationOverride
|
if (destinationOverride != null) return destinationOverride
|
||||||
|
if (data.extraType != null || type == BaseItemKind.TRAILER) {
|
||||||
|
// Extras including trailers should always play directly
|
||||||
|
return Destination.Playback(id, 0)
|
||||||
|
}
|
||||||
val result =
|
val result =
|
||||||
// Redirect episodes & seasons to their series if possible
|
// Redirect episodes & seasons to their series if possible
|
||||||
when (type) {
|
when (type) {
|
||||||
BaseItemKind.EPISODE -> {
|
BaseItemKind.EPISODE -> {
|
||||||
data.seasonId?.let { seasonId ->
|
val seasonId = data.seasonId
|
||||||
|
val seriesId = data.seriesId
|
||||||
|
if (seasonId != null && seriesId != null) {
|
||||||
Destination.SeriesOverview(
|
Destination.SeriesOverview(
|
||||||
data.seriesId!!,
|
seriesId,
|
||||||
BaseItemKind.SERIES,
|
BaseItemKind.SERIES,
|
||||||
SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber),
|
SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber),
|
||||||
)
|
)
|
||||||
} ?: Destination.MediaItem(this)
|
} else {
|
||||||
|
Destination.MediaItem(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BaseItemKind.SEASON -> {
|
BaseItemKind.SEASON -> {
|
||||||
|
val seriesId = data.seriesId
|
||||||
|
if (seriesId != null) {
|
||||||
Destination.SeriesOverview(
|
Destination.SeriesOverview(
|
||||||
data.seriesId!!,
|
seriesId,
|
||||||
BaseItemKind.SERIES,
|
BaseItemKind.SERIES,
|
||||||
SeasonEpisodeIds(id, indexNumber, null, null),
|
SeasonEpisodeIds(id, indexNumber, null, null),
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
Destination.MediaItem(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BaseItemKind.TV_CHANNEL -> {
|
BaseItemKind.TV_CHANNEL -> {
|
||||||
|
|
@ -237,6 +282,16 @@ data class BaseItem(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BaseItemKind.AUDIO -> {
|
||||||
|
data.albumId?.let { albumId ->
|
||||||
|
Destination.MediaItem(
|
||||||
|
itemId = albumId,
|
||||||
|
type = BaseItemKind.MUSIC_ALBUM,
|
||||||
|
initialSongId = id,
|
||||||
|
)
|
||||||
|
} ?: Destination.MediaItem(this)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Destination.MediaItem(this)
|
Destination.MediaItem(this)
|
||||||
}
|
}
|
||||||
|
|
@ -260,6 +315,9 @@ data class BaseItem(
|
||||||
|
|
||||||
val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { h -> w.toFloat() / h.toFloat() } }
|
val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { h -> w.toFloat() / h.toFloat() } }
|
||||||
|
|
||||||
|
/** The server strips leading articles (eg "The ") from [BaseItemDto.sortName]. */
|
||||||
|
val BaseItemDto.alphabetSortName: String get() = sortName ?: name ?: ""
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class BaseItemUi(
|
data class BaseItemUi(
|
||||||
val episodeCornerText: String?,
|
val episodeCornerText: String?,
|
||||||
|
|
@ -283,6 +341,7 @@ fun createGenreDestination(
|
||||||
parentId: UUID,
|
parentId: UUID,
|
||||||
parentName: String?,
|
parentName: String?,
|
||||||
includeItemTypes: List<BaseItemKind>?,
|
includeItemTypes: List<BaseItemKind>?,
|
||||||
|
collectionType: CollectionType,
|
||||||
) = Destination.FilteredCollection(
|
) = Destination.FilteredCollection(
|
||||||
itemId = parentId,
|
itemId = parentId,
|
||||||
parentType = BaseItemKind.GENRE,
|
parentType = BaseItemKind.GENRE,
|
||||||
|
|
@ -298,9 +357,11 @@ fun createGenreDestination(
|
||||||
genres = listOf(genreId),
|
genres = listOf(genreId),
|
||||||
includeItemTypes = includeItemTypes,
|
includeItemTypes = includeItemTypes,
|
||||||
),
|
),
|
||||||
useSavedLibraryDisplayInfo = false,
|
useSavedLibraryDisplayInfo = true,
|
||||||
|
libraryDisplayInfoIdOverride = "${parentId.toServerString()}_genres",
|
||||||
),
|
),
|
||||||
recursive = true,
|
recursive = true,
|
||||||
|
collectionType = collectionType,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun createStudioDestination(
|
fun createStudioDestination(
|
||||||
|
|
@ -324,9 +385,11 @@ fun createStudioDestination(
|
||||||
studios = listOf(studioId),
|
studios = listOf(studioId),
|
||||||
includeItemTypes = includeItemTypes,
|
includeItemTypes = includeItemTypes,
|
||||||
),
|
),
|
||||||
useSavedLibraryDisplayInfo = false,
|
useSavedLibraryDisplayInfo = true,
|
||||||
|
libraryDisplayInfoIdOverride = "${parentId.toServerString()}_studios",
|
||||||
),
|
),
|
||||||
recursive = true,
|
recursive = true,
|
||||||
|
collectionType = CollectionType.UNKNOWN,
|
||||||
)
|
)
|
||||||
|
|
||||||
val BaseItem.studioNames get() = data.studios?.mapNotNull { it.name }.orEmpty()
|
val BaseItem.studioNames get() = data.studios?.mapNotNull { it.name }.orEmpty()
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,30 @@
|
||||||
package com.github.damontecres.wholphin.data.model
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a chapter within a video
|
* Represents a chapter within a video
|
||||||
*/
|
*/
|
||||||
data class Chapter(
|
data class Chapter(
|
||||||
|
val itemId: UUID,
|
||||||
val name: String?,
|
val name: String?,
|
||||||
val position: Duration,
|
val position: Duration,
|
||||||
val imageUrl: String?,
|
val tag: String?,
|
||||||
|
val index: Int,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromDto(
|
fun fromDto(dto: BaseItemDto): List<Chapter> =
|
||||||
dto: BaseItemDto,
|
|
||||||
api: ApiClient,
|
|
||||||
): List<Chapter> =
|
|
||||||
dto.chapters
|
dto.chapters
|
||||||
?.mapIndexed { index, chapter ->
|
?.mapIndexed { index, chapter ->
|
||||||
Chapter(
|
Chapter(
|
||||||
chapter.name,
|
|
||||||
chapter.startPositionTicks.ticks,
|
|
||||||
chapter.imageTag?.let {
|
|
||||||
api.imageApi.getItemImageUrl(
|
|
||||||
itemId = dto.id,
|
itemId = dto.id,
|
||||||
imageType = ImageType.CHAPTER,
|
name = chapter.name,
|
||||||
tag = it,
|
position = chapter.startPositionTicks.ticks,
|
||||||
imageIndex = index,
|
tag = chapter.imageTag,
|
||||||
)
|
index = index,
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}?.sortedBy { it.position }
|
}?.sortedBy { it.position }
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
|
|
|
||||||
|
|
@ -56,12 +56,14 @@ enum class SeerrItemType(
|
||||||
enum class SeerrAvailability(
|
enum class SeerrAvailability(
|
||||||
val status: Int,
|
val status: Int,
|
||||||
) {
|
) {
|
||||||
|
// https://github.com/seerr-team/seerr/blob/develop/server/constants/media.ts#L14
|
||||||
UNKNOWN(1),
|
UNKNOWN(1),
|
||||||
PENDING(2),
|
PENDING(2),
|
||||||
PROCESSING(3),
|
PROCESSING(3),
|
||||||
PARTIALLY_AVAILABLE(4),
|
PARTIALLY_AVAILABLE(4),
|
||||||
AVAILABLE(5),
|
AVAILABLE(5),
|
||||||
DELETED(6),
|
BLOCKLISTED(6),
|
||||||
|
DELETED(7),
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -69,6 +71,23 @@ enum class SeerrAvailability(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class RequestStatus(
|
||||||
|
val status: Int,
|
||||||
|
) {
|
||||||
|
UNKNOWN(0),
|
||||||
|
PENDING(1),
|
||||||
|
APPROVED(2),
|
||||||
|
DECLINED(3),
|
||||||
|
FAILURE(4),
|
||||||
|
COMPLETED(5),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(status: Int?) = RequestStatus.entries.firstOrNull { it.status == status } ?: UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An item provided by a discovery service (ie Seerr). It may exist on the JF server as well, see [availability].
|
* An item provided by a discovery service (ie Seerr). It may exist on the JF server as well, see [availability].
|
||||||
*/
|
*/
|
||||||
|
|
@ -84,6 +103,7 @@ data class DiscoverItem(
|
||||||
@Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?,
|
@Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?,
|
||||||
val posterUrl: String?,
|
val posterUrl: String?,
|
||||||
val backDropUrl: String?,
|
val backDropUrl: String?,
|
||||||
|
val logoUrl: String?,
|
||||||
val jellyfinItemId: UUID?,
|
val jellyfinItemId: UUID?,
|
||||||
) : CardGridItem {
|
) : CardGridItem {
|
||||||
override val gridId: String get() = id.toString()
|
override val gridId: String get() = id.toString()
|
||||||
|
|
@ -92,13 +112,7 @@ data class DiscoverItem(
|
||||||
|
|
||||||
val destination: Destination
|
val destination: Destination
|
||||||
get() {
|
get() {
|
||||||
val jfType =
|
val jfType = type.baseItemKind
|
||||||
when (type) {
|
|
||||||
SeerrItemType.MOVIE -> BaseItemKind.MOVIE
|
|
||||||
SeerrItemType.TV -> BaseItemKind.SERIES
|
|
||||||
SeerrItemType.PERSON -> BaseItemKind.PERSON
|
|
||||||
SeerrItemType.UNKNOWN -> null
|
|
||||||
}
|
|
||||||
return if (jellyfinItemId != null && jfType != null) {
|
return if (jellyfinItemId != null && jfType != null) {
|
||||||
Destination.MediaItem(
|
Destination.MediaItem(
|
||||||
itemId = jellyfinItemId,
|
itemId = jellyfinItemId,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.UseSerializers
|
import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.VideoType
|
import org.jellyfin.sdk.model.api.VideoType
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
|
|
@ -22,9 +23,13 @@ data class CollectionFolderFilter(
|
||||||
val nameOverride: String? = null,
|
val nameOverride: String? = null,
|
||||||
val filter: GetItemsFilter = GetItemsFilter(),
|
val filter: GetItemsFilter = GetItemsFilter(),
|
||||||
/**
|
/**
|
||||||
* Whether to use the libray's saved sort & filter
|
* Whether to use the library's saved sort & filter
|
||||||
*/
|
*/
|
||||||
val useSavedLibraryDisplayInfo: Boolean = true,
|
val useSavedLibraryDisplayInfo: Boolean = true,
|
||||||
|
/**
|
||||||
|
* Use a different ID for the library's saved sort & filter, such as for library's genres
|
||||||
|
*/
|
||||||
|
val libraryDisplayInfoIdOverride: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -134,6 +139,22 @@ data class GetItemsFilter(
|
||||||
isFavorite = favorite,
|
isFavorite = favorite,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun applyTo(req: GetArtistsRequest) =
|
||||||
|
req.copy(
|
||||||
|
minCommunityRating = minCommunityRating,
|
||||||
|
isFavorite = favorite,
|
||||||
|
genreIds = genres,
|
||||||
|
personIds = persons,
|
||||||
|
studioIds = studios,
|
||||||
|
tags = tags,
|
||||||
|
officialRatings = officialRatings,
|
||||||
|
years =
|
||||||
|
buildSet {
|
||||||
|
years?.letNotEmpty(::addAll)
|
||||||
|
decades?.forEach { addAll(it..<(it + 10)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge another [GetItemsFilter] onto this one, replacing only unset values
|
* Merge another [GetItemsFilter] onto this one, replacing only unset values
|
||||||
*/
|
*/
|
||||||
|
|
@ -158,4 +179,5 @@ data class GetItemsFilter(
|
||||||
enum class GetItemsFilterOverride {
|
enum class GetItemsFilterOverride {
|
||||||
NONE,
|
NONE,
|
||||||
PERSON,
|
PERSON,
|
||||||
|
ARTIST,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -109,15 +109,7 @@ sealed interface HomeRowConfig {
|
||||||
@SerialName("Favorite")
|
@SerialName("Favorite")
|
||||||
data class Favorite(
|
data class Favorite(
|
||||||
val kind: BaseItemKind,
|
val kind: BaseItemKind,
|
||||||
override val viewOptions: HomeRowViewOptions =
|
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||||
if (kind == BaseItemKind.EPISODE) {
|
|
||||||
HomeRowViewOptions(
|
|
||||||
heightDp = Cards.HEIGHT_EPISODE,
|
|
||||||
aspectRatio = AspectRatio.WIDE,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
HomeRowViewOptions()
|
|
||||||
},
|
|
||||||
) : HomeRowConfig {
|
) : HomeRowConfig {
|
||||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions)
|
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions)
|
||||||
}
|
}
|
||||||
|
|
@ -243,7 +235,7 @@ data class HomeRowViewOptions(
|
||||||
|
|
||||||
val liveTvDefault =
|
val liveTvDefault =
|
||||||
HomeRowViewOptions(
|
HomeRowViewOptions(
|
||||||
heightDp = 96,
|
heightDp = Cards.HEIGHT_LIVE_TV,
|
||||||
aspectRatio = AspectRatio.WIDE,
|
aspectRatio = AspectRatio.WIDE,
|
||||||
contentScale = PrefContentScale.FIT,
|
contentScale = PrefContentScale.FIT,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import androidx.room.Relation
|
import androidx.room.Relation
|
||||||
import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer
|
import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer
|
||||||
|
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserProfileSettings
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.UseSerializers
|
import kotlinx.serialization.UseSerializers
|
||||||
|
|
@ -65,11 +67,17 @@ data class JellyfinUser(
|
||||||
val requireLogin: Boolean = false,
|
val requireLogin: Boolean = false,
|
||||||
val lastUsed: ZonedDateTime? = null,
|
val lastUsed: ZonedDateTime? = null,
|
||||||
val uiLanguage: String? = null,
|
val uiLanguage: String? = null,
|
||||||
|
@Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(),
|
||||||
) {
|
) {
|
||||||
val hasPin: Boolean get() = pin.isNotNullOrBlank()
|
val hasPin: Boolean get() = pin.isNotNullOrBlank()
|
||||||
|
|
||||||
|
val isProtected: Boolean get() = hasPin || requireLogin
|
||||||
|
|
||||||
override fun toString(): String =
|
override fun toString(): String =
|
||||||
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})"
|
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " +
|
||||||
|
"accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " +
|
||||||
|
"requireLogin=$requireLogin, lastUsed=$lastUsed, uiLanguage=$uiLanguage, " +
|
||||||
|
"appPreferences=$appPreferences"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -83,3 +91,13 @@ data class JellyfinServerUsers(
|
||||||
)
|
)
|
||||||
val users: List<JellyfinUser>,
|
val users: List<JellyfinUser>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class JellyfinUserPreferences(
|
||||||
|
@ColumnInfo(defaultValue = "")
|
||||||
|
val preferredAudioLanguage: String = UserProfileSettings.USE_USER_PROFILE,
|
||||||
|
@ColumnInfo(defaultValue = "")
|
||||||
|
val preferredSubtitleLanguage: String = UserProfileSettings.USE_USER_PROFILE,
|
||||||
|
@ColumnInfo(defaultValue = "USE_USER_PROFILE")
|
||||||
|
val subtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
package com.github.damontecres.wholphin.data.model
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
@ -11,38 +8,13 @@ import java.util.UUID
|
||||||
*
|
*
|
||||||
* This is not the same thing as a Jellyfin server playlist
|
* This is not the same thing as a Jellyfin server playlist
|
||||||
*/
|
*/
|
||||||
class Playlist(
|
data class Playlist(
|
||||||
items: List<PlaylistItem>,
|
val items: List<PlaylistItem>,
|
||||||
startIndex: Int = 0,
|
|
||||||
) {
|
) {
|
||||||
val items = items.subList(startIndex, items.size)
|
|
||||||
|
|
||||||
var index by mutableIntStateOf(0)
|
|
||||||
|
|
||||||
fun hasPrevious(): Boolean = index > 0
|
|
||||||
|
|
||||||
fun hasNext(): Boolean = (index + 1) < items.size
|
|
||||||
|
|
||||||
fun getPreviousAndReverse(): PlaylistItem = items[--index]
|
|
||||||
|
|
||||||
fun getAndAdvance(): PlaylistItem = items[++index]
|
|
||||||
|
|
||||||
fun peek(): PlaylistItem? = items.getOrNull(index + 1)
|
|
||||||
|
|
||||||
fun upcomingItems(): List<PlaylistItem> = items.subList(index + 1, items.size)
|
|
||||||
|
|
||||||
fun advanceTo(id: UUID): PlaylistItem? {
|
|
||||||
while (hasNext()) {
|
|
||||||
val potential = getAndAdvance()
|
|
||||||
if (potential.id == id) {
|
|
||||||
return potential
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val MAX_SIZE = 100
|
const val MAX_SIZE = 100
|
||||||
|
|
||||||
|
fun fromMedia(list: List<BaseItem>) = Playlist(list.map { PlaylistItem.Media(it) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = JellyfinUser::class,
|
||||||
|
parentColumns = arrayOf("rowId"),
|
||||||
|
childColumns = arrayOf("userId"),
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
onUpdate = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("userId", "itemId", unique = true)],
|
||||||
|
primaryKeys = ["userId", "itemId"],
|
||||||
|
)
|
||||||
|
data class RememberedTab(
|
||||||
|
val userId: Int,
|
||||||
|
val itemId: String,
|
||||||
|
val index: Int,
|
||||||
|
)
|
||||||
|
|
@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.services.SeerrUserConfig
|
||||||
*/
|
*/
|
||||||
enum class SeerrPermission(
|
enum class SeerrPermission(
|
||||||
private val flag: Int,
|
private val flag: Int,
|
||||||
|
vararg val parents: SeerrPermission,
|
||||||
) {
|
) {
|
||||||
// Source: https://github.com/seerr-team/seerr/blob/develop/server/lib/permissions.ts
|
// Source: https://github.com/seerr-team/seerr/blob/develop/server/lib/permissions.ts
|
||||||
NONE(0),
|
NONE(0),
|
||||||
|
|
@ -17,28 +18,28 @@ enum class SeerrPermission(
|
||||||
REQUEST(32),
|
REQUEST(32),
|
||||||
VOTE(64),
|
VOTE(64),
|
||||||
AUTO_APPROVE(128),
|
AUTO_APPROVE(128),
|
||||||
AUTO_APPROVE_MOVIE(256),
|
AUTO_APPROVE_MOVIE(256, AUTO_APPROVE),
|
||||||
AUTO_APPROVE_TV(512),
|
AUTO_APPROVE_TV(512, AUTO_APPROVE),
|
||||||
REQUEST_4K(1024),
|
REQUEST_4K(1024),
|
||||||
REQUEST_4K_MOVIE(2048),
|
REQUEST_4K_MOVIE(2048, REQUEST_4K),
|
||||||
REQUEST_4K_TV(4096),
|
REQUEST_4K_TV(4096, REQUEST_4K),
|
||||||
REQUEST_ADVANCED(8192),
|
REQUEST_ADVANCED(8192, MANAGE_REQUESTS),
|
||||||
REQUEST_VIEW(16384),
|
REQUEST_VIEW(16384, MANAGE_REQUESTS),
|
||||||
AUTO_APPROVE_4K(32768),
|
AUTO_APPROVE_4K(32768),
|
||||||
AUTO_APPROVE_4K_MOVIE(65536),
|
AUTO_APPROVE_4K_MOVIE(65536, AUTO_APPROVE_4K),
|
||||||
AUTO_APPROVE_4K_TV(131072),
|
AUTO_APPROVE_4K_TV(131072, AUTO_APPROVE_4K),
|
||||||
REQUEST_MOVIE(262144),
|
REQUEST_MOVIE(262144, REQUEST),
|
||||||
REQUEST_TV(524288),
|
REQUEST_TV(524288, REQUEST),
|
||||||
MANAGE_ISSUES(1048576),
|
MANAGE_ISSUES(1048576),
|
||||||
VIEW_ISSUES(2097152),
|
VIEW_ISSUES(2097152, MANAGE_ISSUES),
|
||||||
CREATE_ISSUES(4194304),
|
CREATE_ISSUES(4194304, MANAGE_ISSUES),
|
||||||
AUTO_REQUEST(8388608),
|
AUTO_REQUEST(8388608),
|
||||||
AUTO_REQUEST_MOVIE(16777216),
|
AUTO_REQUEST_MOVIE(16777216, AUTO_REQUEST),
|
||||||
AUTO_REQUEST_TV(33554432),
|
AUTO_REQUEST_TV(33554432, AUTO_REQUEST),
|
||||||
RECENT_VIEW(67108864),
|
RECENT_VIEW(67108864, MANAGE_REQUESTS),
|
||||||
WATCHLIST_VIEW(134217728),
|
WATCHLIST_VIEW(134217728, MANAGE_REQUESTS),
|
||||||
MANAGE_BLACKLIST(268435456),
|
MANAGE_BLOCKLIST(268435456),
|
||||||
VIEW_BLACKLIST(1073741824),
|
VIEW_BLOCKLIST(1073741824, MANAGE_BLOCKLIST),
|
||||||
;
|
;
|
||||||
|
|
||||||
internal fun hasPermission(permissions: Int) = flag.and(permissions) == flag
|
internal fun hasPermission(permissions: Int) = flag.and(permissions) == flag
|
||||||
|
|
@ -48,5 +49,8 @@ enum class SeerrPermission(
|
||||||
* Check whether the user has the given permissions (or is an admin)
|
* Check whether the user has the given permissions (or is an admin)
|
||||||
*/
|
*/
|
||||||
fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean {
|
fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean {
|
||||||
return permission.hasPermission(this?.permissions ?: return false) || SeerrPermission.ADMIN.hasPermission(permissions)
|
if (this?.permissions == null) return false
|
||||||
|
return permission.hasPermission(permissions) ||
|
||||||
|
permission.parents.any { hasPermission(it) } ||
|
||||||
|
SeerrPermission.ADMIN.hasPermission(permissions)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,10 @@ sealed interface AppPreference<Pref, T> {
|
||||||
value: T?,
|
value: T?,
|
||||||
): String? = null
|
): String? = null
|
||||||
|
|
||||||
fun validate(value: T): PreferenceValidation = PreferenceValidation.Valid
|
fun validate(
|
||||||
|
prefs: Pref,
|
||||||
|
value: T,
|
||||||
|
): PreferenceValidation = PreferenceValidation.Valid
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val SkipForward =
|
val SkipForward =
|
||||||
|
|
@ -215,15 +218,16 @@ sealed interface AppPreference<Pref, T> {
|
||||||
)
|
)
|
||||||
|
|
||||||
val HomeClickToPlay =
|
val HomeClickToPlay =
|
||||||
AppSwitchPreference<AppPreferences>(
|
AppChoicePreference<AppPreferences, Boolean>(
|
||||||
title = R.string.continue_watching_click_behavior,
|
title = R.string.continue_watching_click_behavior,
|
||||||
defaultValue = false,
|
defaultValue = false,
|
||||||
getter = { it.homePagePreferences.clickToPlay },
|
getter = { it.homePagePreferences.clickToPlay },
|
||||||
setter = { prefs, value ->
|
setter = { prefs, value ->
|
||||||
prefs.updateHomePagePreferences { clickToPlay = value }
|
prefs.updateHomePagePreferences { clickToPlay = value }
|
||||||
},
|
},
|
||||||
summaryOn = R.string.continue_watching_click_summary_on,
|
displayValues = R.array.home_click_to_play_options,
|
||||||
summaryOff = R.string.continue_watching_click_summary_off,
|
indexToValue = { it != 0 },
|
||||||
|
valueToIndex = { if (it) 1 else 0 },
|
||||||
)
|
)
|
||||||
|
|
||||||
val PlayThemeMusic =
|
val PlayThemeMusic =
|
||||||
|
|
@ -391,18 +395,25 @@ sealed interface AppPreference<Pref, T> {
|
||||||
defaultValue = true,
|
defaultValue = true,
|
||||||
getter = { it.playbackPreferences.overrides.ac3Supported },
|
getter = { it.playbackPreferences.overrides.ac3Supported },
|
||||||
setter = { prefs, value ->
|
setter = { prefs, value ->
|
||||||
prefs.updatePlaybackOverrides { ac3Supported = value }
|
if (!value) prefs.updateExperimentalPreferences { preferAc3Surround = false }
|
||||||
|
prefs.updatePlaybackOverrides {
|
||||||
|
ac3Supported = value
|
||||||
|
}
|
||||||
},
|
},
|
||||||
summaryOn = R.string.enabled,
|
summaryOn = R.string.enabled,
|
||||||
summaryOff = R.string.disabled,
|
summaryOff = R.string.disabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
val DownMixStereo =
|
val DownMixStereo =
|
||||||
AppSwitchPreference<AppPreferences>(
|
AppSwitchPreference<AppPreferences>(
|
||||||
title = R.string.downmix_stereo,
|
title = R.string.downmix_stereo,
|
||||||
defaultValue = false,
|
defaultValue = false,
|
||||||
getter = { it.playbackPreferences.overrides.downmixStereo },
|
getter = { it.playbackPreferences.overrides.downmixStereo },
|
||||||
setter = { prefs, value ->
|
setter = { prefs, value ->
|
||||||
prefs.updatePlaybackOverrides { downmixStereo = value }
|
if (value) prefs.updateExperimentalPreferences { preferAc3Surround = false }
|
||||||
|
prefs.updatePlaybackOverrides {
|
||||||
|
downmixStereo = value
|
||||||
|
}
|
||||||
},
|
},
|
||||||
summaryOn = R.string.enabled,
|
summaryOn = R.string.enabled,
|
||||||
summaryOff = R.string.disabled,
|
summaryOff = R.string.disabled,
|
||||||
|
|
@ -572,6 +583,13 @@ sealed interface AppPreference<Pref, T> {
|
||||||
destination = Destination.Settings(PreferenceScreenOption.ADVANCED),
|
destination = Destination.Settings(PreferenceScreenOption.ADVANCED),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val UserProfileSettings =
|
||||||
|
AppDestinationPreference<AppPreferences>(
|
||||||
|
title = R.string.override_user_profile_settings,
|
||||||
|
destination = Destination.UserAppPreferences,
|
||||||
|
summary = R.string.override_user_profile_settings_summary,
|
||||||
|
)
|
||||||
|
|
||||||
val SkipIntros =
|
val SkipIntros =
|
||||||
AppChoicePreference<AppPreferences, SkipSegmentBehavior>(
|
AppChoicePreference<AppPreferences, SkipSegmentBehavior>(
|
||||||
title = R.string.skip_intro_behavior,
|
title = R.string.skip_intro_behavior,
|
||||||
|
|
@ -644,6 +662,20 @@ sealed interface AppPreference<Pref, T> {
|
||||||
destination = Destination.Settings(PreferenceScreenOption.SKIP_SEGMENTS),
|
destination = Destination.Settings(PreferenceScreenOption.SKIP_SEGMENTS),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val DpadSeekModePref =
|
||||||
|
AppChoicePreference<AppPreferences, DpadSeekMode>(
|
||||||
|
title = R.string.d_pad_seek_mode_title,
|
||||||
|
defaultValue = DpadSeekMode.SEEKBAR_MINIMAL,
|
||||||
|
getter = { it.playbackPreferences.dpadSeekMode },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updatePlaybackPreferences { dpadSeekMode = value }
|
||||||
|
},
|
||||||
|
displayValues = R.array.dpad_seek_mode_options,
|
||||||
|
indexToValue = { DpadSeekMode.forNumber(it) },
|
||||||
|
valueToIndex = { if (it != DpadSeekMode.UNRECOGNIZED) it.number else 0 },
|
||||||
|
subtitles = R.array.dpad_seek_mode_summaries,
|
||||||
|
)
|
||||||
|
|
||||||
val GlobalContentScale =
|
val GlobalContentScale =
|
||||||
AppChoicePreference<AppPreferences, PrefContentScale>(
|
AppChoicePreference<AppPreferences, PrefContentScale>(
|
||||||
title = R.string.global_content_scale,
|
title = R.string.global_content_scale,
|
||||||
|
|
@ -1106,6 +1138,7 @@ val basicPreferences =
|
||||||
AppPreference.CustomizeHome,
|
AppPreference.CustomizeHome,
|
||||||
AppPreference.UserPinnedNavDrawerItems,
|
AppPreference.UserPinnedNavDrawerItems,
|
||||||
AppPreference.UserInterfaceLanguage,
|
AppPreference.UserInterfaceLanguage,
|
||||||
|
AppPreference.UserProfileSettings,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PreferenceGroup(
|
PreferenceGroup(
|
||||||
|
|
@ -1208,6 +1241,7 @@ val advancedPreferences =
|
||||||
AppPreference.CinemaMode,
|
AppPreference.CinemaMode,
|
||||||
AppPreference.GlobalContentScale,
|
AppPreference.GlobalContentScale,
|
||||||
AppPreference.SkipSegments,
|
AppPreference.SkipSegments,
|
||||||
|
AppPreference.DpadSeekModePref,
|
||||||
AppPreference.MaxBitrate,
|
AppPreference.MaxBitrate,
|
||||||
AppPreference.RefreshRateSwitching,
|
AppPreference.RefreshRateSwitching,
|
||||||
AppPreference.ResolutionSwitching,
|
AppPreference.ResolutionSwitching,
|
||||||
|
|
@ -1266,8 +1300,16 @@ val advancedPreferences =
|
||||||
AppPreference.DebugLogging,
|
AppPreference.DebugLogging,
|
||||||
AppPreference.ImageDiskCacheSize,
|
AppPreference.ImageDiskCacheSize,
|
||||||
AppPreference.ClearImageCache,
|
AppPreference.ClearImageCache,
|
||||||
|
ExperimentalPreference.Enable,
|
||||||
AppPreference.OssLicenseInfo,
|
AppPreference.OssLicenseInfo,
|
||||||
),
|
),
|
||||||
|
conditionalPreferences =
|
||||||
|
listOf(
|
||||||
|
ConditionalPreferences(
|
||||||
|
condition = { it.experimentalPreferences.enabled },
|
||||||
|
preferences = listOf(ExperimentalPreference.ExperimentalSettings),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1296,6 +1338,14 @@ val screensaverPreferences =
|
||||||
ScreensaverPreference.Start,
|
ScreensaverPreference.Start,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
PreferenceGroup(
|
||||||
|
title = R.string.dim_screen,
|
||||||
|
preferences =
|
||||||
|
listOf(
|
||||||
|
ScreensaverPreference.DimToggle,
|
||||||
|
ScreensaverPreference.DimPercentage,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AppSwitchPreference<Pref>(
|
data class AppSwitchPreference<Pref>(
|
||||||
|
|
@ -1303,11 +1353,16 @@ data class AppSwitchPreference<Pref>(
|
||||||
override val defaultValue: Boolean,
|
override val defaultValue: Boolean,
|
||||||
override val getter: (prefs: Pref) -> Boolean,
|
override val getter: (prefs: Pref) -> Boolean,
|
||||||
override val setter: (prefs: Pref, value: Boolean) -> Pref,
|
override val setter: (prefs: Pref, value: Boolean) -> Pref,
|
||||||
val validator: (value: Boolean) -> PreferenceValidation = { PreferenceValidation.Valid },
|
val validator: (prefs: Pref, value: Boolean) -> PreferenceValidation = { _, _ -> PreferenceValidation.Valid },
|
||||||
@param:StringRes val summary: Int? = null,
|
@param:StringRes val summary: Int? = null,
|
||||||
@param:StringRes val summaryOn: Int? = null,
|
@param:StringRes val summaryOn: Int? = null,
|
||||||
@param:StringRes val summaryOff: Int? = null,
|
@param:StringRes val summaryOff: Int? = null,
|
||||||
) : AppPreference<Pref, Boolean> {
|
) : AppPreference<Pref, Boolean> {
|
||||||
|
override fun validate(
|
||||||
|
prefs: Pref,
|
||||||
|
value: Boolean,
|
||||||
|
): PreferenceValidation = validator.invoke(prefs, value)
|
||||||
|
|
||||||
override fun summary(
|
override fun summary(
|
||||||
context: Context,
|
context: Context,
|
||||||
value: Boolean?,
|
value: Boolean?,
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ class AppPreferencesSerializer
|
||||||
AppPreference.RefreshRateSwitching.defaultValue
|
AppPreference.RefreshRateSwitching.defaultValue
|
||||||
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
|
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
|
||||||
cinemaMode = AppPreference.CinemaMode.defaultValue
|
cinemaMode = AppPreference.CinemaMode.defaultValue
|
||||||
|
dpadSeekMode = AppPreference.DpadSeekModePref.defaultValue
|
||||||
|
|
||||||
overrides =
|
overrides =
|
||||||
PlaybackOverrides
|
PlaybackOverrides
|
||||||
|
|
@ -174,6 +175,16 @@ class AppPreferencesSerializer
|
||||||
showLyrics = true
|
showLyrics = true
|
||||||
showAlbumArt = true
|
showAlbumArt = true
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
|
experimentalPreferences =
|
||||||
|
ExperimentalPreferences
|
||||||
|
.newBuilder()
|
||||||
|
.apply {
|
||||||
|
enabled = false
|
||||||
|
videoTunnelingEnabled = false
|
||||||
|
preferAc3Surround = ExperimentalPreference.PreferAc3ForSurround.defaultValue
|
||||||
|
disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue
|
||||||
|
}.build()
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
override suspend fun readFrom(input: InputStream): AppPreferences {
|
override suspend fun readFrom(input: InputStream): AppPreferences {
|
||||||
|
|
@ -252,6 +263,11 @@ inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder
|
||||||
musicPreferences = musicPreferences.toBuilder().apply(block).build()
|
musicPreferences = musicPreferences.toBuilder().apply(block).build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline fun AppPreferences.updateExperimentalPreferences(block: ExperimentalPreferences.Builder.() -> Unit): AppPreferences =
|
||||||
|
update {
|
||||||
|
experimentalPreferences = experimentalPreferences.toBuilder().apply(block).build()
|
||||||
|
}
|
||||||
|
|
||||||
fun SubtitlePreferences.Builder.resetSubtitles() {
|
fun SubtitlePreferences.Builder.resetSubtitles() {
|
||||||
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
|
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
|
||||||
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
|
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
package com.github.damontecres.wholphin.preferences
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
|
||||||
|
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||||
|
import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation
|
||||||
|
|
||||||
|
object ExperimentalPreference {
|
||||||
|
val Enable =
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.experimental_settings,
|
||||||
|
defaultValue = false,
|
||||||
|
getter = { it.experimentalPreferences.enabled },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateExperimentalPreferences { enabled = value }
|
||||||
|
},
|
||||||
|
summaryOn = R.string.enabled,
|
||||||
|
summaryOff = R.string.disabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
val ExperimentalSettings =
|
||||||
|
AppDestinationPreference<AppPreferences>(
|
||||||
|
title = R.string.experimental_settings,
|
||||||
|
destination = Destination.Settings(PreferenceScreenOption.EXPERIMENTAL),
|
||||||
|
)
|
||||||
|
|
||||||
|
val VideoTunneling =
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.video_tunneling,
|
||||||
|
defaultValue = false,
|
||||||
|
getter = { it.experimentalPreferences.videoTunnelingEnabled },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateExperimentalPreferences { videoTunnelingEnabled = value }
|
||||||
|
},
|
||||||
|
summaryOn = R.string.enabled,
|
||||||
|
summaryOff = R.string.disabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
val PreferAc3ForSurround =
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.prefer_ac3_for_surround,
|
||||||
|
defaultValue = false,
|
||||||
|
getter = { it.experimentalPreferences.preferAc3Surround },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateExperimentalPreferences {
|
||||||
|
preferAc3Surround = value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
summary = R.string.prefer_ac3_for_surround_summary,
|
||||||
|
validator = { prefs, value ->
|
||||||
|
prefs.playbackPreferences.overrides.let {
|
||||||
|
if (value && !it.ac3Supported) {
|
||||||
|
PreferenceValidation.Invalid("AC3 support is not enabled")
|
||||||
|
} else if (value && it.downmixStereo) {
|
||||||
|
PreferenceValidation.Invalid("Always downmixing to stereo")
|
||||||
|
} else {
|
||||||
|
PreferenceValidation.Valid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val DisableAudioOffload =
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.disable_audio_offload,
|
||||||
|
defaultValue = false,
|
||||||
|
getter = { it.experimentalPreferences.disableAudioOffload },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateExperimentalPreferences { disableAudioOffload = value }
|
||||||
|
},
|
||||||
|
summary = R.string.disable_audio_offload_summary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val experimentalPreferences =
|
||||||
|
buildList {
|
||||||
|
add(
|
||||||
|
PreferenceGroup(
|
||||||
|
title = R.string.experimental_settings,
|
||||||
|
preferences =
|
||||||
|
listOf(
|
||||||
|
ExperimentalPreference.VideoTunneling,
|
||||||
|
ExperimentalPreference.PreferAc3ForSurround,
|
||||||
|
ExperimentalPreference.DisableAudioOffload,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a value from [ExperimentalPreference] or null if not enabled
|
||||||
|
*/
|
||||||
|
fun <T> ExperimentalPreferences.get(block: ExperimentalPreferences.() -> T): T? = if (enabled) block.invoke(this) else null
|
||||||
|
|
||||||
|
fun ExperimentalPreferences.enabled(block: ExperimentalPreferences.() -> Boolean): Boolean = enabled && block.invoke(this)
|
||||||
|
|
@ -1,5 +1,19 @@
|
||||||
package com.github.damontecres.wholphin.preferences
|
package com.github.damontecres.wholphin.preferences
|
||||||
|
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.isDown
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.isUp
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
|
@ -10,3 +24,48 @@ val PlaybackPreferences.skipBackOnResume: Duration?
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds modifiers so that pressing Up on the first element scrolls to the bottom and vice versa
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Modifier.lazyListWrapScrolling(
|
||||||
|
state: LazyListState,
|
||||||
|
focused: Boolean,
|
||||||
|
isFirst: Boolean,
|
||||||
|
isLast: Boolean,
|
||||||
|
firstFocusRequester: FocusRequester,
|
||||||
|
lastFocusRequester: FocusRequester,
|
||||||
|
): Modifier {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
return this
|
||||||
|
.ifElse(
|
||||||
|
isFirst,
|
||||||
|
Modifier
|
||||||
|
.focusRequester(firstFocusRequester)
|
||||||
|
.onKeyEvent {
|
||||||
|
if (focused && isUp(it) && it.type == KeyEventType.KeyDown) {
|
||||||
|
scope.launch {
|
||||||
|
state.animateScrollToItem(state.layoutInfo.totalItemsCount - 1)
|
||||||
|
lastFocusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
return@onKeyEvent true
|
||||||
|
}
|
||||||
|
false
|
||||||
|
},
|
||||||
|
).ifElse(
|
||||||
|
isLast,
|
||||||
|
Modifier
|
||||||
|
.focusRequester(lastFocusRequester)
|
||||||
|
.onKeyEvent {
|
||||||
|
if (focused && isDown(it) && it.type == KeyEventType.KeyDown) {
|
||||||
|
scope.launch {
|
||||||
|
state.animateScrollToItem(0)
|
||||||
|
firstFocusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
return@onKeyEvent true
|
||||||
|
}
|
||||||
|
false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,37 @@ object ScreensaverPreference {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val DimToggle =
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.dim_after_delay,
|
||||||
|
defaultValue = AppPreference.ShowClock.defaultValue,
|
||||||
|
getter = { it.interfacePreferences.screensaverPreference.dimEnabled },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateScreensaverPreferences { dimEnabled = value }
|
||||||
|
},
|
||||||
|
summaryOn = R.string.yes,
|
||||||
|
summaryOff = R.string.no,
|
||||||
|
)
|
||||||
|
|
||||||
|
val DimPercentage =
|
||||||
|
AppSliderPreference<AppPreferences>(
|
||||||
|
title = R.string.dim_percentage,
|
||||||
|
defaultValue = 75,
|
||||||
|
min = 0,
|
||||||
|
max = 95,
|
||||||
|
interval = 5,
|
||||||
|
getter = {
|
||||||
|
it.interfacePreferences.screensaverPreference.dimPercent
|
||||||
|
.toLong()
|
||||||
|
},
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateScreensaverPreferences { dimPercent = value.toInt() }
|
||||||
|
},
|
||||||
|
summarizer = { value ->
|
||||||
|
value?.let { "$value%" }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
val Start =
|
val Start =
|
||||||
AppClickablePreference<AppPreferences>(
|
AppClickablePreference<AppPreferences>(
|
||||||
title = R.string.start_screensaver,
|
title = R.string.start_screensaver,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.preferences
|
package com.github.damontecres.wholphin.preferences
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
|
||||||
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
||||||
import org.jellyfin.sdk.model.api.UserConfiguration
|
import org.jellyfin.sdk.model.api.UserConfiguration
|
||||||
|
|
||||||
|
|
@ -8,6 +9,7 @@ import org.jellyfin.sdk.model.api.UserConfiguration
|
||||||
*/
|
*/
|
||||||
data class UserPreferences(
|
data class UserPreferences(
|
||||||
val appPreferences: AppPreferences,
|
val appPreferences: AppPreferences,
|
||||||
|
val userPreferences: JellyfinUserPreferences?,
|
||||||
)
|
)
|
||||||
|
|
||||||
val DefaultUserConfiguration =
|
val DefaultUserConfiguration =
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
package com.github.damontecres.wholphin.preferences
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
|
||||||
|
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents settings from the user's profile on the server that may be overridden within the app
|
||||||
|
*/
|
||||||
|
object UserProfileSettings {
|
||||||
|
/**
|
||||||
|
* Special value that means the preferred language should be taken from the user's profile on the server
|
||||||
|
*/
|
||||||
|
const val USE_USER_PROFILE = ""
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Special value that means the user has no preferred language
|
||||||
|
*/
|
||||||
|
const val PREFER_ANY_LANGUAGE = "_any-language"
|
||||||
|
|
||||||
|
val PreferredAudioLang =
|
||||||
|
AppClickablePreference<JellyfinUserPreferences>(
|
||||||
|
title = R.string.preferred_audio_language,
|
||||||
|
getter = { },
|
||||||
|
setter = { prefs, _ -> prefs },
|
||||||
|
)
|
||||||
|
|
||||||
|
val PreferredSubtitleLang =
|
||||||
|
AppClickablePreference<JellyfinUserPreferences>(
|
||||||
|
title = R.string.preferred_subtitle_language,
|
||||||
|
summary = null,
|
||||||
|
getter = { },
|
||||||
|
setter = { prefs, _ -> prefs },
|
||||||
|
)
|
||||||
|
|
||||||
|
val SubtitleModePref =
|
||||||
|
AppChoicePreference<JellyfinUserPreferences, SubtitleModePreference>(
|
||||||
|
title = R.string.subtitle_mode,
|
||||||
|
defaultValue = SubtitleModePreference.USE_USER_PROFILE,
|
||||||
|
getter = { it.subtitleMode },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.copy(subtitleMode = value)
|
||||||
|
},
|
||||||
|
displayValues = R.array.subtitle_mode_options,
|
||||||
|
indexToValue = { SubtitleModePreference.entries[it] },
|
||||||
|
valueToIndex = { it.ordinal },
|
||||||
|
)
|
||||||
|
|
||||||
|
val Preferences =
|
||||||
|
listOf(
|
||||||
|
PreferenceGroup(
|
||||||
|
title = R.string.profile_specific_settings_from_server,
|
||||||
|
preferences =
|
||||||
|
listOf(
|
||||||
|
PreferredAudioLang,
|
||||||
|
PreferredSubtitleLang,
|
||||||
|
SubtitleModePref,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SubtitleModePreference {
|
||||||
|
USE_USER_PROFILE,
|
||||||
|
DEFAULT,
|
||||||
|
SMART,
|
||||||
|
ONLY_FORCED,
|
||||||
|
ALWAYS,
|
||||||
|
NONE,
|
||||||
|
}
|
||||||
|
|
@ -6,12 +6,18 @@ import android.os.Build
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
|
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
||||||
|
import com.github.damontecres.wholphin.data.RememberedTabDao
|
||||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.data.model.RememberedTab
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.ExperimentalPreference
|
||||||
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
|
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
|
||||||
import com.github.damontecres.wholphin.preferences.update
|
import com.github.damontecres.wholphin.preferences.update
|
||||||
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
|
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.updateExperimentalPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
|
import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
|
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
|
||||||
|
|
@ -25,12 +31,16 @@ import com.github.damontecres.wholphin.preferences.updateSearchPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
||||||
|
import com.github.damontecres.wholphin.ui.preferences.subtitle.shouldEnableSeparateHdrToggle
|
||||||
import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl
|
import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl
|
||||||
import com.github.damontecres.wholphin.util.Version
|
import com.github.damontecres.wholphin.util.Version
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -44,6 +54,8 @@ class AppUpgradeHandler
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val appPreferences: DataStore<AppPreferences>,
|
private val appPreferences: DataStore<AppPreferences>,
|
||||||
private val seerrServerDao: SeerrServerDao,
|
private val seerrServerDao: SeerrServerDao,
|
||||||
|
private val serverDao: JellyfinServerDao,
|
||||||
|
private val rememberedTabDao: RememberedTabDao,
|
||||||
) {
|
) {
|
||||||
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
|
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
|
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
|
||||||
|
|
@ -350,5 +362,85 @@ class AppUpgradeHandler
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (previous.isEqualOrBefore(Version.fromString("1.0.1-2-g0"))) {
|
||||||
|
Timber.i("Starting tab migration")
|
||||||
|
val users = mutableMapOf<UUID, JellyfinUser?>()
|
||||||
|
val tabs =
|
||||||
|
appPreferences.data.first().interfacePreferences.rememberedTabsMap.mapNotNull { (key, tabIndex) ->
|
||||||
|
// "serverId_userId_itemId", itemId can have underscores
|
||||||
|
try {
|
||||||
|
val firstUnderScore = key.indexOf("_").takeIf { it >= 1 }
|
||||||
|
if (firstUnderScore != null) {
|
||||||
|
val secondUnderscore =
|
||||||
|
key
|
||||||
|
.indexOf("_", startIndex = firstUnderScore + 1)
|
||||||
|
.takeIf { it >= 1 }
|
||||||
|
if (secondUnderscore != null) {
|
||||||
|
val serverId = key.substring(0, firstUnderScore).toUUIDOrNull()
|
||||||
|
val userId =
|
||||||
|
key
|
||||||
|
.substring(firstUnderScore + 1, secondUnderscore)
|
||||||
|
.toUUIDOrNull()
|
||||||
|
val itemId = key.substring(secondUnderscore + 1, key.length)
|
||||||
|
if (userId != null && serverId != null) {
|
||||||
|
return@mapNotNull users
|
||||||
|
.getOrPut(userId) {
|
||||||
|
serverDao.getUser(serverId, userId)
|
||||||
|
}?.let {
|
||||||
|
RememberedTab(it.rowId, itemId, tabIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.w(ex, "Error parsing '%s'", key)
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
Timber.v("Migrating %s tabs", tabs.size)
|
||||||
|
try {
|
||||||
|
if (tabs.isNotEmpty()) {
|
||||||
|
rememberedTabDao.saveAll(tabs)
|
||||||
|
}
|
||||||
|
appPreferences.updateData {
|
||||||
|
it.updateInterfacePreferences {
|
||||||
|
clearRememberedTabs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error saving migrated tabs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previous.isEqualOrBefore(Version.fromString("1.0.2-8-g0"))) {
|
||||||
|
appPreferences.updateData {
|
||||||
|
it.updateSubtitlePreferences {
|
||||||
|
useSeparateHdr = it.interfacePreferences.shouldEnableSeparateHdrToggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previous.isEqualOrBefore(Version.fromString("1.0.6-0-g0"))) {
|
||||||
|
appPreferences.updateData {
|
||||||
|
it.updateScreensaverPreferences {
|
||||||
|
dimPercent = ScreensaverPreference.DimPercentage.defaultValue.toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previous.isEqualOrBefore(Version.fromString("1.0.6-7-g0"))) {
|
||||||
|
// preferAc3Surround was mistaken enabled by default, reset it only if the user hasn't enabled experimental settings
|
||||||
|
appPreferences.updateData {
|
||||||
|
if (!it.experimentalPreferences.enabled) {
|
||||||
|
it.updateExperimentalPreferences {
|
||||||
|
preferAc3Surround =
|
||||||
|
ExperimentalPreference.PreferAc3ForSurround.defaultValue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.BackdropStyle
|
import com.github.damontecres.wholphin.preferences.BackdropStyle
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -55,7 +55,7 @@ class BackdropService
|
||||||
* Update the backdrop to use the specified item
|
* Update the backdrop to use the specified item
|
||||||
*/
|
*/
|
||||||
suspend fun submit(item: BaseItem) =
|
suspend fun submit(item: BaseItem) =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(WholphinDispatchers.IO) {
|
||||||
val imageUrl =
|
val imageUrl =
|
||||||
if (item.type == BaseItemKind.GENRE) {
|
if (item.type == BaseItemKind.GENRE) {
|
||||||
item.imageUrlOverride
|
item.imageUrlOverride
|
||||||
|
|
@ -76,7 +76,7 @@ class BackdropService
|
||||||
suspend fun submit(
|
suspend fun submit(
|
||||||
itemId: String,
|
itemId: String,
|
||||||
imageUrl: String?,
|
imageUrl: String?,
|
||||||
) = withContext(Dispatchers.IO) {
|
) = withContext(WholphinDispatchers.IO) {
|
||||||
if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) {
|
if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) {
|
||||||
_backdropFlow.update {
|
_backdropFlow.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|
@ -132,7 +132,7 @@ class BackdropService
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
|
suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(WholphinDispatchers.IO) {
|
||||||
if (imageUrl.isNullOrBlank()) {
|
if (imageUrl.isNullOrBlank()) {
|
||||||
return@withContext ExtractedColors.DEFAULT
|
return@withContext ExtractedColors.DEFAULT
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,12 @@ import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||||
import com.github.damontecres.wholphin.ui.collectLatestIn
|
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||||
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import com.google.common.cache.CacheBuilder
|
import com.google.common.cache.CacheBuilder
|
||||||
import dagger.hilt.android.qualifiers.ActivityContext
|
import dagger.hilt.android.qualifiers.ActivityContext
|
||||||
import dagger.hilt.android.scopes.ActivityScoped
|
import dagger.hilt.android.scopes.ActivityScoped
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
@ -56,7 +56,7 @@ class DatePlayedService
|
||||||
adjacentTo = item.id,
|
adjacentTo = item.id,
|
||||||
limit = 1,
|
limit = 1,
|
||||||
)
|
)
|
||||||
return scope.async(Dispatchers.IO) {
|
return scope.async(WholphinDispatchers.IO) {
|
||||||
val premiereDate = item.data.premiereDate
|
val premiereDate = item.data.premiereDate
|
||||||
try {
|
try {
|
||||||
val result =
|
val result =
|
||||||
|
|
@ -92,7 +92,7 @@ class DatePlayedService
|
||||||
* the previous episode's last played timestamp, and the episode's premiere date
|
* the previous episode's last played timestamp, and the episode's premiere date
|
||||||
*/
|
*/
|
||||||
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? =
|
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(WholphinDispatchers.IO) {
|
||||||
val seriesId = item.data.seriesId
|
val seriesId = item.data.seriesId
|
||||||
return@withContext if (seriesId != null) {
|
return@withContext if (seriesId != null) {
|
||||||
datePlayedCache
|
datePlayedCache
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
|
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
|
||||||
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
|
import com.github.damontecres.wholphin.preferences.ExperimentalPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.PlaybackOverrides
|
||||||
|
import com.github.damontecres.wholphin.preferences.enabled
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
|
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
|
||||||
import com.github.damontecres.wholphin.util.profile.createDeviceProfile
|
import com.github.damontecres.wholphin.util.profile.createDeviceProfile
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
@ -34,20 +37,17 @@ class DeviceProfileService
|
||||||
private var deviceProfile: DeviceProfile? = null
|
private var deviceProfile: DeviceProfile? = null
|
||||||
|
|
||||||
suspend fun getOrCreateDeviceProfile(
|
suspend fun getOrCreateDeviceProfile(
|
||||||
prefs: PlaybackPreferences,
|
appPrefs: AppPreferences,
|
||||||
serverVersion: ServerVersion?,
|
serverVersion: ServerVersion?,
|
||||||
): DeviceProfile =
|
): DeviceProfile =
|
||||||
withContext(Dispatchers.Default) {
|
withContext(WholphinDispatchers.Default) {
|
||||||
|
val prefs = appPrefs.playbackPreferences
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val newConfig =
|
val newConfig =
|
||||||
DeviceProfileConfiguration(
|
DeviceProfileConfiguration(
|
||||||
maxBitrate = prefs.maxBitrate.toInt(),
|
maxBitrate = prefs.maxBitrate.toInt(),
|
||||||
isAC3Enabled = prefs.overrides.ac3Supported,
|
overrides = prefs.overrides,
|
||||||
downMixAudio = prefs.overrides.downmixStereo,
|
experimental = appPrefs.experimentalPreferences,
|
||||||
assPlaybackMode = prefs.overrides.assPlaybackMode,
|
|
||||||
pgsDirectPlay = prefs.overrides.directPlayPgs,
|
|
||||||
dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL,
|
|
||||||
decodeAv1 = prefs.overrides.decodeAv1,
|
|
||||||
jellyfinTenEleven =
|
jellyfinTenEleven =
|
||||||
serverVersion != null && serverVersion >= ServerVersion(10, 11, 0),
|
serverVersion != null && serverVersion >= ServerVersion(10, 11, 0),
|
||||||
)
|
)
|
||||||
|
|
@ -57,12 +57,13 @@ class DeviceProfileService
|
||||||
createDeviceProfile(
|
createDeviceProfile(
|
||||||
mediaTest = mediaCodecCapabilitiesTest,
|
mediaTest = mediaCodecCapabilitiesTest,
|
||||||
maxBitrate = newConfig.maxBitrate,
|
maxBitrate = newConfig.maxBitrate,
|
||||||
isAC3Enabled = newConfig.isAC3Enabled,
|
isAC3Enabled = newConfig.overrides.ac3Supported,
|
||||||
downMixAudio = newConfig.downMixAudio,
|
downMixAudio = newConfig.overrides.downmixStereo,
|
||||||
assDirectPlay = newConfig.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
|
assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
|
||||||
pgsDirectPlay = newConfig.pgsDirectPlay,
|
pgsDirectPlay = newConfig.overrides.directPlayPgs,
|
||||||
dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay,
|
dolbyVisionELDirectPlay = newConfig.overrides.directPlayDolbyVisionEL,
|
||||||
decodeAv1 = prefs.overrides.decodeAv1,
|
decodeAv1 = prefs.overrides.decodeAv1,
|
||||||
|
preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround },
|
||||||
jellyfinTenEleven = newConfig.jellyfinTenEleven,
|
jellyfinTenEleven = newConfig.jellyfinTenEleven,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -76,11 +77,7 @@ class DeviceProfileService
|
||||||
*/
|
*/
|
||||||
data class DeviceProfileConfiguration(
|
data class DeviceProfileConfiguration(
|
||||||
val maxBitrate: Int,
|
val maxBitrate: Int,
|
||||||
val isAC3Enabled: Boolean,
|
val overrides: PlaybackOverrides,
|
||||||
val downMixAudio: Boolean,
|
val experimental: ExperimentalPreferences,
|
||||||
val assPlaybackMode: AssPlaybackMode,
|
|
||||||
val pgsDirectPlay: Boolean,
|
|
||||||
val dolbyVisionELDirectPlay: Boolean,
|
|
||||||
val decodeAv1: Boolean,
|
|
||||||
val jellyfinTenEleven: Boolean,
|
val jellyfinTenEleven: Boolean,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.ExtrasItem
|
import com.github.damontecres.wholphin.data.ExtrasItem
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.pluralRes
|
import com.github.damontecres.wholphin.data.pluralRes
|
||||||
|
import com.github.damontecres.wholphin.data.stringRes
|
||||||
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
|
@ -48,9 +50,10 @@ class ExtrasService
|
||||||
if (items.size == 1) {
|
if (items.size == 1) {
|
||||||
val item = items.first()
|
val item = items.first()
|
||||||
val title =
|
val title =
|
||||||
item.title ?: context.resources.getQuantityString(type.pluralRes, 1)
|
item.title.takeIf { it.isNotNullOrBlank() }
|
||||||
|
?: context.resources.getQuantityString(type.pluralRes, 1)
|
||||||
val subtitle =
|
val subtitle =
|
||||||
if (item.title == null) {
|
if (item.title.isNotNullOrBlank()) {
|
||||||
context.resources.getQuantityString(type.pluralRes, 1)
|
context.resources.getQuantityString(type.pluralRes, 1)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
|
|
@ -64,14 +67,14 @@ class ExtrasService
|
||||||
imageUrl = imageUrlService.getItemImageUrl(item, ImageType.PRIMARY),
|
imageUrl = imageUrlService.getItemImageUrl(item, ImageType.PRIMARY),
|
||||||
)
|
)
|
||||||
} else if (items.size > 1) {
|
} else if (items.size > 1) {
|
||||||
val title =
|
val title = context.resources.getString(type.stringRes)
|
||||||
context.resources.getQuantityString(type.pluralRes, items.size)
|
|
||||||
val subtitle =
|
val subtitle =
|
||||||
context.resources.getQuantityString(
|
context.resources.getQuantityString(
|
||||||
R.plurals.items,
|
R.plurals.items,
|
||||||
items.size,
|
items.size,
|
||||||
items.size,
|
items.size,
|
||||||
)
|
)
|
||||||
|
val itemForImage = items.random()
|
||||||
ExtrasItem.Group(
|
ExtrasItem.Group(
|
||||||
parentId = itemId,
|
parentId = itemId,
|
||||||
type = type,
|
type = type,
|
||||||
|
|
@ -81,9 +84,10 @@ class ExtrasService
|
||||||
isPlayed = items.all { it.played },
|
isPlayed = items.all { it.played },
|
||||||
imageUrl =
|
imageUrl =
|
||||||
imageUrlService.getItemImageUrl(
|
imageUrlService.getItemImageUrl(
|
||||||
items.random(),
|
itemForImage,
|
||||||
ImageType.PRIMARY,
|
ImageType.PRIMARY,
|
||||||
),
|
),
|
||||||
|
aspectRatio = itemForImage.aspectRatio,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.TvShowStatus
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter
|
||||||
|
import com.github.damontecres.wholphin.data.filter.DecadeFilter
|
||||||
|
import com.github.damontecres.wholphin.data.filter.DiscoverFilterBy
|
||||||
|
import com.github.damontecres.wholphin.data.filter.DiscoverMovieContentRatingFilter
|
||||||
|
import com.github.damontecres.wholphin.data.filter.DiscoverMovieGenreFilter
|
||||||
|
import com.github.damontecres.wholphin.data.filter.DiscoverMovieStudiosFilter
|
||||||
|
import com.github.damontecres.wholphin.data.filter.DiscoverRatingFilter
|
||||||
|
import com.github.damontecres.wholphin.data.filter.DiscoverTvContentRatingFilter
|
||||||
|
import com.github.damontecres.wholphin.data.filter.DiscoverTvGenreFilter
|
||||||
|
import com.github.damontecres.wholphin.data.filter.DiscoverTvStatusFilter
|
||||||
|
import com.github.damontecres.wholphin.data.filter.DiscoverTvStudiosFilter
|
||||||
|
import com.github.damontecres.wholphin.data.filter.FavoriteFilter
|
||||||
|
import com.github.damontecres.wholphin.data.filter.FilterBy
|
||||||
|
import com.github.damontecres.wholphin.data.filter.FilterValueOption
|
||||||
|
import com.github.damontecres.wholphin.data.filter.FilterVideoType
|
||||||
|
import com.github.damontecres.wholphin.data.filter.GenreFilter
|
||||||
|
import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter
|
||||||
|
import com.github.damontecres.wholphin.data.filter.PlayedFilter
|
||||||
|
import com.github.damontecres.wholphin.data.filter.StudioFilter
|
||||||
|
import com.github.damontecres.wholphin.data.filter.VideoTypeFilter
|
||||||
|
import com.github.damontecres.wholphin.data.filter.YearFilter
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||||
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
|
import com.mayakapps.kache.InMemoryKache
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.filterApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.genresApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.localizationApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.studiosApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.yearsApi
|
||||||
|
import org.jellyfin.sdk.model.UUID
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.TreeSet
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the possible values for filters in a library
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class FilterOptionCache
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
@param:IoCoroutineScope private val ioCoroutineScope: CoroutineScope,
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
|
private val seerrService: SeerrService,
|
||||||
|
) {
|
||||||
|
private val cache =
|
||||||
|
InMemoryKache<FilterOptionCacheKey, List<FilterValueOption>>(16) {
|
||||||
|
creationScope = ioCoroutineScope
|
||||||
|
expireAfterWriteDuration = 1.hours
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the possible values for a filter
|
||||||
|
*
|
||||||
|
* For example, the possible genres in the parent ID
|
||||||
|
*/
|
||||||
|
suspend fun getFilterOptionValues(
|
||||||
|
parentId: UUID?,
|
||||||
|
filterOption: FilterBy<*, *>,
|
||||||
|
): List<FilterValueOption> {
|
||||||
|
val cacheKey = FilterOptionCacheKey(serverRepository.currentUser?.id, parentId, filterOption)
|
||||||
|
return try {
|
||||||
|
cache
|
||||||
|
.getOrPut(cacheKey) { (userId, parentId, filterOption) ->
|
||||||
|
getFilterOptionValues(userId, parentId, filterOption)
|
||||||
|
}.orEmpty()
|
||||||
|
} catch (ex: CancellationException) {
|
||||||
|
throw ex
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error fetching options for %s", filterOption)
|
||||||
|
showToast(context, "Error occurred: ${ex.localizedMessage}")
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getFilterOptionValues(
|
||||||
|
userId: UUID?,
|
||||||
|
parentId: UUID?,
|
||||||
|
filterOption: FilterBy<*, *>,
|
||||||
|
) = when (filterOption) {
|
||||||
|
GenreFilter -> {
|
||||||
|
api.genresApi
|
||||||
|
.getGenres(
|
||||||
|
parentId = parentId,
|
||||||
|
userId = userId,
|
||||||
|
).content.items
|
||||||
|
.map { FilterValueOption(it.name ?: "", it.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
StudioFilter -> {
|
||||||
|
api.studiosApi
|
||||||
|
.getStudios(
|
||||||
|
parentId = parentId,
|
||||||
|
userId = userId,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.SERIES),
|
||||||
|
).content.items
|
||||||
|
.map { FilterValueOption(it.name ?: "", it.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
FavoriteFilter,
|
||||||
|
PlayedFilter,
|
||||||
|
-> {
|
||||||
|
listOf(
|
||||||
|
FilterValueOption("True", null),
|
||||||
|
FilterValueOption("False", null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OfficialRatingFilter -> {
|
||||||
|
val ratings =
|
||||||
|
api.localizationApi
|
||||||
|
.getParentalRatings()
|
||||||
|
.content
|
||||||
|
.associate {
|
||||||
|
it.name to it.value
|
||||||
|
}
|
||||||
|
api.filterApi
|
||||||
|
.getQueryFiltersLegacy(
|
||||||
|
parentId = parentId,
|
||||||
|
userId = userId,
|
||||||
|
).content.officialRatings
|
||||||
|
?.map { r ->
|
||||||
|
val value = ratings[r] ?: 0
|
||||||
|
FilterValueOption(r, value)
|
||||||
|
}?.sortedBy { it.value as Int }
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoTypeFilter -> {
|
||||||
|
FilterVideoType.entries.map {
|
||||||
|
FilterValueOption(it.readable, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
YearFilter -> {
|
||||||
|
api.yearsApi
|
||||||
|
.getYears(
|
||||||
|
parentId = parentId,
|
||||||
|
userId = userId,
|
||||||
|
sortBy = listOf(ItemSortBy.SORT_NAME),
|
||||||
|
sortOrder = listOf(SortOrder.ASCENDING),
|
||||||
|
).content.items
|
||||||
|
.mapNotNull {
|
||||||
|
it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DecadeFilter -> {
|
||||||
|
val items = TreeSet<Int>()
|
||||||
|
api.yearsApi
|
||||||
|
.getYears(
|
||||||
|
parentId = parentId,
|
||||||
|
userId = userId,
|
||||||
|
sortBy = listOf(ItemSortBy.SORT_NAME),
|
||||||
|
sortOrder = listOf(SortOrder.ASCENDING),
|
||||||
|
).content.items
|
||||||
|
.mapNotNullTo(items) {
|
||||||
|
it.name
|
||||||
|
?.toIntOrNull()
|
||||||
|
?.div(10)
|
||||||
|
?.times(10)
|
||||||
|
}
|
||||||
|
items.toList().sorted().map { FilterValueOption("$it's", it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
CommunityRatingFilter -> {
|
||||||
|
(1..10).map {
|
||||||
|
FilterValueOption("$it", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is DiscoverFilterBy -> {
|
||||||
|
getDiscoverFilterOptionValues(filterOption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getDiscoverFilterOptionValues(filterOption: DiscoverFilterBy<*>): List<FilterValueOption> {
|
||||||
|
if (seerrService.active.firstOrNull() != true) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
return when (filterOption) {
|
||||||
|
DiscoverMovieGenreFilter -> {
|
||||||
|
seerrService.api.searchApi
|
||||||
|
.discoverGenresliderMovieGet()
|
||||||
|
.filter { it.name != null && it.id != null }
|
||||||
|
.map {
|
||||||
|
FilterValueOption(it.name!!, it.id!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DiscoverTvGenreFilter -> {
|
||||||
|
seerrService.api.searchApi
|
||||||
|
.discoverGenresliderTvGet()
|
||||||
|
.filter { it.name != null && it.id != null }
|
||||||
|
.map {
|
||||||
|
FilterValueOption(it.name!!, it.id!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DiscoverMovieStudiosFilter -> {
|
||||||
|
// TODO region
|
||||||
|
seerrService.api.otherApi
|
||||||
|
.watchprovidersMoviesGet("US")
|
||||||
|
.filter { it.name != null && it.id != null }
|
||||||
|
// TODO not what this is for
|
||||||
|
// .sortedBy { it.displayPriority }
|
||||||
|
.map {
|
||||||
|
// TODO logo?
|
||||||
|
FilterValueOption(it.name!!, it.id!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DiscoverTvStudiosFilter -> {
|
||||||
|
// TODO region
|
||||||
|
seerrService.api.otherApi
|
||||||
|
.watchprovidersTvGet("US")
|
||||||
|
.filter { it.name != null && it.id != null }
|
||||||
|
// .sortedBy { it.displayPriority }
|
||||||
|
.map {
|
||||||
|
FilterValueOption(it.name!!, it.id!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DiscoverTvStatusFilter -> {
|
||||||
|
TvShowStatus.entries.map {
|
||||||
|
FilterValueOption(it.value, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DiscoverMovieContentRatingFilter -> {
|
||||||
|
listOf("NR", "G", "PG", "PG-13", "R", "NC-17")
|
||||||
|
.map { FilterValueOption(it, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
DiscoverTvContentRatingFilter -> {
|
||||||
|
listOf("NR", "TV-Y", "TV-Y7", "TV-G", "TV-PG", "TV-14", "TV-MA")
|
||||||
|
.map { FilterValueOption(it, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
DiscoverRatingFilter -> {
|
||||||
|
(1..10).map {
|
||||||
|
FilterValueOption("$it", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class FilterOptionCacheKey(
|
||||||
|
val userId: UUID?,
|
||||||
|
val parentId: UUID?,
|
||||||
|
val filterOption: FilterBy<*, *>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -7,16 +7,19 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.HomePageSettings
|
import com.github.damontecres.wholphin.data.model.HomePageSettings
|
||||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||||
|
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||||
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
|
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
|
||||||
import com.github.damontecres.wholphin.data.model.createGenreDestination
|
import com.github.damontecres.wholphin.data.model.createGenreDestination
|
||||||
import com.github.damontecres.wholphin.data.model.createStudioDestination
|
import com.github.damontecres.wholphin.data.model.createStudioDestination
|
||||||
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
|
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
|
||||||
import com.github.damontecres.wholphin.preferences.HomePagePreferences
|
import com.github.damontecres.wholphin.preferences.HomePagePreferences
|
||||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
import com.github.damontecres.wholphin.ui.AspectRatio
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
|
import com.github.damontecres.wholphin.ui.HomeItemFields
|
||||||
|
import com.github.damontecres.wholphin.ui.ProgramItemFields
|
||||||
import com.github.damontecres.wholphin.ui.components.getGenreImageMap
|
import com.github.damontecres.wholphin.ui.components.getGenreImageMap
|
||||||
|
import com.github.damontecres.wholphin.ui.formatTypeName
|
||||||
import com.github.damontecres.wholphin.ui.main.settings.Library
|
import com.github.damontecres.wholphin.ui.main.settings.Library
|
||||||
import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions
|
|
||||||
import com.github.damontecres.wholphin.ui.playback.getTypeFor
|
import com.github.damontecres.wholphin.ui.playback.getTypeFor
|
||||||
import com.github.damontecres.wholphin.ui.toBaseItems
|
import com.github.damontecres.wholphin.ui.toBaseItems
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
|
|
@ -26,15 +29,16 @@ import com.github.damontecres.wholphin.ui.util.ResStringProvider
|
||||||
import com.github.damontecres.wholphin.ui.util.StringProvider
|
import com.github.damontecres.wholphin.ui.util.StringProvider
|
||||||
import com.github.damontecres.wholphin.ui.util.StringStringProvider
|
import com.github.damontecres.wholphin.ui.util.StringStringProvider
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
|
import com.github.damontecres.wholphin.util.GetArtistsHandler
|
||||||
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler
|
import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
||||||
|
import com.github.damontecres.wholphin.util.GetProgramsDtoHandler
|
||||||
import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler
|
import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
|
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
|
||||||
import com.github.damontecres.wholphin.util.supportedHomeCollectionTypes
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -53,20 +57,22 @@ import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
import org.jellyfin.sdk.model.DateTime
|
||||||
import org.jellyfin.sdk.model.UUID
|
import org.jellyfin.sdk.model.UUID
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
import org.jellyfin.sdk.model.api.GetProgramsDto
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.api.UserDto
|
import org.jellyfin.sdk.model.api.UserDto
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
|
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest
|
|
||||||
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
|
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
|
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -239,7 +245,7 @@ class HomeSettingsService
|
||||||
settings.rows.mapIndexed { index, config ->
|
settings.rows.mapIndexed { index, config ->
|
||||||
resolve(index, config)
|
resolve(index, config)
|
||||||
}
|
}
|
||||||
HomePageResolvedSettings(resolvedRows)
|
HomePageResolvedSettings(userId, resolvedRows)
|
||||||
} else {
|
} else {
|
||||||
createDefault(userId)
|
createDefault(userId)
|
||||||
}
|
}
|
||||||
|
|
@ -250,12 +256,15 @@ class HomeSettingsService
|
||||||
/**
|
/**
|
||||||
* Resolve the settings and set them to be the current settings
|
* Resolve the settings and set them to be the current settings
|
||||||
*/
|
*/
|
||||||
suspend fun updateCurrent(settings: HomePageSettings) {
|
suspend fun updateCurrent(
|
||||||
|
userId: UUID,
|
||||||
|
settings: HomePageSettings,
|
||||||
|
) {
|
||||||
val resolvedRows =
|
val resolvedRows =
|
||||||
settings.rows.mapIndexed { index, config ->
|
settings.rows.mapIndexed { index, config ->
|
||||||
resolve(index, config)
|
resolve(index, config)
|
||||||
}
|
}
|
||||||
val resolvedSettings = HomePageResolvedSettings(resolvedRows)
|
val resolvedSettings = HomePageResolvedSettings(userId, resolvedRows)
|
||||||
currentSettings.update { resolvedSettings }
|
currentSettings.update { resolvedSettings }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,20 +284,25 @@ class HomeSettingsService
|
||||||
|
|
||||||
val includedIds =
|
val includedIds =
|
||||||
libraries
|
libraries
|
||||||
.mapIndexed { index, it ->
|
.mapIndexed { index, library ->
|
||||||
val parentId = it.itemId
|
val parentId = library.itemId
|
||||||
val title = getRecentlyAddedTitle(it)
|
val title = getRecentlyAddedTitle(library.name)
|
||||||
if (it.collectionType == CollectionType.LIVETV) {
|
if (library.collectionType == CollectionType.LIVETV) {
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id = index,
|
id = index,
|
||||||
title = ResStringProvider(R.string.live_tv),
|
title = ResStringProvider(R.string.watch_live),
|
||||||
config = HomeRowConfig.TvPrograms(),
|
config = HomeRowConfig.TvPrograms(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
val viewOptions = viewOptionsForCollectionType(library.collectionType)
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id = index,
|
id = index,
|
||||||
title = title,
|
title = title,
|
||||||
config = HomeRowConfig.RecentlyAdded(parentId),
|
config =
|
||||||
|
HomeRowConfig.RecentlyAdded(
|
||||||
|
parentId = parentId,
|
||||||
|
viewOptions = viewOptions,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -301,7 +315,7 @@ class HomeSettingsService
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val rowConfig = continueWatchingRow + includedIds
|
val rowConfig = continueWatchingRow + includedIds
|
||||||
return HomePageResolvedSettings(rowConfig)
|
return HomePageResolvedSettings(userId, rowConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -318,12 +332,10 @@ class HomeSettingsService
|
||||||
val userDto by api.userApi.getUserById(userId)
|
val userDto by api.userApi.getUserById(userId)
|
||||||
val config = userDto.configuration ?: DefaultUserConfiguration
|
val config = userDto.configuration ?: DefaultUserConfiguration
|
||||||
val libraries =
|
val libraries =
|
||||||
api.userViewsApi
|
navDrawerService
|
||||||
.getUserViews(userId = userId)
|
.getAllUserLibraries(userId, userDto.tvAccess)
|
||||||
.content.items
|
.filterNot {
|
||||||
.filter {
|
it.itemId in config.latestItemsExcludes
|
||||||
it.collectionType in supportedHomeCollectionTypes &&
|
|
||||||
it.id !in config.latestItemsExcludes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return if (customPrefs.isNotEmpty()) {
|
return if (customPrefs.isNotEmpty()) {
|
||||||
|
|
@ -367,7 +379,7 @@ class HomeSettingsService
|
||||||
if (userDto.tvAccess) {
|
if (userDto.tvAccess) {
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id = id++,
|
id = id++,
|
||||||
title = ResStringProvider(R.string.live_tv),
|
title = ResStringProvider(R.string.watch_live),
|
||||||
config = HomeRowConfig.TvPrograms(),
|
config = HomeRowConfig.TvPrograms(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -396,15 +408,21 @@ class HomeSettingsService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (sectionType == HomeSectionType.LATEST_MEDIA) {
|
if (sectionType == HomeSectionType.LATEST_MEDIA) {
|
||||||
libraries.map {
|
libraries.map { library ->
|
||||||
|
val viewOptions =
|
||||||
|
viewOptionsForCollectionType(library.collectionType)
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id = id++,
|
id = id++,
|
||||||
title =
|
title =
|
||||||
ResArgStringProvider(
|
ResArgStringProvider(
|
||||||
R.string.recently_added_in,
|
R.string.recently_added_in,
|
||||||
it.name ?: "",
|
library.name ?: "",
|
||||||
|
),
|
||||||
|
config =
|
||||||
|
HomeRowConfig.RecentlyAdded(
|
||||||
|
parentId = library.itemId,
|
||||||
|
viewOptions = viewOptions,
|
||||||
),
|
),
|
||||||
config = HomeRowConfig.RecentlyAdded(it.id),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (config != null) {
|
} else if (config != null) {
|
||||||
|
|
@ -413,7 +431,7 @@ class HomeSettingsService
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}.flatten()
|
}.flatten()
|
||||||
HomePageResolvedSettings(rowConfigs)
|
HomePageResolvedSettings(userId, rowConfigs)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
@ -503,8 +521,8 @@ class HomeSettingsService
|
||||||
is HomeRowConfig.Favorite -> {
|
is HomeRowConfig.Favorite -> {
|
||||||
val name =
|
val name =
|
||||||
ResProviderStringProvider(
|
ResProviderStringProvider(
|
||||||
R.string.favorite_items,
|
R.string.favorite_items_title,
|
||||||
ResStringProvider(favoriteOptions[config.kind]!!),
|
ResStringProvider(formatTypeName(config.kind)),
|
||||||
)
|
)
|
||||||
HomeRowConfigDisplay(id, name, config)
|
HomeRowConfigDisplay(id, name, config)
|
||||||
}
|
}
|
||||||
|
|
@ -520,7 +538,7 @@ class HomeSettingsService
|
||||||
is HomeRowConfig.TvPrograms -> {
|
is HomeRowConfig.TvPrograms -> {
|
||||||
HomeRowConfigDisplay(
|
HomeRowConfigDisplay(
|
||||||
id = id,
|
id = id,
|
||||||
title = ResStringProvider(R.string.live_tv),
|
title = ResStringProvider(R.string.watch_live),
|
||||||
config,
|
config,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -550,8 +568,10 @@ class HomeSettingsService
|
||||||
): StringProvider =
|
): StringProvider =
|
||||||
try {
|
try {
|
||||||
api.userLibraryApi
|
api.userLibraryApi
|
||||||
.getItem(itemId = itemId)
|
.getItem(
|
||||||
.content.name
|
userId = serverRepository.currentUser?.id,
|
||||||
|
itemId = itemId,
|
||||||
|
).content.name
|
||||||
?.let {
|
?.let {
|
||||||
if (stringRes == null) {
|
if (stringRes == null) {
|
||||||
StringStringProvider(it)
|
StringStringProvider(it)
|
||||||
|
|
@ -592,6 +612,7 @@ class HomeSettingsService
|
||||||
items = resume,
|
items = resume,
|
||||||
viewOptions = row.viewOptions,
|
viewOptions = row.viewOptions,
|
||||||
rowType = row,
|
rowType = row,
|
||||||
|
showViewMore = resume.size >= limit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -611,6 +632,7 @@ class HomeSettingsService
|
||||||
items = nextUp,
|
items = nextUp,
|
||||||
viewOptions = row.viewOptions,
|
viewOptions = row.viewOptions,
|
||||||
rowType = row,
|
rowType = row,
|
||||||
|
showViewMore = nextUp.size >= limit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -631,17 +653,14 @@ class HomeSettingsService
|
||||||
prefs.maxDaysNextUp,
|
prefs.maxDaysNextUp,
|
||||||
row.viewOptions.useSeries,
|
row.viewOptions.useSeries,
|
||||||
)
|
)
|
||||||
|
val combined = latestNextUpService.buildCombined(resume, nextUp)
|
||||||
|
|
||||||
Success(
|
Success(
|
||||||
title = ResStringProvider(R.string.continue_watching),
|
title = ResStringProvider(R.string.continue_watching),
|
||||||
items =
|
items = combined.take(limit),
|
||||||
latestNextUpService
|
|
||||||
.buildCombined(
|
|
||||||
resume,
|
|
||||||
nextUp,
|
|
||||||
).take(limit),
|
|
||||||
viewOptions = row.viewOptions,
|
viewOptions = row.viewOptions,
|
||||||
rowType = row,
|
rowType = row,
|
||||||
|
showViewMore = combined.size >= limit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -693,6 +712,9 @@ class HomeSettingsService
|
||||||
listOf(it)
|
listOf(it)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
collectionType =
|
||||||
|
library?.collectionType
|
||||||
|
?: CollectionType.UNKNOWN,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -702,6 +724,7 @@ class HomeSettingsService
|
||||||
genres,
|
genres,
|
||||||
viewOptions = row.viewOptions,
|
viewOptions = row.viewOptions,
|
||||||
rowType = row,
|
rowType = row,
|
||||||
|
showViewMore = genres.size >= limit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -753,17 +776,16 @@ class HomeSettingsService
|
||||||
title,
|
title,
|
||||||
studios,
|
studios,
|
||||||
viewOptions = row.viewOptions,
|
viewOptions = row.viewOptions,
|
||||||
|
showViewMore = studios.size >= limit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.RecentlyAdded -> {
|
is HomeRowConfig.RecentlyAdded -> {
|
||||||
val library =
|
val library = libraries.firstOrNull { it.itemId == row.parentId }
|
||||||
libraries
|
val title = getRecentlyAddedTitle(library?.name)
|
||||||
.firstOrNull { it.itemId == row.parentId }
|
|
||||||
val title = getRecentlyAddedTitle(library)
|
|
||||||
val request =
|
val request =
|
||||||
GetLatestMediaRequest(
|
GetLatestMediaRequest(
|
||||||
fields = SlimItemFields,
|
fields = library.itemFields,
|
||||||
imageTypeLimit = 1,
|
imageTypeLimit = 1,
|
||||||
parentId = row.parentId,
|
parentId = row.parentId,
|
||||||
groupItems = true,
|
groupItems = true,
|
||||||
|
|
@ -781,18 +803,16 @@ class HomeSettingsService
|
||||||
it,
|
it,
|
||||||
row.viewOptions,
|
row.viewOptions,
|
||||||
rowType = row,
|
rowType = row,
|
||||||
|
showViewMore = it.size >= limit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
latest
|
latest
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.RecentlyReleased -> {
|
is HomeRowConfig.RecentlyReleased -> {
|
||||||
val name =
|
val library = libraries.firstOrNull { it.itemId == row.parentId }
|
||||||
libraries
|
|
||||||
.firstOrNull { it.itemId == row.parentId }
|
|
||||||
?.name
|
|
||||||
val title =
|
val title =
|
||||||
name?.let {
|
library?.name?.let {
|
||||||
ResArgStringProvider(R.string.recently_released_in, it)
|
ResArgStringProvider(R.string.recently_released_in, it)
|
||||||
} ?: ResStringProvider(R.string.recently_released)
|
} ?: ResStringProvider(R.string.recently_released)
|
||||||
val request =
|
val request =
|
||||||
|
|
@ -811,7 +831,7 @@ class HomeSettingsService
|
||||||
SortOrder.ASCENDING,
|
SortOrder.ASCENDING,
|
||||||
SortOrder.DESCENDING,
|
SortOrder.DESCENDING,
|
||||||
),
|
),
|
||||||
fields = DefaultItemFields,
|
fields = library.itemFields,
|
||||||
recursive = true,
|
recursive = true,
|
||||||
maxPremiereDate = LocalDateTime.now(),
|
maxPremiereDate = LocalDateTime.now(),
|
||||||
isUnaired = false,
|
isUnaired = false,
|
||||||
|
|
@ -835,11 +855,13 @@ class HomeSettingsService
|
||||||
it,
|
it,
|
||||||
row.viewOptions,
|
row.viewOptions,
|
||||||
rowType = row,
|
rowType = row,
|
||||||
|
showViewMore = it.size >= limit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.ByParent -> {
|
is HomeRowConfig.ByParent -> {
|
||||||
|
val library = libraries.firstOrNull { it.itemId == row.parentId }
|
||||||
val request =
|
val request =
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
userId = userDto.id,
|
userId = userDto.id,
|
||||||
|
|
@ -848,27 +870,44 @@ class HomeSettingsService
|
||||||
sortBy =
|
sortBy =
|
||||||
row.sort?.let {
|
row.sort?.let {
|
||||||
buildList {
|
buildList {
|
||||||
|
if (it.sort == ItemSortBy.RANDOM) {
|
||||||
|
add(ItemSortBy.SORT_NAME)
|
||||||
|
add(ItemSortBy.RANDOM)
|
||||||
|
} else {
|
||||||
add(it.sort)
|
add(it.sort)
|
||||||
if (it.sort != ItemSortBy.SORT_NAME) {
|
if (it.sort != ItemSortBy.SORT_NAME) {
|
||||||
add(ItemSortBy.SORT_NAME)
|
add(ItemSortBy.SORT_NAME)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
sortOrder =
|
sortOrder =
|
||||||
row.sort?.let {
|
row.sort?.let {
|
||||||
buildList {
|
buildList {
|
||||||
|
if (it.sort == ItemSortBy.RANDOM) {
|
||||||
|
add(SortOrder.ASCENDING)
|
||||||
|
add(it.direction)
|
||||||
|
} else {
|
||||||
add(it.direction)
|
add(it.direction)
|
||||||
if (it.sort != ItemSortBy.SORT_NAME) {
|
if (it.sort != ItemSortBy.SORT_NAME) {
|
||||||
add(SortOrder.ASCENDING)
|
add(SortOrder.ASCENDING)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
limit = limit,
|
limit = limit,
|
||||||
fields = DefaultItemFields,
|
fields = library.itemFields,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Not using getItemName because we want to throw the 404
|
||||||
val title =
|
val title =
|
||||||
getItemName(null, row.parentId, ResStringProvider(R.string.collection))
|
api.userLibraryApi
|
||||||
|
.getItem(
|
||||||
|
userId = serverRepository.currentUser?.id,
|
||||||
|
itemId = row.parentId,
|
||||||
|
).content.name
|
||||||
|
?.let { StringStringProvider(it) }
|
||||||
|
?: ResStringProvider(R.string.collection)
|
||||||
if (usePaging) {
|
if (usePaging) {
|
||||||
ApiRequestPager(
|
ApiRequestPager(
|
||||||
api,
|
api,
|
||||||
|
|
@ -888,6 +927,7 @@ class HomeSettingsService
|
||||||
it,
|
it,
|
||||||
row.viewOptions,
|
row.viewOptions,
|
||||||
rowType = row,
|
rowType = row,
|
||||||
|
showViewMore = it.size >= limit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -925,6 +965,7 @@ class HomeSettingsService
|
||||||
it,
|
it,
|
||||||
row.viewOptions,
|
row.viewOptions,
|
||||||
rowType = row,
|
rowType = row,
|
||||||
|
showViewMore = it.size >= limit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -932,37 +973,61 @@ class HomeSettingsService
|
||||||
is HomeRowConfig.Favorite -> {
|
is HomeRowConfig.Favorite -> {
|
||||||
val title =
|
val title =
|
||||||
ResProviderStringProvider(
|
ResProviderStringProvider(
|
||||||
R.string.favorite_items,
|
R.string.favorite_items_title,
|
||||||
ResStringProvider(favoriteOptions[row.kind]!!),
|
ResStringProvider(formatTypeName(row.kind)),
|
||||||
)
|
)
|
||||||
if (row.kind == BaseItemKind.PERSON) {
|
val resultList =
|
||||||
|
when (row.kind) {
|
||||||
|
BaseItemKind.PERSON -> {
|
||||||
val request =
|
val request =
|
||||||
GetPersonsRequest(
|
GetPersonsRequest(
|
||||||
userId = userDto.id,
|
userId = userDto.id,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
fields = DefaultItemFields,
|
fields = HomeItemFields,
|
||||||
isFavorite = true,
|
isFavorite = true,
|
||||||
enableImages = true,
|
enableImages = true,
|
||||||
enableImageTypes = listOf(ImageType.PRIMARY),
|
enableImageTypes = listOf(ImageType.PRIMARY),
|
||||||
)
|
)
|
||||||
GetPersonsHandler
|
|
||||||
.execute(api, request)
|
GetPersonsHandler.execute(api, request).toBaseItems()
|
||||||
.content.items
|
|
||||||
.map { BaseItem(it, true) }
|
|
||||||
.let {
|
|
||||||
Success(
|
|
||||||
title,
|
|
||||||
it,
|
|
||||||
row.viewOptions,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BaseItemKind.MUSIC_ARTIST -> {
|
||||||
|
val request =
|
||||||
|
GetArtistsRequest(
|
||||||
|
userId = userDto.id,
|
||||||
|
limit = limit,
|
||||||
|
fields = HomeItemFields,
|
||||||
|
isFavorite = true,
|
||||||
|
)
|
||||||
|
if (usePaging) {
|
||||||
|
ApiRequestPager(
|
||||||
|
api,
|
||||||
|
request,
|
||||||
|
GetArtistsHandler,
|
||||||
|
scope,
|
||||||
|
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||||
|
).init()
|
||||||
} else {
|
} else {
|
||||||
|
GetArtistsHandler
|
||||||
|
.execute(api, request)
|
||||||
|
.toBaseItems(row.viewOptions.useSeries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val fields =
|
||||||
|
if (row.kind == BaseItemKind.BOX_SET) {
|
||||||
|
HomeItemFieldsBoxSets
|
||||||
|
} else {
|
||||||
|
HomeItemFields
|
||||||
|
}
|
||||||
val request =
|
val request =
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
userId = userDto.id,
|
userId = userDto.id,
|
||||||
recursive = true,
|
recursive = true,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
fields = DefaultItemFields,
|
fields = fields,
|
||||||
includeItemTypes = listOf(row.kind),
|
includeItemTypes = listOf(row.kind),
|
||||||
isFavorite = true,
|
isFavorite = true,
|
||||||
)
|
)
|
||||||
|
|
@ -977,25 +1042,27 @@ class HomeSettingsService
|
||||||
} else {
|
} else {
|
||||||
GetItemsRequestHandler
|
GetItemsRequestHandler
|
||||||
.execute(api, request)
|
.execute(api, request)
|
||||||
.content.items
|
.toBaseItems(row.viewOptions.useSeries)
|
||||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
}
|
||||||
}.let {
|
}
|
||||||
|
}
|
||||||
|
resultList.let {
|
||||||
Success(
|
Success(
|
||||||
title,
|
title,
|
||||||
it,
|
it,
|
||||||
row.viewOptions,
|
row.viewOptions,
|
||||||
rowType = row,
|
rowType = row,
|
||||||
|
showViewMore = it.size >= limit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
is HomeRowConfig.Recordings -> {
|
is HomeRowConfig.Recordings -> {
|
||||||
val request =
|
val request =
|
||||||
GetRecordingsRequest(
|
GetRecordingsRequest(
|
||||||
userId = userDto.id,
|
userId = userDto.id,
|
||||||
isInProgress = true,
|
isInProgress = true,
|
||||||
fields = DefaultItemFields,
|
fields = HomeItemFields,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
enableImages = true,
|
enableImages = true,
|
||||||
enableUserData = true,
|
enableUserData = true,
|
||||||
|
|
@ -1020,32 +1087,44 @@ class HomeSettingsService
|
||||||
it,
|
it,
|
||||||
row.viewOptions,
|
row.viewOptions,
|
||||||
rowType = row,
|
rowType = row,
|
||||||
|
showViewMore = it.size >= limit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.TvPrograms -> {
|
is HomeRowConfig.TvPrograms -> {
|
||||||
val request =
|
val request =
|
||||||
GetRecommendedProgramsRequest(
|
GetProgramsDto(
|
||||||
userId = userDto.id,
|
userId = userDto.id,
|
||||||
fields = DefaultItemFields,
|
fields = ProgramItemFields,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
enableUserData = true,
|
enableUserData = true,
|
||||||
enableImages = true,
|
enableImages = true,
|
||||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
|
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
|
||||||
imageTypeLimit = 1,
|
imageTypeLimit = 1,
|
||||||
|
isAiring = true,
|
||||||
|
minEndDate = DateTime.now().plusMinutes(1),
|
||||||
)
|
)
|
||||||
// paging not supported
|
if (usePaging) {
|
||||||
|
ApiRequestPager(
|
||||||
|
api,
|
||||||
|
request,
|
||||||
|
GetProgramsDtoHandler,
|
||||||
|
scope,
|
||||||
|
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||||
|
).init()
|
||||||
|
} else {
|
||||||
api.liveTvApi
|
api.liveTvApi
|
||||||
.getRecommendedPrograms(request)
|
.getPrograms(request)
|
||||||
.content.items
|
.content.items
|
||||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||||
.let {
|
}.let {
|
||||||
Success(
|
Success(
|
||||||
ResStringProvider(R.string.live_tv),
|
ResStringProvider(R.string.watch_live),
|
||||||
it,
|
it,
|
||||||
row.viewOptions,
|
row.viewOptions,
|
||||||
rowType = row,
|
rowType = row,
|
||||||
|
showViewMore = it.size >= limit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1054,7 +1133,7 @@ class HomeSettingsService
|
||||||
val request =
|
val request =
|
||||||
GetLiveTvChannelsRequest(
|
GetLiveTvChannelsRequest(
|
||||||
userId = userDto.id,
|
userId = userDto.id,
|
||||||
fields = DefaultItemFields,
|
fields = HomeItemFields,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
enableImages = true,
|
enableImages = true,
|
||||||
)
|
)
|
||||||
|
|
@ -1076,6 +1155,7 @@ class HomeSettingsService
|
||||||
it,
|
it,
|
||||||
row.viewOptions,
|
row.viewOptions,
|
||||||
rowType = row,
|
rowType = row,
|
||||||
|
showViewMore = it.size >= limit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1108,6 +1188,7 @@ class HomeSettingsService
|
||||||
suggestions.items,
|
suggestions.items,
|
||||||
row.viewOptions,
|
row.viewOptions,
|
||||||
rowType = row,
|
rowType = row,
|
||||||
|
showViewMore = suggestions.items.size >= limit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1146,10 +1227,11 @@ data class HomeRowConfigDisplay(
|
||||||
* @see HomePageSettings
|
* @see HomePageSettings
|
||||||
*/
|
*/
|
||||||
data class HomePageResolvedSettings(
|
data class HomePageResolvedSettings(
|
||||||
|
val userId: UUID,
|
||||||
val rows: List<HomeRowConfigDisplay>,
|
val rows: List<HomeRowConfigDisplay>,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val EMPTY = HomePageResolvedSettings(listOf())
|
val EMPTY = HomePageResolvedSettings(UUID.randomUUID(), emptyList())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1179,10 +1261,56 @@ class UnsupportedHomeSettingsVersionException(
|
||||||
val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION,
|
val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION,
|
||||||
) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion")
|
) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion")
|
||||||
|
|
||||||
fun getRecentlyAddedTitle(library: Library?): StringProvider =
|
fun getRecentlyAddedTitle(name: String?): StringProvider =
|
||||||
if (library?.isRecordingFolder == true) {
|
name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
|
||||||
ResStringProvider(R.string.recently_recorded)
|
|
||||||
} else {
|
|
||||||
library?.name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
|
|
||||||
?: ResStringProvider(R.string.recently_added)
|
?: ResStringProvider(R.string.recently_added)
|
||||||
|
|
||||||
|
private val Library?.itemFields: List<ItemFields>
|
||||||
|
get() =
|
||||||
|
if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) {
|
||||||
|
// Get child count to show in the header
|
||||||
|
HomeItemFieldsBoxSets
|
||||||
|
} else {
|
||||||
|
HomeItemFields
|
||||||
|
}
|
||||||
|
|
||||||
|
private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT)
|
||||||
|
|
||||||
|
fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOptions =
|
||||||
|
when (collectionType) {
|
||||||
|
CollectionType.MUSIC,
|
||||||
|
-> {
|
||||||
|
HomeRowViewOptions(
|
||||||
|
heightDp = Cards.HEIGHT_EPISODE,
|
||||||
|
aspectRatio = AspectRatio.SQUARE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectionType.PHOTOS,
|
||||||
|
CollectionType.HOMEVIDEOS,
|
||||||
|
CollectionType.MUSICVIDEOS,
|
||||||
|
CollectionType.TRAILERS,
|
||||||
|
-> {
|
||||||
|
HomeRowViewOptions(
|
||||||
|
heightDp = Cards.HEIGHT_EPISODE,
|
||||||
|
aspectRatio = AspectRatio.WIDE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectionType.LIVETV,
|
||||||
|
-> {
|
||||||
|
HomeRowViewOptions.liveTvDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectionType.MOVIES,
|
||||||
|
CollectionType.TVSHOWS,
|
||||||
|
CollectionType.BOXSETS,
|
||||||
|
CollectionType.BOOKS,
|
||||||
|
CollectionType.PLAYLISTS,
|
||||||
|
CollectionType.FOLDERS,
|
||||||
|
CollectionType.UNKNOWN,
|
||||||
|
null,
|
||||||
|
-> {
|
||||||
|
HomeRowViewOptions()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -223,11 +223,11 @@ class ImageUrlService
|
||||||
imageType = imageType,
|
imageType = imageType,
|
||||||
maxWidth = maxWidth,
|
maxWidth = maxWidth,
|
||||||
maxHeight = maxHeight,
|
maxHeight = maxHeight,
|
||||||
width = width,
|
width = width?.takeIf { it > 0 },
|
||||||
height = height,
|
height = height?.takeIf { it > 0 },
|
||||||
quality = quality,
|
quality = quality,
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth?.takeIf { it > 0 },
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight?.takeIf { it > 0 },
|
||||||
tag = tag,
|
tag = tag,
|
||||||
format = format,
|
format = format,
|
||||||
percentPlayed = percentPlayed,
|
percentPlayed = percentPlayed,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import android.app.SearchManager
|
||||||
|
import android.content.Intent
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class IntentService
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
|
private val userPreferencesService: UserPreferencesService,
|
||||||
|
) {
|
||||||
|
suspend fun parseIntent(intent: Intent): IntentResult {
|
||||||
|
Timber.v("Parsing intent %s", intent)
|
||||||
|
Timber.v("Intent extras: %s", intent.extras)
|
||||||
|
Timber.v("Intent data: %s", intent.data)
|
||||||
|
val action = intent.action ?: intent.data?.host
|
||||||
|
if (
|
||||||
|
intent.getStringParam(INTENT_ITEM_TYPE) == null &&
|
||||||
|
(action == Intent.ACTION_MAIN || action.isNullOrBlank())
|
||||||
|
) {
|
||||||
|
// Normal app launch
|
||||||
|
return IntentResult.NoOp
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = prepare(intent)
|
||||||
|
if (result != null) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existence of this key implies launching from Play Next channel
|
||||||
|
if (intent.getStringParam(INTENT_ITEM_TYPE) != null) {
|
||||||
|
return getDestinationFromChannel(intent)?.let {
|
||||||
|
IntentResult.Target(
|
||||||
|
destinations = listOf(it),
|
||||||
|
addHomeToBackStack = false,
|
||||||
|
)
|
||||||
|
} ?: IntentResult.Error("Invalid parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == Intent.ACTION_SEARCH || action == "search") {
|
||||||
|
val query = intent.getStringParam(SearchManager.QUERY)
|
||||||
|
return IntentResult.Target(listOf(Destination.Search(query ?: "")))
|
||||||
|
}
|
||||||
|
|
||||||
|
val itemId = intent.getStringParam("itemId")?.toUUIDOrNull()
|
||||||
|
val item =
|
||||||
|
itemId?.let {
|
||||||
|
try {
|
||||||
|
api.userLibraryApi
|
||||||
|
.getItem(itemId)
|
||||||
|
.content
|
||||||
|
.let { BaseItem(it) }
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.w(ex, "Error fetching item %s", itemId)
|
||||||
|
return IntentResult.Error("Could not fetch item $itemId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val itemDestination = item?.destination()
|
||||||
|
|
||||||
|
val destinations =
|
||||||
|
when (action) {
|
||||||
|
Intent.ACTION_VIEW, "view" -> {
|
||||||
|
if (itemDestination != null) {
|
||||||
|
listOf(itemDestination)
|
||||||
|
} else {
|
||||||
|
// Will go to the home page
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ACTION_PLAYBACK, "play" -> {
|
||||||
|
if (itemId == null || itemDestination == null) {
|
||||||
|
return IntentResult.Error("Cannot start playback with no itemId")
|
||||||
|
}
|
||||||
|
val position = intent.getLongParam("position")?.coerceAtLeast(0)
|
||||||
|
val shuffle = intent.getBooleanExtra("shuffle", false)
|
||||||
|
|
||||||
|
val playbackDestination =
|
||||||
|
Destination.Playback(
|
||||||
|
itemId = itemId,
|
||||||
|
positionMs = position ?: 0L,
|
||||||
|
shuffle = shuffle,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (itemDestination is Destination.Playback) {
|
||||||
|
listOf(playbackDestination)
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
itemDestination,
|
||||||
|
playbackDestination,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
return IntentResult.Error("Invalid action: ${intent.action}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return IntentResult.Target(destinations, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun prepare(intent: Intent): IntentResult? {
|
||||||
|
val appPrefs = userPreferencesService.flow.first().appPreferences
|
||||||
|
|
||||||
|
val userId = intent.getStringParam(INTENT_USER_ID)?.toUUIDOrNull()
|
||||||
|
val serverId = intent.getStringParam(INTENT_SERVER_ID)?.toUUIDOrNull()
|
||||||
|
return if (userId != null && serverId != null) {
|
||||||
|
Timber.v("Intent switches user")
|
||||||
|
val user = serverRepository.serverDao.getUser(serverId, userId)
|
||||||
|
if (user != null && !user.isProtected) {
|
||||||
|
serverRepository.restoreSession(serverId, userId)
|
||||||
|
?: IntentResult.Error("Error restoring user")
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
IntentResult.Error("Cannot switch to specified user")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val profileProtected =
|
||||||
|
serverRepository.current.value
|
||||||
|
?.user
|
||||||
|
?.isProtected == true
|
||||||
|
if (appPrefs.signInAutomatically && !profileProtected) {
|
||||||
|
Timber.v("No current user, so restoring last")
|
||||||
|
val userId = appPrefs.currentUserId?.toUUIDOrNull()
|
||||||
|
val serverId = appPrefs.currentServerId?.toUUIDOrNull()
|
||||||
|
|
||||||
|
if (userId != null && serverId != null) {
|
||||||
|
val current =
|
||||||
|
serverRepository.restoreSession(serverId, userId)
|
||||||
|
?: return IntentResult.Error("Error restoring user")
|
||||||
|
if (current.user.isProtected) {
|
||||||
|
IntentResult.Error("Could not auto sign-in, user is protected")
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IntentResult.Error("Could not auto sign-in, specify a server & user")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IntentResult.Error("Auto sign-in not enabled or user is protected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Intent.getStringParam(key: String) = getStringExtra(key) ?: data?.getQueryParameter(key)
|
||||||
|
|
||||||
|
private fun Intent.getLongParam(key: String) =
|
||||||
|
getLongExtra(key, -1).takeIf { it >= 0 } ?: data?.getQueryParameter(key)?.toLongOrNull()
|
||||||
|
|
||||||
|
private fun getDestinationFromChannel(intent: Intent): Destination? =
|
||||||
|
intent.let {
|
||||||
|
val itemId =
|
||||||
|
it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull()
|
||||||
|
val type =
|
||||||
|
it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull)
|
||||||
|
if (itemId != null && type != null) {
|
||||||
|
val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull()
|
||||||
|
val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull()
|
||||||
|
val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1)
|
||||||
|
val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1)
|
||||||
|
if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) {
|
||||||
|
Destination.SeriesOverview(
|
||||||
|
itemId = seriesId,
|
||||||
|
type = BaseItemKind.SERIES,
|
||||||
|
seasonEpisode =
|
||||||
|
SeasonEpisodeIds(
|
||||||
|
seasonId = seasonId,
|
||||||
|
seasonNumber = seasonNumber,
|
||||||
|
episodeId = itemId,
|
||||||
|
episodeNumber = episodeNumber,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Destination.MediaItem(itemId, type)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val INTENT_ITEM_ID = "itemId"
|
||||||
|
const val INTENT_ITEM_TYPE = "itemType"
|
||||||
|
const val INTENT_SERIES_ID = "seriesId"
|
||||||
|
const val INTENT_EPISODE_NUMBER = "epNum"
|
||||||
|
const val INTENT_SEASON_NUMBER = "seaNum"
|
||||||
|
const val INTENT_SEASON_ID = "seaId"
|
||||||
|
const val INTENT_SERVER_ID = "serverId"
|
||||||
|
const val INTENT_USER_ID = "userId"
|
||||||
|
|
||||||
|
const val ACTION_PLAYBACK = "com.github.damontecres.wholphin.PLAYBACK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface IntentResult {
|
||||||
|
data class Error(
|
||||||
|
val message: String,
|
||||||
|
) : IntentResult
|
||||||
|
|
||||||
|
data object NoOp : IntentResult
|
||||||
|
|
||||||
|
data class Target(
|
||||||
|
val destinations: List<Destination>,
|
||||||
|
val addHomeToBackStack: Boolean = true,
|
||||||
|
) : IntentResult
|
||||||
|
}
|
||||||
|
|
@ -8,8 +8,7 @@ package com.github.damontecres.wholphin.services
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.util.LocalDateTimeSerializer
|
import com.github.damontecres.wholphin.util.LocalDateTimeSerializer
|
||||||
import com.github.damontecres.wholphin.util.supportItemKinds
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
|
@ -23,6 +22,7 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||||
|
|
@ -60,16 +60,8 @@ class LatestNextUpService
|
||||||
userId = userId,
|
userId = userId,
|
||||||
fields = SlimItemFields,
|
fields = SlimItemFields,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
includeItemTypes =
|
mediaTypes = listOf(MediaType.VIDEO),
|
||||||
if (includeEpisodes) {
|
excludeItemTypes = if (!includeEpisodes) listOf(BaseItemKind.EPISODE) else null,
|
||||||
supportItemKinds
|
|
||||||
} else {
|
|
||||||
supportItemKinds
|
|
||||||
.toMutableSet()
|
|
||||||
.apply {
|
|
||||||
remove(BaseItemKind.EPISODE)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enableTotalRecordCount = false,
|
enableTotalRecordCount = false,
|
||||||
)
|
)
|
||||||
val items =
|
val items =
|
||||||
|
|
@ -143,14 +135,14 @@ class LatestNextUpService
|
||||||
resume: List<BaseItem>,
|
resume: List<BaseItem>,
|
||||||
nextUp: List<BaseItem>,
|
nextUp: List<BaseItem>,
|
||||||
): List<BaseItem> =
|
): List<BaseItem> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(WholphinDispatchers.IO) {
|
||||||
val start = System.currentTimeMillis()
|
val start = System.currentTimeMillis()
|
||||||
val semaphore = Semaphore(3)
|
val semaphore = Semaphore(3)
|
||||||
val deferred =
|
val deferred =
|
||||||
nextUp
|
nextUp
|
||||||
.filter { it.data.seriesId != null }
|
.filter { it.data.seriesId != null }
|
||||||
.map { item ->
|
.map { item ->
|
||||||
async(Dispatchers.IO) {
|
async(WholphinDispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
semaphore.withPermit {
|
semaphore.withPermit {
|
||||||
datePlayedService.getLastPlayed(item)
|
datePlayedService.getLastPlayed(item)
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,12 @@ import androidx.work.workDataOf
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.ui.collectLatestIn
|
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.qualifiers.ActivityContext
|
import dagger.hilt.android.qualifiers.ActivityContext
|
||||||
import dagger.hilt.android.scopes.ActivityScoped
|
import dagger.hilt.android.scopes.ActivityScoped
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
|
@ -92,7 +92,7 @@ class LatestNextUpSchedulerService
|
||||||
)
|
)
|
||||||
|
|
||||||
// Exposed for testing
|
// Exposed for testing
|
||||||
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
|
internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO
|
||||||
|
|
||||||
init {
|
init {
|
||||||
serverRepository.current.collectLatestIn(activity.lifecycleScope) { user ->
|
serverRepository.current.collectLatestIn(activity.lifecycleScope) { user ->
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||||
|
import org.jellyfin.sdk.model.api.TimerInfoDto
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Functions for recording live tv programs
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class LiveTvService
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val api: ApiClient,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Fetch a tv program by ID
|
||||||
|
*/
|
||||||
|
suspend fun fetchProgramForDialog(programId: UUID): BaseItem {
|
||||||
|
val result =
|
||||||
|
api.liveTvApi
|
||||||
|
.getProgram(programId.toServerString())
|
||||||
|
.content
|
||||||
|
.let { BaseItem(it) }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a recording by [timerId]
|
||||||
|
*
|
||||||
|
* @param series whether the timerId is a series recording or not
|
||||||
|
* @param timerId the recording ID
|
||||||
|
*/
|
||||||
|
suspend fun cancelRecording(
|
||||||
|
series: Boolean,
|
||||||
|
timerId: String?,
|
||||||
|
): Boolean =
|
||||||
|
if (timerId != null) {
|
||||||
|
if (series) {
|
||||||
|
api.liveTvApi.cancelSeriesTimer(timerId)
|
||||||
|
} else {
|
||||||
|
api.liveTvApi.cancelTimer(timerId)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record tv program
|
||||||
|
*
|
||||||
|
* @param programId the tv program ID
|
||||||
|
* @param series whether to set up series recording or just a single recording
|
||||||
|
*/
|
||||||
|
suspend fun record(
|
||||||
|
programId: UUID,
|
||||||
|
series: Boolean,
|
||||||
|
) {
|
||||||
|
val d by api.liveTvApi.getDefaultTimer(programId.toServerString())
|
||||||
|
if (series) {
|
||||||
|
api.liveTvApi.createSeriesTimer(d)
|
||||||
|
} else {
|
||||||
|
val payload =
|
||||||
|
TimerInfoDto(
|
||||||
|
id = d.id,
|
||||||
|
type = d.type,
|
||||||
|
serverId = d.serverId,
|
||||||
|
externalId = d.externalId,
|
||||||
|
channelId = d.channelId,
|
||||||
|
externalChannelId = d.externalChannelId,
|
||||||
|
channelName = d.channelName,
|
||||||
|
programId = d.programId,
|
||||||
|
externalProgramId = d.externalProgramId,
|
||||||
|
name = d.name,
|
||||||
|
overview = d.overview,
|
||||||
|
startDate = d.startDate,
|
||||||
|
endDate = d.endDate,
|
||||||
|
serviceName = d.serviceName,
|
||||||
|
priority = d.priority,
|
||||||
|
prePaddingSeconds = d.prePaddingSeconds,
|
||||||
|
postPaddingSeconds = d.postPaddingSeconds,
|
||||||
|
isPrePaddingRequired = d.isPrePaddingRequired,
|
||||||
|
isPostPaddingRequired = d.isPostPaddingRequired,
|
||||||
|
keepUntil = d.keepUntil,
|
||||||
|
)
|
||||||
|
api.liveTvApi.createTimer(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,8 +13,11 @@ import androidx.media3.session.MediaSession
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.AudioItem
|
import com.github.damontecres.wholphin.data.model.AudioItem
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.preferences.enabled
|
||||||
|
import com.github.damontecres.wholphin.preferences.get
|
||||||
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
||||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
import com.github.damontecres.wholphin.ui.DetailItemFields
|
||||||
|
import com.github.damontecres.wholphin.ui.gt
|
||||||
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
|
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
|
||||||
import com.github.damontecres.wholphin.ui.onMain
|
import com.github.damontecres.wholphin.ui.onMain
|
||||||
import com.github.damontecres.wholphin.ui.seekBack
|
import com.github.damontecres.wholphin.ui.seekBack
|
||||||
|
|
@ -24,10 +27,11 @@ import com.github.damontecres.wholphin.util.BlockingList
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import com.github.damontecres.wholphin.util.PlaybackItemState
|
import com.github.damontecres.wholphin.util.PlaybackItemState
|
||||||
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
|
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
|
import com.github.damontecres.wholphin.util.profile.Codec
|
||||||
import com.github.damontecres.wholphin.util.profile.supportedAudioCodecs
|
import com.github.damontecres.wholphin.util.profile.supportedAudioCodecs
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -35,6 +39,7 @@ import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
@ -44,6 +49,7 @@ import org.jellyfin.sdk.api.client.extensions.universalAudioApi
|
||||||
import org.jellyfin.sdk.api.sockets.subscribe
|
import org.jellyfin.sdk.api.sockets.subscribe
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
import org.jellyfin.sdk.model.api.PlaystateCommand
|
import org.jellyfin.sdk.model.api.PlaystateCommand
|
||||||
import org.jellyfin.sdk.model.api.PlaystateMessage
|
import org.jellyfin.sdk.model.api.PlaystateMessage
|
||||||
|
|
@ -72,6 +78,7 @@ class MusicService
|
||||||
private val playerFactory: PlayerFactory,
|
private val playerFactory: PlayerFactory,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val imageUrlService: ImageUrlService,
|
private val imageUrlService: ImageUrlService,
|
||||||
|
private val userPreferencesService: UserPreferencesService,
|
||||||
) {
|
) {
|
||||||
private val _state = MutableStateFlow(MusicServiceState.EMPTY)
|
private val _state = MutableStateFlow(MusicServiceState.EMPTY)
|
||||||
val state: StateFlow<MusicServiceState> = _state
|
val state: StateFlow<MusicServiceState> = _state
|
||||||
|
|
@ -79,8 +86,15 @@ class MusicService
|
||||||
private val audioFormats by lazy { listOf(*supportedAudioCodecs) }
|
private val audioFormats by lazy { listOf(*supportedAudioCodecs) }
|
||||||
|
|
||||||
val player: Player by lazy {
|
val player: Player by lazy {
|
||||||
|
val disableAudioOffload =
|
||||||
|
runBlocking {
|
||||||
|
userPreferencesService
|
||||||
|
.getCurrent()
|
||||||
|
.appPreferences.experimentalPreferences
|
||||||
|
.get { disableAudioOffload } ?: false
|
||||||
|
}
|
||||||
playerFactory
|
playerFactory
|
||||||
.createAudioPlayer()
|
.createAudioPlayer(disableAudioOffload = disableAudioOffload)
|
||||||
.also {
|
.also {
|
||||||
it.addListener(MusicPlayerListener(it, _state))
|
it.addListener(MusicPlayerListener(it, _state))
|
||||||
}
|
}
|
||||||
|
|
@ -92,6 +106,12 @@ class MusicService
|
||||||
private var activityTracker: TrackActivityPlaybackListener? = null
|
private var activityTracker: TrackActivityPlaybackListener? = null
|
||||||
private var websocketJob: Job? = null
|
private var websocketJob: Job? = null
|
||||||
|
|
||||||
|
private suspend fun preferAc3Surround() =
|
||||||
|
userPreferencesService
|
||||||
|
.getCurrent()
|
||||||
|
.appPreferences.experimentalPreferences
|
||||||
|
.enabled { preferAc3Surround }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start music playback
|
* Start music playback
|
||||||
*
|
*
|
||||||
|
|
@ -162,7 +182,7 @@ class MusicService
|
||||||
userId = serverRepository.currentUser?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
limit = 200,
|
limit = 200,
|
||||||
fields = DefaultItemFields,
|
fields = DetailItemFields,
|
||||||
).content.items
|
).content.items
|
||||||
.map { BaseItem(it, false) }
|
.map { BaseItem(it, false) }
|
||||||
setQueue(items, false)
|
setQueue(items, false)
|
||||||
|
|
@ -177,9 +197,9 @@ class MusicService
|
||||||
items: BlockingList<BaseItem?>,
|
items: BlockingList<BaseItem?>,
|
||||||
startIndex: Int,
|
startIndex: Int,
|
||||||
shuffled: Boolean,
|
shuffled: Boolean,
|
||||||
) = withContext(Dispatchers.IO) {
|
) = withContext(WholphinDispatchers.IO) {
|
||||||
Timber.d("setQueue: %s items, startIndex=%s, shuffled=%s", items.size, startIndex, shuffled)
|
Timber.d("setQueue: %s items, startIndex=%s, shuffled=%s", items.size, startIndex, shuffled)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
player.setMediaItems(emptyList())
|
player.setMediaItems(emptyList())
|
||||||
player.shuffleModeEnabled = shuffled
|
player.shuffleModeEnabled = shuffled
|
||||||
}
|
}
|
||||||
|
|
@ -195,11 +215,12 @@ class MusicService
|
||||||
shuffled: Boolean,
|
shuffled: Boolean,
|
||||||
) {
|
) {
|
||||||
Timber.d("setQueue: %s items, shuffled=%s", items.size, shuffled)
|
Timber.d("setQueue: %s items, shuffled=%s", items.size, shuffled)
|
||||||
|
val preferAc3Surround = preferAc3Surround()
|
||||||
val mediaItems =
|
val mediaItems =
|
||||||
items
|
items
|
||||||
.filter { it.type == BaseItemKind.AUDIO }
|
.filter { it.type == BaseItemKind.AUDIO }
|
||||||
.map(::convert)
|
.map { convert(it, preferAc3Surround) }
|
||||||
withContext(Dispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
player.setMediaItems(mediaItems)
|
player.setMediaItems(mediaItems)
|
||||||
player.shuffleModeEnabled = shuffled
|
player.shuffleModeEnabled = shuffled
|
||||||
updateQueueSize()
|
updateQueueSize()
|
||||||
|
|
@ -215,8 +236,9 @@ class MusicService
|
||||||
index: Int? = null,
|
index: Int? = null,
|
||||||
) {
|
) {
|
||||||
if (item.type == BaseItemKind.AUDIO) {
|
if (item.type == BaseItemKind.AUDIO) {
|
||||||
val mediaItem = convert(item)
|
val preferAc3Surround = preferAc3Surround()
|
||||||
withContext(Dispatchers.Main) {
|
val mediaItem = convert(item, preferAc3Surround)
|
||||||
|
withContext(WholphinDispatchers.Main) {
|
||||||
if (index != null) {
|
if (index != null) {
|
||||||
player.addMediaItem(index, mediaItem)
|
player.addMediaItem(index, mediaItem)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -228,6 +250,8 @@ class MusicService
|
||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Timber.w("Tried to add non-audio type %s: %s", item.type, item.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,6 +264,7 @@ class MusicService
|
||||||
list: BlockingList<BaseItem?>,
|
list: BlockingList<BaseItem?>,
|
||||||
startIndex: Int,
|
startIndex: Int,
|
||||||
) = loading {
|
) = loading {
|
||||||
|
val preferAc3Surround = preferAc3Surround()
|
||||||
var remaining = startIndex
|
var remaining = startIndex
|
||||||
list.indices
|
list.indices
|
||||||
.chunked(25)
|
.chunked(25)
|
||||||
|
|
@ -250,7 +275,7 @@ class MusicService
|
||||||
list
|
list
|
||||||
.getBlocking(it)
|
.getBlocking(it)
|
||||||
?.takeIf { it.type == BaseItemKind.AUDIO }
|
?.takeIf { it.type == BaseItemKind.AUDIO }
|
||||||
?.let(::convert)
|
?.let { convert(it, preferAc3Surround) }
|
||||||
} else {
|
} else {
|
||||||
Timber.v("Skipping $remaining")
|
Timber.v("Skipping $remaining")
|
||||||
remaining--
|
remaining--
|
||||||
|
|
@ -266,12 +291,36 @@ class MusicService
|
||||||
/**
|
/**
|
||||||
* Converts a [BaseItem] into a [MediaItem] setting an [AudioItem] as its tag
|
* Converts a [BaseItem] into a [MediaItem] setting an [AudioItem] as its tag
|
||||||
*/
|
*/
|
||||||
private fun convert(audio: BaseItem): MediaItem {
|
private fun convert(
|
||||||
|
audio: BaseItem,
|
||||||
|
preferAc3Surround: Boolean,
|
||||||
|
): MediaItem {
|
||||||
|
val needsAc3Transcode =
|
||||||
|
preferAc3Surround &&
|
||||||
|
audio.data.mediaSources
|
||||||
|
?.firstOrNull()
|
||||||
|
?.mediaStreams
|
||||||
|
?.any { stream ->
|
||||||
|
stream.type == MediaStreamType.AUDIO &&
|
||||||
|
stream.channels.gt(2)
|
||||||
|
stream.codec != Codec.Audio.AC3
|
||||||
|
} == true
|
||||||
val url =
|
val url =
|
||||||
|
if (needsAc3Transcode) {
|
||||||
|
api.universalAudioApi.getUniversalAudioStreamUrl(
|
||||||
|
itemId = audio.id,
|
||||||
|
container = listOf("mka"),
|
||||||
|
transcodingContainer = "mka",
|
||||||
|
maxAudioChannels = 6,
|
||||||
|
transcodingAudioChannels = 6,
|
||||||
|
audioCodec = Codec.Audio.AC3,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
api.universalAudioApi.getUniversalAudioStreamUrl(
|
api.universalAudioApi.getUniversalAudioStreamUrl(
|
||||||
itemId = audio.id,
|
itemId = audio.id,
|
||||||
container = audioFormats,
|
container = audioFormats,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
Timber.i("url=%s", url)
|
Timber.i("url=%s", url)
|
||||||
val imageUrl =
|
val imageUrl =
|
||||||
audio.data.albumId?.let { albumId ->
|
audio.data.albumId?.let { albumId ->
|
||||||
|
|
@ -293,7 +342,7 @@ class MusicService
|
||||||
*/
|
*/
|
||||||
private suspend fun updateQueueSize() {
|
private suspend fun updateQueueSize() {
|
||||||
// val ids =
|
// val ids =
|
||||||
// withContext(Dispatchers.Default) {
|
// withContext(WholphinDispatchers.Default) {
|
||||||
// (0..<player.mediaItemCount).map { player.getMediaItemAt(it).mediaId.toUUID() }
|
// (0..<player.mediaItemCount).map { player.getMediaItemAt(it).mediaId.toUUID() }
|
||||||
// }
|
// }
|
||||||
val timeline = onMain { player.currentTimeline }
|
val timeline = onMain { player.currentTimeline }
|
||||||
|
|
@ -304,7 +353,7 @@ class MusicService
|
||||||
timeline.getWindow(it, window)
|
timeline.getWindow(it, window)
|
||||||
window.mediaItem.mediaId.toUUID()
|
window.mediaItem.mediaId.toUUID()
|
||||||
}.toSet()
|
}.toSet()
|
||||||
withContext(Dispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
queueVersion = it.queueVersion + 1,
|
queueVersion = it.queueVersion + 1,
|
||||||
|
|
@ -321,7 +370,7 @@ class MusicService
|
||||||
suspend fun moveQueue(
|
suspend fun moveQueue(
|
||||||
index: Int,
|
index: Int,
|
||||||
direction: MoveDirection,
|
direction: MoveDirection,
|
||||||
) = withContext(Dispatchers.Main) {
|
) = withContext(WholphinDispatchers.Main) {
|
||||||
player.moveMediaItem(index, if (direction == MoveDirection.UP) index - 1 else index + 1)
|
player.moveMediaItem(index, if (direction == MoveDirection.UP) index - 1 else index + 1)
|
||||||
updateQueueSize()
|
updateQueueSize()
|
||||||
}
|
}
|
||||||
|
|
@ -332,7 +381,7 @@ class MusicService
|
||||||
suspend fun moveQueue(
|
suspend fun moveQueue(
|
||||||
index: Int,
|
index: Int,
|
||||||
newIndex: Int,
|
newIndex: Int,
|
||||||
) = withContext(Dispatchers.Main) {
|
) = withContext(WholphinDispatchers.Main) {
|
||||||
player.moveMediaItem(index, newIndex)
|
player.moveMediaItem(index, newIndex)
|
||||||
updateQueueSize()
|
updateQueueSize()
|
||||||
}
|
}
|
||||||
|
|
@ -352,7 +401,7 @@ class MusicService
|
||||||
* Play this item next after the current, ie add the item as the next index in the queue
|
* Play this item next after the current, ie add the item as the next index in the queue
|
||||||
*/
|
*/
|
||||||
suspend fun playNext(song: BaseItem) {
|
suspend fun playNext(song: BaseItem) {
|
||||||
val mediaItem = convert(song)
|
val mediaItem = convert(song, preferAc3Surround())
|
||||||
onMain {
|
onMain {
|
||||||
player.addMediaItem(state.value.currentIndex + 1, mediaItem)
|
player.addMediaItem(state.value.currentIndex + 1, mediaItem)
|
||||||
if (player.mediaItemCount == 1) {
|
if (player.mediaItemCount == 1) {
|
||||||
|
|
@ -385,7 +434,7 @@ class MusicService
|
||||||
.subscribe<PlaystateMessage>()
|
.subscribe<PlaystateMessage>()
|
||||||
.onEach { message ->
|
.onEach { message ->
|
||||||
message.data?.let {
|
message.data?.let {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
when (it.command) {
|
when (it.command) {
|
||||||
PlaystateCommand.STOP -> {
|
PlaystateCommand.STOP -> {
|
||||||
stop()
|
stop()
|
||||||
|
|
|
||||||
|
|
@ -6,27 +6,27 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.data.model.NavPinType
|
import com.github.damontecres.wholphin.data.model.NavPinType
|
||||||
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
||||||
|
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.main.settings.Library
|
import com.github.damontecres.wholphin.ui.main.settings.Library
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||||
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.launchIn
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
|
@ -52,35 +52,37 @@ class NavDrawerService
|
||||||
private val seerrServerRepository: SeerrServerRepository,
|
private val seerrServerRepository: SeerrServerRepository,
|
||||||
private val musicService: MusicService,
|
private val musicService: MusicService,
|
||||||
) {
|
) {
|
||||||
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
|
private val _state = MutableStateFlow(NavDrawerItemState())
|
||||||
val state: StateFlow<NavDrawerItemState> = _state
|
val state: StateFlow<NavDrawerItemState> = _state
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Handle updating the nav drawer when the user changes
|
// Handle updating the nav drawer when the user changes
|
||||||
serverRepository.currentUserFlow
|
combine(
|
||||||
.combine(serverRepository.currentUserDtoFlow) { user, userDto ->
|
serverRepository.currentUserFlow,
|
||||||
Pair(user, userDto)
|
serverRepository.currentUserDtoFlow,
|
||||||
}.onEach { (user, userDto) ->
|
seerrServerRepository.active,
|
||||||
Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id)
|
) { user, userDto, discoverActive ->
|
||||||
_state.update {
|
Triple(user, userDto, discoverActive)
|
||||||
it.copy(
|
}.collectLatestIn(coroutineScope) { (user, userDto, discoverActive) ->
|
||||||
items = emptyList(),
|
Timber.d(
|
||||||
moreItems = emptyList(),
|
"User updated: user=%s, userDto=%s, discoverActive=%s",
|
||||||
|
user?.id,
|
||||||
|
userDto?.id,
|
||||||
|
discoverActive,
|
||||||
)
|
)
|
||||||
}
|
try {
|
||||||
if (user != null && userDto != null && user.id == userDto.id) {
|
if (user != null && userDto != null && user.id == userDto.id) {
|
||||||
updateNavDrawer(user, userDto)
|
updateNavDrawer(user, userDto, discoverActive)
|
||||||
|
} else {
|
||||||
|
_state.update { NavDrawerItemState() }
|
||||||
}
|
}
|
||||||
}.catch { ex ->
|
} catch (ex: CancellationException) {
|
||||||
|
throw ex
|
||||||
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Error updating nav drawer")
|
Timber.e(ex, "Error updating nav drawer")
|
||||||
showToast(context, "Error fetching user's views")
|
showToast(context, "Error fetching user's views")
|
||||||
}.launchIn(coroutineScope)
|
}
|
||||||
|
}
|
||||||
// Handle when the user has logged into a Seerr server
|
|
||||||
seerrServerRepository.active
|
|
||||||
.onEach { discoverActive ->
|
|
||||||
_state.update { it.copy(discoverEnabled = discoverActive) }
|
|
||||||
}.launchIn(coroutineScope)
|
|
||||||
|
|
||||||
// Handle when music is actively playing or not
|
// Handle when music is actively playing or not
|
||||||
coroutineScope.launchDefault {
|
coroutineScope.launchDefault {
|
||||||
|
|
@ -132,13 +134,22 @@ class NavDrawerService
|
||||||
.content.items
|
.content.items
|
||||||
val recordingFolders =
|
val recordingFolders =
|
||||||
if (tvAccess) {
|
if (tvAccess) {
|
||||||
|
try {
|
||||||
api.liveTvApi
|
api.liveTvApi
|
||||||
.getRecordingFolders(userId = userId)
|
.getRecordingFolders(userId = userId)
|
||||||
.content.items
|
.content.items
|
||||||
.map { it.id }
|
.map { it.id }
|
||||||
.toSet()
|
.toSet()
|
||||||
|
} catch (ex: InvalidStatusException) {
|
||||||
|
if (ex.status == 401 || ex.status == 403) {
|
||||||
|
Timber.w("Got HTTP %s querying for recording folders", ex.status)
|
||||||
|
emptySet()
|
||||||
} else {
|
} else {
|
||||||
setOf()
|
throw ex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptySet()
|
||||||
}
|
}
|
||||||
val libraries =
|
val libraries =
|
||||||
userViews
|
userViews
|
||||||
|
|
@ -178,8 +189,13 @@ class NavDrawerService
|
||||||
suspend fun updateNavDrawer(
|
suspend fun updateNavDrawer(
|
||||||
user: JellyfinUser,
|
user: JellyfinUser,
|
||||||
userDto: UserDto,
|
userDto: UserDto,
|
||||||
|
discoverActive: Boolean,
|
||||||
) {
|
) {
|
||||||
val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
|
val builtins =
|
||||||
|
buildList {
|
||||||
|
add(NavDrawerItem.Favorites)
|
||||||
|
if (discoverActive) add(NavDrawerItem.Discover)
|
||||||
|
}
|
||||||
val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess)
|
val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess)
|
||||||
val libraries =
|
val libraries =
|
||||||
allLibraries
|
allLibraries
|
||||||
|
|
@ -204,7 +220,7 @@ class NavDrawerService
|
||||||
val allItems = builtins + libraries
|
val allItems = builtins + libraries
|
||||||
|
|
||||||
val navDrawerPins =
|
val navDrawerPins =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(WholphinDispatchers.IO) {
|
||||||
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
|
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,21 +245,18 @@ class NavDrawerService
|
||||||
it.copy(
|
it.copy(
|
||||||
items = items,
|
items = items,
|
||||||
moreItems = moreItems,
|
moreItems = moreItems,
|
||||||
|
allLibraries = allLibraries,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class NavDrawerItemState(
|
data class NavDrawerItemState(
|
||||||
val items: List<NavDrawerItem>,
|
val items: List<NavDrawerItem> = emptyList(),
|
||||||
val moreItems: List<NavDrawerItem>,
|
val moreItems: List<NavDrawerItem> = emptyList(),
|
||||||
val discoverEnabled: Boolean,
|
val nowPlayingEnabled: Boolean = false,
|
||||||
val nowPlayingEnabled: Boolean,
|
val nowPlayingTitle: String? = null,
|
||||||
val nowPlayingTitle: String?,
|
val allLibraries: List<Library> = emptyList(),
|
||||||
) {
|
)
|
||||||
companion object {
|
|
||||||
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false, false, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true
|
val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true
|
||||||
|
|
|
||||||
|
|
@ -24,19 +24,21 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
|
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
|
||||||
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
||||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||||
|
import androidx.media3.session.MediaSession
|
||||||
|
import com.github.damontecres.wholphin.mpv.MpvPlayer
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
|
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
|
||||||
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
|
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
|
||||||
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
|
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
|
import com.github.damontecres.wholphin.preferences.get
|
||||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||||
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import io.github.peerless2012.ass.media.AssHandler
|
import io.github.peerless2012.ass.media.AssHandler
|
||||||
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
|
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
|
||||||
import io.github.peerless2012.ass.media.kt.withAssMkvSupport
|
import io.github.peerless2012.ass.media.kt.withAssMkvSupport
|
||||||
import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
|
import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
|
||||||
import io.github.peerless2012.ass.media.type.AssRenderType
|
import io.github.peerless2012.ass.media.type.AssRenderType
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -60,9 +62,10 @@ class PlayerFactory
|
||||||
|
|
||||||
suspend fun createVideoPlayer(
|
suspend fun createVideoPlayer(
|
||||||
backend: PlayerBackend,
|
backend: PlayerBackend,
|
||||||
prefs: PlaybackPreferences,
|
appPreferences: AppPreferences,
|
||||||
): PlayerCreation {
|
): PlayerCreation {
|
||||||
withContext(Dispatchers.Main) {
|
val prefs = appPreferences.playbackPreferences
|
||||||
|
withContext(WholphinDispatchers.Main) {
|
||||||
if (currentPlayer?.isReleased == false) {
|
if (currentPlayer?.isReleased == false) {
|
||||||
Timber.w("Player was not released before trying to create a new one!")
|
Timber.w("Player was not released before trying to create a new one!")
|
||||||
currentPlayer?.release()
|
currentPlayer?.release()
|
||||||
|
|
@ -104,6 +107,7 @@ class PlayerFactory
|
||||||
WholphinRenderersFactory(context, decodeAv1)
|
WholphinRenderersFactory(context, decodeAv1)
|
||||||
.setEnableDecoderFallback(true)
|
.setEnableDecoderFallback(true)
|
||||||
.setExtensionRendererMode(rendererMode)
|
.setExtensionRendererMode(rendererMode)
|
||||||
|
|
||||||
val mediaSourceFactory =
|
val mediaSourceFactory =
|
||||||
if (useLibAss) {
|
if (useLibAss) {
|
||||||
val renderType =
|
val renderType =
|
||||||
|
|
@ -128,7 +132,15 @@ class PlayerFactory
|
||||||
extractorsFactory,
|
extractorsFactory,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val trackSelector = createTrackSelector()
|
val disableAudioOffload =
|
||||||
|
appPreferences.experimentalPreferences.get { disableAudioOffload } ?: false
|
||||||
|
val tunneling =
|
||||||
|
appPreferences.experimentalPreferences.get { videoTunnelingEnabled }
|
||||||
|
val trackSelector =
|
||||||
|
createTrackSelector(
|
||||||
|
tunneling = tunneling,
|
||||||
|
disableAudioOffload = disableAudioOffload,
|
||||||
|
)
|
||||||
|
|
||||||
ExoPlayer
|
ExoPlayer
|
||||||
.Builder(context)
|
.Builder(context)
|
||||||
|
|
@ -138,7 +150,7 @@ class PlayerFactory
|
||||||
.build()
|
.build()
|
||||||
.apply {
|
.apply {
|
||||||
assHandler?.init(this)
|
assHandler?.init(this)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
setAudioAttributes(
|
setAudioAttributes(
|
||||||
AudioAttributes
|
AudioAttributes
|
||||||
.Builder()
|
.Builder()
|
||||||
|
|
@ -158,7 +170,10 @@ class PlayerFactory
|
||||||
return PlayerCreation(newPlayer, assHandler)
|
return PlayerCreation(newPlayer, assHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createAudioPlayer(extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK): ExoPlayer {
|
fun createAudioPlayer(
|
||||||
|
disableAudioOffload: Boolean,
|
||||||
|
extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK,
|
||||||
|
): ExoPlayer {
|
||||||
val rendererMode =
|
val rendererMode =
|
||||||
when (extensions) {
|
when (extensions) {
|
||||||
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||||
|
|
@ -176,7 +191,7 @@ class PlayerFactory
|
||||||
OkHttpDataSource.Factory(authOkHttpClient),
|
OkHttpDataSource.Factory(authOkHttpClient),
|
||||||
extractorsFactory,
|
extractorsFactory,
|
||||||
)
|
)
|
||||||
val trackSelector = createTrackSelector()
|
val trackSelector = createTrackSelector(disableAudioOffload = disableAudioOffload)
|
||||||
return ExoPlayer
|
return ExoPlayer
|
||||||
.Builder(context)
|
.Builder(context)
|
||||||
.setMediaSourceFactory(mediaSourceFactory)
|
.setMediaSourceFactory(mediaSourceFactory)
|
||||||
|
|
@ -187,9 +202,10 @@ class PlayerFactory
|
||||||
it.setAudioAttributes(
|
it.setAudioAttributes(
|
||||||
AudioAttributes
|
AudioAttributes
|
||||||
.Builder()
|
.Builder()
|
||||||
|
.setUsage(C.USAGE_MEDIA)
|
||||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||||
.build(),
|
.build(),
|
||||||
false,
|
true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -199,18 +215,33 @@ class PlayerFactory
|
||||||
.setConstantBitrateSeekingEnabled(true)
|
.setConstantBitrateSeekingEnabled(true)
|
||||||
.setConstantBitrateSeekingAlwaysEnabled(true)
|
.setConstantBitrateSeekingAlwaysEnabled(true)
|
||||||
|
|
||||||
private fun createTrackSelector() =
|
private fun createTrackSelector(
|
||||||
DefaultTrackSelector(context).apply {
|
tunneling: Boolean? = null,
|
||||||
|
disableAudioOffload: Boolean = false,
|
||||||
|
) = DefaultTrackSelector(context).apply {
|
||||||
|
val offloadMode =
|
||||||
|
if (disableAudioOffload) {
|
||||||
|
AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED
|
||||||
|
} else {
|
||||||
|
AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED
|
||||||
|
}
|
||||||
setParameters(
|
setParameters(
|
||||||
buildUponParameters()
|
buildUponParameters()
|
||||||
.setAudioOffloadPreferences(
|
.apply {
|
||||||
|
tunneling?.let { setTunnelingEnabled(tunneling) }
|
||||||
|
}.setAudioOffloadPreferences(
|
||||||
AudioOffloadPreferences
|
AudioOffloadPreferences
|
||||||
.Builder()
|
.Builder()
|
||||||
.setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)
|
.setAudioOffloadMode(offloadMode)
|
||||||
.build(),
|
.build(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun createMediaSession(player: Player) =
|
||||||
|
MediaSession
|
||||||
|
.Builder(context, player)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
val Player.isReleased: Boolean
|
val Player.isReleased: Boolean
|
||||||
|
|
|
||||||
|
|
@ -8,21 +8,20 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||||
import com.github.damontecres.wholphin.data.model.Playlist
|
import com.github.damontecres.wholphin.data.model.Playlist
|
||||||
import com.github.damontecres.wholphin.data.model.PlaylistInfo
|
import com.github.damontecres.wholphin.data.model.PlaylistInfo
|
||||||
import com.github.damontecres.wholphin.data.model.PlaylistItem
|
import com.github.damontecres.wholphin.data.model.PlaylistItem
|
||||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
import com.github.damontecres.wholphin.ui.DetailItemFields
|
||||||
import com.github.damontecres.wholphin.ui.components.baseItemKinds
|
import com.github.damontecres.wholphin.ui.components.baseItemKinds
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
import com.github.damontecres.wholphin.ui.gt
|
import com.github.damontecres.wholphin.ui.gt
|
||||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||||
import com.github.damontecres.wholphin.ui.playback.playable
|
import com.github.damontecres.wholphin.ui.playback.playable
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
|
||||||
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.TransformList
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||||
import org.jellyfin.sdk.api.client.extensions.playlistsApi
|
import org.jellyfin.sdk.api.client.extensions.playlistsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
|
@ -66,7 +65,7 @@ class PlaylistCreator
|
||||||
GetEpisodesRequest(
|
GetEpisodesRequest(
|
||||||
seriesId = seriesId,
|
seriesId = seriesId,
|
||||||
seasonId = seasonId,
|
seasonId = seasonId,
|
||||||
fields = DefaultItemFields,
|
fields = DetailItemFields,
|
||||||
startItemId = episodeId,
|
startItemId = episodeId,
|
||||||
sortBy = if (shuffled) ItemSortBy.RANDOM else null,
|
sortBy = if (shuffled) ItemSortBy.RANDOM else null,
|
||||||
limit = Playlist.MAX_SIZE,
|
limit = Playlist.MAX_SIZE,
|
||||||
|
|
@ -78,7 +77,7 @@ class PlaylistCreator
|
||||||
.convertAndAddParts(false)
|
.convertAndAddParts(false)
|
||||||
val startIndex =
|
val startIndex =
|
||||||
episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0
|
episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0
|
||||||
return Playlist(episodes, startIndex)
|
return Playlist(episodes.subList(startIndex, episodes.size))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -95,7 +94,7 @@ class PlaylistCreator
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
userId = serverRepository.currentUser?.id,
|
userId = serverRepository.currentUser?.id,
|
||||||
parentId = playlistId,
|
parentId = playlistId,
|
||||||
fields = DefaultItemFields,
|
fields = DetailItemFields,
|
||||||
startIndex = startIndex,
|
startIndex = startIndex,
|
||||||
limit = Playlist.MAX_SIZE,
|
limit = Playlist.MAX_SIZE,
|
||||||
sortBy = listOf(sortAndDirection.sort),
|
sortBy = listOf(sortAndDirection.sort),
|
||||||
|
|
@ -104,7 +103,7 @@ class PlaylistCreator
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val items = GetItemsRequestHandler.execute(api, request).content.items
|
val items = GetItemsRequestHandler.execute(api, request).content.items
|
||||||
return Playlist(items.convertAndAddParts(), 0)
|
return Playlist(items.convertAndAddParts())
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun createFromCollection(
|
private suspend fun createFromCollection(
|
||||||
|
|
@ -136,7 +135,7 @@ class PlaylistCreator
|
||||||
excludeItemIds = listOf(item.id),
|
excludeItemIds = listOf(item.id),
|
||||||
sortBy = sortAndDirection?.let { listOf(sortAndDirection.sort) },
|
sortBy = sortAndDirection?.let { listOf(sortAndDirection.sort) },
|
||||||
sortOrder = sortAndDirection?.let { listOf(sortAndDirection.direction) },
|
sortOrder = sortAndDirection?.let { listOf(sortAndDirection.direction) },
|
||||||
fields = DefaultItemFields,
|
fields = DetailItemFields,
|
||||||
startIndex = startIndex,
|
startIndex = startIndex,
|
||||||
limit = Playlist.MAX_SIZE,
|
limit = Playlist.MAX_SIZE,
|
||||||
excludeLocationTypes = listOf(LocationType.VIRTUAL),
|
excludeLocationTypes = listOf(LocationType.VIRTUAL),
|
||||||
|
|
@ -147,7 +146,7 @@ class PlaylistCreator
|
||||||
.execute(api, request)
|
.execute(api, request)
|
||||||
.content.items
|
.content.items
|
||||||
.convertAndAddParts()
|
.convertAndAddParts()
|
||||||
return Playlist(items, 0)
|
return Playlist(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -202,6 +201,20 @@ class PlaylistCreator
|
||||||
BaseItemKind.SEASON -> {
|
BaseItemKind.SEASON -> {
|
||||||
val seriesId = item.seriesId
|
val seriesId = item.seriesId
|
||||||
if (seriesId != null) {
|
if (seriesId != null) {
|
||||||
|
if (shuffled) {
|
||||||
|
api.tvShowsApi
|
||||||
|
.getEpisodes(
|
||||||
|
seriesId = seriesId,
|
||||||
|
seasonId = item.id,
|
||||||
|
limit = Playlist.MAX_SIZE,
|
||||||
|
sortBy = ItemSortBy.RANDOM,
|
||||||
|
fields = DetailItemFields,
|
||||||
|
).content.items
|
||||||
|
.convertAndAddParts()
|
||||||
|
.let {
|
||||||
|
PlaylistCreationResult.Success(Playlist(it))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
PlaylistCreationResult.Success(
|
PlaylistCreationResult.Success(
|
||||||
createFromEpisode(
|
createFromEpisode(
|
||||||
seriesId = seriesId,
|
seriesId = seriesId,
|
||||||
|
|
@ -210,20 +223,50 @@ class PlaylistCreator
|
||||||
shuffled = shuffled,
|
shuffled = shuffled,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
PlaylistCreationResult.Error(null, "Episode has no seriesId")
|
PlaylistCreationResult.Error(null, "Episode has no seriesId")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BaseItemKind.SERIES -> {
|
BaseItemKind.SERIES -> {
|
||||||
|
if (shuffled) {
|
||||||
|
api.tvShowsApi
|
||||||
|
.getEpisodes(
|
||||||
|
seriesId = item.id,
|
||||||
|
limit = Playlist.MAX_SIZE,
|
||||||
|
sortBy = ItemSortBy.RANDOM,
|
||||||
|
fields = DetailItemFields,
|
||||||
|
).content.items
|
||||||
|
.convertAndAddParts()
|
||||||
|
.let {
|
||||||
|
PlaylistCreationResult.Success(Playlist(it))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val result by api.tvShowsApi.getNextUp(seriesId = item.id)
|
||||||
|
val nextUp =
|
||||||
|
result.items.firstOrNull() ?: api.tvShowsApi
|
||||||
|
.getEpisodes(
|
||||||
|
item.id,
|
||||||
|
limit = 1,
|
||||||
|
).content.items
|
||||||
|
.firstOrNull()
|
||||||
|
if (nextUp != null) {
|
||||||
PlaylistCreationResult.Success(
|
PlaylistCreationResult.Success(
|
||||||
createFromEpisode(
|
createFromEpisode(
|
||||||
seriesId = item.id,
|
seriesId = item.id,
|
||||||
seasonId = null,
|
seasonId = null,
|
||||||
episodeId = null,
|
episodeId = nextUp.id,
|
||||||
shuffled = shuffled,
|
shuffled = shuffled,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
PlaylistCreationResult.Error(
|
||||||
|
null,
|
||||||
|
"Could not determine next up episode for series: " + item.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BaseItemKind.PLAYLIST -> {
|
BaseItemKind.PLAYLIST -> {
|
||||||
|
|
@ -263,7 +306,7 @@ class PlaylistCreator
|
||||||
}.let(::addAll)
|
}.let(::addAll)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlaylistCreationResult.Success(Playlist(list, 0))
|
PlaylistCreationResult.Success(Playlist(list))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not support yet
|
// Not support yet
|
||||||
|
|
@ -299,27 +342,50 @@ class PlaylistCreator
|
||||||
* Get the playlists on the server for a given media type
|
* Get the playlists on the server for a given media type
|
||||||
*/
|
*/
|
||||||
suspend fun getServerPlaylists(
|
suspend fun getServerPlaylists(
|
||||||
|
query: String,
|
||||||
mediaType: MediaType?,
|
mediaType: MediaType?,
|
||||||
scope: CoroutineScope,
|
): List<PlaylistInfo> {
|
||||||
): List<PlaylistInfo?> {
|
val userId =
|
||||||
|
serverRepository.currentUser?.id ?: throw IllegalStateException("No user found")
|
||||||
val request =
|
val request =
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
|
searchTerm = query.takeIf { it.isNotBlank() },
|
||||||
includeItemTypes = listOf(BaseItemKind.PLAYLIST),
|
includeItemTypes = listOf(BaseItemKind.PLAYLIST),
|
||||||
mediaTypes = mediaType?.let { listOf(mediaType) },
|
mediaTypes = mediaType?.let { listOf(mediaType) },
|
||||||
recursive = true,
|
recursive = true,
|
||||||
|
limit = 25,
|
||||||
|
sortBy = listOf(ItemSortBy.DATE_LAST_CONTENT_ADDED),
|
||||||
|
sortOrder = listOf(SortOrder.DESCENDING),
|
||||||
)
|
)
|
||||||
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
|
val playlists = GetItemsRequestHandler.execute(api, request).content.items
|
||||||
return TransformList(pager) {
|
return playlists
|
||||||
it?.let {
|
.mapNotNull { playlist ->
|
||||||
|
try {
|
||||||
|
val response = api.playlistsApi.getPlaylistUser(playlist.id, userId).content
|
||||||
|
if (response.canEdit) {
|
||||||
PlaylistInfo(
|
PlaylistInfo(
|
||||||
id = it.id,
|
id = playlist.id,
|
||||||
name = it.name ?: context.getString(R.string.unknown),
|
name = playlist.name ?: context.getString(R.string.unknown),
|
||||||
count = it.data.childCount ?: 0,
|
count = playlist.childCount ?: 0,
|
||||||
mediaType = it.data.mediaType,
|
mediaType = playlist.mediaType,
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (ex: InvalidStatusException) {
|
||||||
|
if (ex.status == 404) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
throw ex
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// .sortedWith(
|
||||||
|
// compareBy<PlaylistInfo> {
|
||||||
|
// SearchRelevance.score(it.name, BaseItemKind.PLAYLIST, query)
|
||||||
|
// }.thenBy { it.name },
|
||||||
|
// )
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun createServerPlaylist(
|
suspend fun createServerPlaylist(
|
||||||
name: String,
|
name: String,
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ import android.os.Looper
|
||||||
import android.view.Display
|
import android.view.Display
|
||||||
import com.github.damontecres.wholphin.MainActivity
|
import com.github.damontecres.wholphin.MainActivity
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
@ -38,7 +38,7 @@ class RefreshRateService
|
||||||
stream: MediaStream,
|
stream: MediaStream,
|
||||||
switchRefreshRate: Boolean,
|
switchRefreshRate: Boolean,
|
||||||
switchResolution: Boolean,
|
switchResolution: Boolean,
|
||||||
) = withContext(Dispatchers.IO) {
|
) = withContext(WholphinDispatchers.IO) {
|
||||||
if (!switchRefreshRate && !switchResolution) {
|
if (!switchRefreshRate && !switchResolution) {
|
||||||
Timber.v("Not switching either refresh rate nor resolution")
|
Timber.v("Not switching either refresh rate nor resolution")
|
||||||
return@withContext
|
return@withContext
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.data.RememberedTabDao
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.RememberedTab
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class RememberedTabService
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
|
private val rememberedTabDao: RememberedTabDao,
|
||||||
|
) {
|
||||||
|
suspend fun getRememberedTab(itemId: String): Int? =
|
||||||
|
withContext(WholphinDispatchers.IO) {
|
||||||
|
serverRepository.currentUser?.rowId?.let { userId ->
|
||||||
|
rememberedTabDao.getRememberedTab(userId, itemId)?.index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveRememberedTab(
|
||||||
|
itemId: String,
|
||||||
|
tabIndex: Int,
|
||||||
|
): Unit =
|
||||||
|
withContext(WholphinDispatchers.IO) {
|
||||||
|
serverRepository.currentUser?.rowId?.let { userId ->
|
||||||
|
rememberedTabDao.save(RememberedTab(userId, itemId, tabIndex))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
import coil3.imageLoader
|
import coil3.imageLoader
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
||||||
|
|
@ -10,10 +13,10 @@ import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
@ -57,6 +60,7 @@ class ScreensaverService
|
||||||
val keepScreenOn = MutableStateFlow(false)
|
val keepScreenOn = MutableStateFlow(false)
|
||||||
|
|
||||||
private var waitJob: Job? = null
|
private var waitJob: Job? = null
|
||||||
|
private var dimJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
userPreferencesService.flow
|
userPreferencesService.flow
|
||||||
|
|
@ -65,7 +69,14 @@ class ScreensaverService
|
||||||
val enabled =
|
val enabled =
|
||||||
prefs.appPreferences.interfacePreferences.screensaverPreference.enabled
|
prefs.appPreferences.interfacePreferences.screensaverPreference.enabled
|
||||||
keepScreenOnInternal(enabled)
|
keepScreenOnInternal(enabled)
|
||||||
ScreensaverState(enabled, false, false, false)
|
ScreensaverState(
|
||||||
|
enabled = enabled,
|
||||||
|
enabledTemp = false,
|
||||||
|
active = false,
|
||||||
|
paused = false,
|
||||||
|
dimEnabled = prefs.appPreferences.interfacePreferences.screensaverPreference.dimEnabled,
|
||||||
|
dimActive = false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}.launchIn(scope)
|
}.launchIn(scope)
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +90,10 @@ class ScreensaverService
|
||||||
// Timber.v("pulse")
|
// Timber.v("pulse")
|
||||||
_state.update {
|
_state.update {
|
||||||
if (!it.active) {
|
if (!it.active) {
|
||||||
it.copy(active = false)
|
it.copy(
|
||||||
|
active = false,
|
||||||
|
dimActive = false,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
it
|
it
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +108,30 @@ class ScreensaverService
|
||||||
.appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds
|
.appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds
|
||||||
delay(startDelay)
|
delay(startDelay)
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(active = true)
|
it.copy(
|
||||||
|
active = true,
|
||||||
|
dimActive = it.dimEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dimJob?.cancel()
|
||||||
|
if (_state.value.dimEnabled) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(dimActive = false)
|
||||||
|
}
|
||||||
|
if (!_state.value.paused) {
|
||||||
|
dimJob =
|
||||||
|
scope.launch(ExceptionHandler()) {
|
||||||
|
val startDelay =
|
||||||
|
userPreferencesService
|
||||||
|
.getCurrent()
|
||||||
|
.appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds
|
||||||
|
delay(startDelay)
|
||||||
|
Timber.v("state dim=%s", _state.value)
|
||||||
|
_state.update {
|
||||||
|
it.copy(dimActive = !_state.value.paused)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -109,6 +146,7 @@ class ScreensaverService
|
||||||
it.copy(
|
it.copy(
|
||||||
enabledTemp = true,
|
enabledTemp = true,
|
||||||
active = true,
|
active = true,
|
||||||
|
dimActive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -121,9 +159,13 @@ class ScreensaverService
|
||||||
it.copy(
|
it.copy(
|
||||||
enabledTemp = false,
|
enabledTemp = false,
|
||||||
active = false,
|
active = false,
|
||||||
|
dimActive = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (cancelJob) waitJob?.cancel()
|
if (cancelJob) {
|
||||||
|
waitJob?.cancel()
|
||||||
|
dimJob?.cancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -131,17 +173,24 @@ class ScreensaverService
|
||||||
*/
|
*/
|
||||||
fun keepScreenOn(keep: Boolean) {
|
fun keepScreenOn(keep: Boolean) {
|
||||||
scope.launchDefault {
|
scope.launchDefault {
|
||||||
val screensaverEnabled = _state.value.enabled
|
val screensaverEnabled = state.value.enabled
|
||||||
|
val dimEnabled = state.value.dimEnabled
|
||||||
Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled)
|
Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled)
|
||||||
if (screensaverEnabled) {
|
if (screensaverEnabled || dimEnabled) {
|
||||||
// Page is requesting to keep screen on, so we don't wait to show the screensaver
|
// Page is requesting to keep screen on, so we don't wait to show the screensaver
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(active = false, paused = keep)
|
it.copy(
|
||||||
|
active = false,
|
||||||
|
dimActive = false,
|
||||||
|
paused = keep,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (!keep) {
|
if (!keep) {
|
||||||
pulse()
|
pulse()
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
if (!screensaverEnabled) {
|
||||||
|
// If in-app screensaver is not enabled, send keep screen on to the OS
|
||||||
keepScreenOnInternal(keep)
|
keepScreenOnInternal(keep)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -224,7 +273,7 @@ class ScreensaverService
|
||||||
if (index > pager.lastIndex) index = 0
|
if (index > pager.lastIndex) index = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.flowOn(Dispatchers.Default).cancellable()
|
}.flowOn(WholphinDispatchers.Default).cancellable()
|
||||||
|
|
||||||
private suspend fun createPager(): ApiRequestPager<GetItemsRequest> {
|
private suspend fun createPager(): ApiRequestPager<GetItemsRequest> {
|
||||||
val prefs =
|
val prefs =
|
||||||
|
|
@ -245,6 +294,11 @@ class ScreensaverService
|
||||||
)
|
)
|
||||||
return ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
|
return ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val enterAnimation = fadeIn(animationSpec = tween(durationMillis = 1000))
|
||||||
|
val exitAnimation = fadeOut(animationSpec = tween(durationMillis = 500))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ScreensaverState(
|
data class ScreensaverState(
|
||||||
|
|
@ -252,6 +306,10 @@ data class ScreensaverState(
|
||||||
val enabledTemp: Boolean,
|
val enabledTemp: Boolean,
|
||||||
val active: Boolean,
|
val active: Boolean,
|
||||||
val paused: Boolean,
|
val paused: Boolean,
|
||||||
|
val dimEnabled: Boolean = false,
|
||||||
|
val dimActive: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val show get() = (enabled || enabledTemp) && active && !paused
|
val show get() = (enabled || enabledTemp) && active && !paused
|
||||||
|
|
||||||
|
val showDim get() = dimEnabled && dimActive && !paused
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,21 @@ import com.github.damontecres.wholphin.api.seerr.model.CreditCrew
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.MediaInfo
|
import com.github.damontecres.wholphin.api.seerr.model.MediaInfo
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
|
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.MovieResult
|
import com.github.damontecres.wholphin.api.seerr.model.MovieResult
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.RootFolder
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner
|
import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.ServiceProfile
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.TvResult
|
import com.github.damontecres.wholphin.api.seerr.model.TvResult
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrPermission
|
||||||
|
import com.github.damontecres.wholphin.data.model.hasPermission
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.discover.SeerrProfile
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.discover.SeerrRequestData
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.discover.SeerrRootFolder
|
||||||
|
import com.github.damontecres.wholphin.ui.formatBytes
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.toLocalDate
|
import com.github.damontecres.wholphin.ui.toLocalDate
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
@ -20,6 +28,7 @@ import kotlinx.coroutines.flow.firstOrNull
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -115,21 +124,7 @@ class SeerrService
|
||||||
}
|
}
|
||||||
|
|
||||||
BaseItemKind.PERSON -> {
|
BaseItemKind.PERSON -> {
|
||||||
api.personApi
|
personCredits(it)
|
||||||
.personPersonIdCombinedCreditsGet(personId = it)
|
|
||||||
.let { credits ->
|
|
||||||
val cast =
|
|
||||||
credits.cast
|
|
||||||
?.take(25)
|
|
||||||
?.map { createDiscoverItem(it) }
|
|
||||||
.orEmpty()
|
|
||||||
val crew =
|
|
||||||
credits.crew
|
|
||||||
?.take(25)
|
|
||||||
?.map { createDiscoverItem(it) }
|
|
||||||
.orEmpty()
|
|
||||||
cast + crew
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
|
|
@ -141,6 +136,50 @@ class SeerrService
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the combined movie & TV credits of a person as [DiscoverItem]s
|
||||||
|
*
|
||||||
|
* The two lists returned by the API are merged, de-duplicated by id since a person can be
|
||||||
|
* both cast and crew on the same title, and sorted by release date descending with undated
|
||||||
|
* entries last
|
||||||
|
*/
|
||||||
|
suspend fun personCredits(personId: Int): List<DiscoverItem> =
|
||||||
|
api.personApi
|
||||||
|
.personPersonIdCombinedCreditsGet(personId = personId)
|
||||||
|
.let { credits ->
|
||||||
|
val cast = credits.cast?.map { createDiscoverItem(it) }.orEmpty()
|
||||||
|
val crew = credits.crew?.map { createDiscoverItem(it) }.orEmpty()
|
||||||
|
(cast + crew)
|
||||||
|
.distinctBy { it.id }
|
||||||
|
.sortedByDescending { it.releaseDate }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a [DiscoverItem] for a person in the library so that the full grid of their
|
||||||
|
* credits can be opened from their page
|
||||||
|
*
|
||||||
|
* @return the item or null if the person has no TMDB id
|
||||||
|
*/
|
||||||
|
fun discoverPerson(item: BaseItem): DiscoverItem? =
|
||||||
|
item.data.providerIds
|
||||||
|
?.get("Tmdb")
|
||||||
|
?.toIntOrNull()
|
||||||
|
?.let {
|
||||||
|
DiscoverItem(
|
||||||
|
id = it,
|
||||||
|
type = SeerrItemType.PERSON,
|
||||||
|
title = item.name,
|
||||||
|
subtitle = null,
|
||||||
|
overview = item.data.overview,
|
||||||
|
availability = SeerrAvailability.UNKNOWN,
|
||||||
|
releaseDate = null,
|
||||||
|
posterUrl = null,
|
||||||
|
backDropUrl = null,
|
||||||
|
logoUrl = null,
|
||||||
|
jellyfinItemId = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getTvSeries(item: BaseItem): TvDetails? =
|
suspend fun getTvSeries(item: BaseItem): TvDetails? =
|
||||||
if (active.first()) {
|
if (active.first()) {
|
||||||
item.data.providerIds
|
item.data.providerIds
|
||||||
|
|
@ -153,20 +192,15 @@ class SeerrService
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun createImageUrl(
|
suspend fun createImageUrl(
|
||||||
imageType: ImageType,
|
imageType: ImageType,
|
||||||
path: String?,
|
path: String?,
|
||||||
mediaInfo: MediaInfo?,
|
mediaInfo: MediaInfo?,
|
||||||
|
backdropHeight: Int = 1080,
|
||||||
|
backdropWidth: Int = 1920,
|
||||||
): String? {
|
): String? {
|
||||||
if (mediaInfo != null) {
|
if (mediaInfo != null) {
|
||||||
val itemId =
|
val itemId = mediaInfo.jellyfinId
|
||||||
if (mediaInfo.jellyfinMediaId.isNotNullOrBlank()) {
|
|
||||||
mediaInfo.jellyfinMediaId.toUUIDOrNull()
|
|
||||||
} else if (mediaInfo.jellyfinMediaId4k.isNotNullOrBlank()) {
|
|
||||||
mediaInfo.jellyfinMediaId4k.toUUIDOrNull()
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
if (itemId != null) {
|
if (itemId != null) {
|
||||||
return imageUrlService.getItemImageUrl(
|
return imageUrlService.getItemImageUrl(
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
|
|
@ -174,6 +208,7 @@ class SeerrService
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return path?.takeIf { it.isNotNullOrBlank() }?.let {
|
||||||
val current = seerrServerRepository.current.firstOrNull() ?: return null
|
val current = seerrServerRepository.current.firstOrNull() ?: return null
|
||||||
val cacheImages = current.serverConfig.cacheImages == true
|
val cacheImages = current.serverConfig.cacheImages == true
|
||||||
val base =
|
val base =
|
||||||
|
|
@ -185,11 +220,132 @@ class SeerrService
|
||||||
val prefix =
|
val prefix =
|
||||||
when (imageType) {
|
when (imageType) {
|
||||||
ImageType.PRIMARY -> "/t/p/w500"
|
ImageType.PRIMARY -> "/t/p/w500"
|
||||||
ImageType.BACKDROP -> "/t/p/w1920_and_h1080_multi_faces"
|
ImageType.BACKDROP -> "/t/p/w${backdropWidth}_and_h${backdropHeight}_multi_faces"
|
||||||
else -> throw IllegalArgumentException("Image type not supported: $imageType")
|
else -> throw IllegalArgumentException("Image type not supported: $imageType")
|
||||||
}
|
}
|
||||||
return "${base}${prefix}$path"
|
return "${base}${prefix}$path"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getProfilesAndFolders(type: SeerrItemType): SeerrRequestData {
|
||||||
|
var result = SeerrRequestData()
|
||||||
|
val current = seerrServerRepository.current.firstOrNull()
|
||||||
|
if (current != null && current.config.hasPermission(SeerrPermission.REQUEST_ADVANCED)) {
|
||||||
|
if (type == SeerrItemType.MOVIE) {
|
||||||
|
val radarrs = api.serviceApi.serviceRadarrGet()
|
||||||
|
val radarr =
|
||||||
|
radarrs.firstOrNull { it.isDefault && !it.is4k }
|
||||||
|
?: radarrs.firstOrNull { !it.is4k }
|
||||||
|
val radarr4k =
|
||||||
|
radarrs.firstOrNull { it.isDefault && it.is4k }
|
||||||
|
?: radarrs.firstOrNull { it.is4k }
|
||||||
|
|
||||||
|
if (radarr != null && radarr.id != null) {
|
||||||
|
val settings = api.serviceApi.serviceRadarrRadarrIdGet(radarr.id)
|
||||||
|
|
||||||
|
setProfilesAndFolder(
|
||||||
|
settings.profiles,
|
||||||
|
settings.rootFolders,
|
||||||
|
settings.server?.activeProfileId,
|
||||||
|
settings.server?.activeDirectory,
|
||||||
|
) { profiles, rootFolders ->
|
||||||
|
result =
|
||||||
|
result.copy(
|
||||||
|
serverId = radarr.id,
|
||||||
|
profiles = profiles,
|
||||||
|
rootFolders = rootFolders,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (radarr4k != null && radarr4k.id != null) {
|
||||||
|
val settings = api.serviceApi.serviceRadarrRadarrIdGet(radarr4k.id)
|
||||||
|
setProfilesAndFolder(
|
||||||
|
settings.profiles,
|
||||||
|
settings.rootFolders,
|
||||||
|
settings.server?.activeProfileId,
|
||||||
|
settings.server?.activeDirectory,
|
||||||
|
) { profiles, rootFolders ->
|
||||||
|
result =
|
||||||
|
result.copy(
|
||||||
|
server4kId = radarr4k.id,
|
||||||
|
profiles4k = profiles,
|
||||||
|
rootFolders4k = rootFolders,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val sonarrs = api.serviceApi.serviceSonarrGet()
|
||||||
|
val sonarr =
|
||||||
|
sonarrs.firstOrNull { it.isDefault && !it.is4k }
|
||||||
|
?: sonarrs.firstOrNull { !it.is4k }
|
||||||
|
val sonarr4k =
|
||||||
|
sonarrs.firstOrNull { it.isDefault && it.is4k }
|
||||||
|
?: sonarrs.firstOrNull { it.is4k }
|
||||||
|
|
||||||
|
if (sonarr != null && sonarr.id != null) {
|
||||||
|
val settings = api.serviceApi.serviceSonarrSonarrIdGet(sonarr.id)
|
||||||
|
|
||||||
|
setProfilesAndFolder(
|
||||||
|
settings.profiles,
|
||||||
|
settings.rootFolders,
|
||||||
|
settings.server?.activeProfileId,
|
||||||
|
settings.server?.activeDirectory,
|
||||||
|
) { profiles, rootFolders ->
|
||||||
|
result =
|
||||||
|
result.copy(
|
||||||
|
serverId = sonarr.id,
|
||||||
|
profiles = profiles,
|
||||||
|
rootFolders = rootFolders,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sonarr4k != null && sonarr4k.id != null) {
|
||||||
|
val settings = api.serviceApi.serviceSonarrSonarrIdGet(sonarr4k.id)
|
||||||
|
setProfilesAndFolder(
|
||||||
|
settings.profiles,
|
||||||
|
settings.rootFolders,
|
||||||
|
settings.server?.activeProfileId,
|
||||||
|
settings.server?.activeDirectory,
|
||||||
|
) { profiles, rootFolders ->
|
||||||
|
result =
|
||||||
|
result.copy(
|
||||||
|
server4kId = sonarr4k.id,
|
||||||
|
profiles4k = profiles,
|
||||||
|
rootFolders4k = rootFolders,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setProfilesAndFolder(
|
||||||
|
profiles: List<ServiceProfile>?,
|
||||||
|
rootFolders: List<RootFolder>?,
|
||||||
|
activeProfileId: Int?,
|
||||||
|
activeDirectory: String?,
|
||||||
|
setter: (List<SeerrProfile>, List<SeerrRootFolder>) -> Unit,
|
||||||
|
) {
|
||||||
|
val profiles =
|
||||||
|
profiles.orEmpty().mapNotNull {
|
||||||
|
if (it.id != null && it.name != null) {
|
||||||
|
SeerrProfile(it.id, it.name, activeProfileId == it.id)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val rootFolders =
|
||||||
|
rootFolders.orEmpty().mapNotNull {
|
||||||
|
if (it.id != null && it.path != null) {
|
||||||
|
val freeSpace = it.freeSpace?.let { formatBytes(it) } ?: ""
|
||||||
|
SeerrRootFolder(it.id, it.path, freeSpace, activeDirectory == it.path)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setter.invoke(profiles, rootFolders)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun createDiscoverItem(item: Any): DiscoverItem =
|
suspend fun createDiscoverItem(item: Any): DiscoverItem =
|
||||||
when (item) {
|
when (item) {
|
||||||
|
|
@ -216,7 +372,8 @@ class SeerrService
|
||||||
releaseDate = toLocalDate(movie.releaseDate),
|
releaseDate = toLocalDate(movie.releaseDate),
|
||||||
posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo),
|
posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo),
|
||||||
backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo),
|
backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo),
|
||||||
jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo),
|
||||||
|
jellyfinItemId = movie.mediaInfo?.jellyfinId,
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun createDiscoverItem(movie: MovieDetails): DiscoverItem =
|
suspend fun createDiscoverItem(movie: MovieDetails): DiscoverItem =
|
||||||
|
|
@ -232,7 +389,8 @@ class SeerrService
|
||||||
releaseDate = toLocalDate(movie.releaseDate),
|
releaseDate = toLocalDate(movie.releaseDate),
|
||||||
posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo),
|
posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo),
|
||||||
backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo),
|
backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo),
|
||||||
jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo),
|
||||||
|
jellyfinItemId = movie.mediaInfo?.jellyfinId,
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun createDiscoverItem(tv: TvResult): DiscoverItem =
|
suspend fun createDiscoverItem(tv: TvResult): DiscoverItem =
|
||||||
|
|
@ -248,7 +406,8 @@ class SeerrService
|
||||||
releaseDate = toLocalDate(tv.firstAirDate),
|
releaseDate = toLocalDate(tv.firstAirDate),
|
||||||
posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo),
|
posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo),
|
||||||
backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo),
|
backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo),
|
||||||
jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo),
|
||||||
|
jellyfinItemId = tv.mediaInfo?.jellyfinId,
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun createDiscoverItem(tv: TvDetails): DiscoverItem =
|
suspend fun createDiscoverItem(tv: TvDetails): DiscoverItem =
|
||||||
|
|
@ -264,7 +423,8 @@ class SeerrService
|
||||||
releaseDate = toLocalDate(tv.firstAirDate),
|
releaseDate = toLocalDate(tv.firstAirDate),
|
||||||
posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo),
|
posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo),
|
||||||
backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo),
|
backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo),
|
||||||
jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo),
|
||||||
|
jellyfinItemId = tv.mediaInfo?.jellyfinId,
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun createDiscoverItem(search: SeerrSearchResult): DiscoverItem =
|
suspend fun createDiscoverItem(search: SeerrSearchResult): DiscoverItem =
|
||||||
|
|
@ -280,7 +440,8 @@ class SeerrService
|
||||||
releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate),
|
releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate),
|
||||||
posterUrl = createImageUrl(ImageType.PRIMARY, search.posterPath, search.mediaInfo),
|
posterUrl = createImageUrl(ImageType.PRIMARY, search.posterPath, search.mediaInfo),
|
||||||
backDropUrl = createImageUrl(ImageType.BACKDROP, search.backdropPath, search.mediaInfo),
|
backDropUrl = createImageUrl(ImageType.BACKDROP, search.backdropPath, search.mediaInfo),
|
||||||
jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
logoUrl = createImageUrl(ImageType.LOGO, null, search.mediaInfo),
|
||||||
|
jellyfinItemId = search.mediaInfo?.jellyfinId,
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun createDiscoverItem(credit: CreditCast): DiscoverItem =
|
suspend fun createDiscoverItem(credit: CreditCast): DiscoverItem =
|
||||||
|
|
@ -293,7 +454,7 @@ class SeerrService
|
||||||
availability =
|
availability =
|
||||||
SeerrAvailability.from(credit.mediaInfo?.status)
|
SeerrAvailability.from(credit.mediaInfo?.status)
|
||||||
?: SeerrAvailability.UNKNOWN,
|
?: SeerrAvailability.UNKNOWN,
|
||||||
releaseDate = toLocalDate(credit.firstAirDate),
|
releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate),
|
||||||
posterUrl =
|
posterUrl =
|
||||||
createImageUrl(
|
createImageUrl(
|
||||||
ImageType.PRIMARY,
|
ImageType.PRIMARY,
|
||||||
|
|
@ -306,7 +467,8 @@ class SeerrService
|
||||||
credit.backdropPath,
|
credit.backdropPath,
|
||||||
credit.mediaInfo,
|
credit.mediaInfo,
|
||||||
),
|
),
|
||||||
jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
logoUrl = createImageUrl(ImageType.LOGO, null, credit.mediaInfo),
|
||||||
|
jellyfinItemId = credit.mediaInfo?.jellyfinId,
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun createDiscoverItem(credit: CreditCrew): DiscoverItem =
|
suspend fun createDiscoverItem(credit: CreditCrew): DiscoverItem =
|
||||||
|
|
@ -319,7 +481,7 @@ class SeerrService
|
||||||
availability =
|
availability =
|
||||||
SeerrAvailability.from(credit.mediaInfo?.status)
|
SeerrAvailability.from(credit.mediaInfo?.status)
|
||||||
?: SeerrAvailability.UNKNOWN,
|
?: SeerrAvailability.UNKNOWN,
|
||||||
releaseDate = toLocalDate(credit.firstAirDate),
|
releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate),
|
||||||
posterUrl =
|
posterUrl =
|
||||||
createImageUrl(
|
createImageUrl(
|
||||||
ImageType.PRIMARY,
|
ImageType.PRIMARY,
|
||||||
|
|
@ -332,6 +494,13 @@ class SeerrService
|
||||||
credit.backdropPath,
|
credit.backdropPath,
|
||||||
credit.mediaInfo,
|
credit.mediaInfo,
|
||||||
),
|
),
|
||||||
jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
logoUrl = createImageUrl(ImageType.LOGO, null, credit.mediaInfo),
|
||||||
|
jellyfinItemId = credit.mediaInfo?.jellyfinId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MediaInfo.jellyfinId: UUID?
|
||||||
|
get() = jellyfinMediaId4k?.toUUIDOrNull() ?: jellyfinMediaId?.toUUIDOrNull()
|
||||||
|
|
||||||
|
val MediaInfo.jellyfinIdAsString: String?
|
||||||
|
get() = jellyfinMediaId4k ?: jellyfinMediaId
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.ui.showToast
|
||||||
import dagger.hilt.android.qualifiers.ActivityContext
|
import dagger.hilt.android.qualifiers.ActivityContext
|
||||||
import dagger.hilt.android.scopes.ActivityScoped
|
import dagger.hilt.android.scopes.ActivityScoped
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
@ -94,6 +95,8 @@ class ServerEventListener
|
||||||
.joinToString("\n")
|
.joinToString("\n")
|
||||||
showToast(context, toast, Toast.LENGTH_LONG)
|
showToast(context, toast, Toast.LENGTH_LONG)
|
||||||
}
|
}
|
||||||
|
}.catch { ex ->
|
||||||
|
Timber.e(ex, "Error in websocket subscription")
|
||||||
}.launchIn(activity.lifecycleScope)
|
}.launchIn(activity.lifecycleScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,14 @@ package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.getLogCatLines
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
import com.google.protobuf.MessageLiteOrBuilder
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
@ -22,10 +25,10 @@ import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send media info to the server
|
* Send reports to the server such as media info or app logs
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class MediaReportService
|
class ServerReportService
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
|
|
@ -45,45 +48,65 @@ class MediaReportService
|
||||||
/**
|
/**
|
||||||
* Fetch the media info and send it to the server
|
* Fetch the media info and send it to the server
|
||||||
*/
|
*/
|
||||||
fun sendReportFor(itemId: UUID) {
|
fun sendMediaReportFor(itemId: UUID) {
|
||||||
ioScope.launchIO(ExceptionHandler(autoToast = true)) {
|
ioScope.launchIO(ExceptionHandler(autoToast = true)) {
|
||||||
val item = api.userLibraryApi.getItem(itemId = itemId).content
|
val item = api.userLibraryApi.getItem(itemId = itemId).content
|
||||||
sendReportFor(item)
|
sendMediaReportFor(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send the media report for the given item
|
* Send the media report for the given item
|
||||||
*/
|
*/
|
||||||
suspend fun sendReportFor(item: BaseItemDto) {
|
suspend fun sendMediaReportFor(item: BaseItemDto) {
|
||||||
val sources =
|
val sources =
|
||||||
item.mediaSources ?: api.userLibraryApi
|
item.mediaSources ?: api.userLibraryApi
|
||||||
.getItem(itemId = item.id)
|
.getItem(itemId = item.id)
|
||||||
.content.mediaSources
|
.content.mediaSources
|
||||||
val sourcesJson = json.encodeToString(sources)
|
val sourcesJson = json.encodeToString(sources)
|
||||||
val playbackPrefs = userPreferencesService.getCurrent().appPreferences.playbackPreferences
|
val appPreferences = userPreferencesService.getCurrent().appPreferences
|
||||||
val serverVersion = serverRepository.currentServer?.serverVersion
|
val serverVersion = serverRepository.currentServer?.serverVersion
|
||||||
val deviceProfile =
|
val deviceProfile =
|
||||||
deviceProfileService.getOrCreateDeviceProfile(playbackPrefs, serverVersion)
|
deviceProfileService.getOrCreateDeviceProfile(appPreferences, serverVersion)
|
||||||
val deviceProfileJson = json.encodeToString(deviceProfile)
|
val deviceProfileJson = json.encodeToString(deviceProfile)
|
||||||
val body =
|
val body =
|
||||||
|
buildLogHeader("Send media info") + "\n\n" +
|
||||||
"""
|
"""
|
||||||
Send media info
|
playbackPrefs=${appPreferences.playbackPreferences.toStringOneLine()}
|
||||||
|
|
||||||
|
experimental=${appPreferences.experimentalPreferences.toStringOneLine()}
|
||||||
|
|
||||||
|
mediaSources=$sourcesJson
|
||||||
|
|
||||||
|
deviceProfile=$deviceProfileJson
|
||||||
|
""".trimIndent()
|
||||||
|
body.chunked(2048).forEach { Timber.w(it) }
|
||||||
|
Timber.w("End send media info")
|
||||||
|
val response by api.clientLogApi.logFile(body)
|
||||||
|
showToast(context, "Sent! Filename=${response.fileName}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildLogHeader(title: String): String {
|
||||||
|
val serverVersion = serverRepository.currentServer?.serverVersion
|
||||||
|
return """
|
||||||
|
$title
|
||||||
serverVersion=$serverVersion
|
serverVersion=$serverVersion
|
||||||
clientInfo=$clientInfo
|
clientInfo=$clientInfo
|
||||||
|
flavor=${BuildConfig.FLAVOR}
|
||||||
deviceInfo=$deviceInfo
|
deviceInfo=$deviceInfo
|
||||||
manufacturer=${Build.MANUFACTURER}
|
manufacturer=${Build.MANUFACTURER}
|
||||||
model=${Build.MODEL}
|
model=${Build.MODEL}
|
||||||
apiLevel=${Build.VERSION.SDK_INT}
|
apiLevel=${Build.VERSION.SDK_INT}
|
||||||
|
|
||||||
playbackPrefs=${playbackPrefs.toString().replace("\n", ", ").replace("\t", " ")}
|
|
||||||
|
|
||||||
deviceProfile=$deviceProfileJson
|
|
||||||
|
|
||||||
mediaSources=$sourcesJson
|
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
Timber.w(body)
|
}
|
||||||
val response by api.clientLogApi.logFile(body)
|
|
||||||
|
suspend fun sendAppLogs() {
|
||||||
|
val logcat = getLogCatLines().joinToString("\n") { it.text }
|
||||||
|
val header = buildLogHeader("Send App Logs")
|
||||||
|
Timber.w(header)
|
||||||
|
val response by api.clientLogApi.logFile(header + "\n\n" + logcat)
|
||||||
showToast(context, "Sent! Filename=${response.fileName}")
|
showToast(context, "Sent! Filename=${response.fileName}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun MessageLiteOrBuilder.toStringOneLine(): String = toString().replace("\n", ", ").replace("\t", " ")
|
||||||
|
|
@ -27,9 +27,6 @@ class SetupNavigationManager
|
||||||
fun navigateTo(destination: SetupDestination) {
|
fun navigateTo(destination: SetupDestination) {
|
||||||
backStack[0] = destination
|
backStack[0] = destination
|
||||||
log()
|
log()
|
||||||
if (destination !is SetupDestination.AppContent) {
|
|
||||||
navigationManager.reloadHome()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun log() {
|
private fun log() {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
|
||||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
|
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserProfileSettings
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
|
@ -135,8 +137,8 @@ class StreamChoiceService
|
||||||
val seriesLang =
|
val seriesLang =
|
||||||
playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() }
|
playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() }
|
||||||
// If the user has chosen a different language for the series, prefer that
|
// If the user has chosen a different language for the series, prefer that
|
||||||
val audioLanguage = seriesLang ?: userConfig?.audioLanguagePreference
|
val audioLanguage =
|
||||||
|
seriesLang ?: getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig)
|
||||||
if (audioLanguage.isNotNullOrBlank()) {
|
if (audioLanguage.isNotNullOrBlank()) {
|
||||||
val sorted =
|
val sorted =
|
||||||
candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels })
|
candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels })
|
||||||
|
|
@ -226,23 +228,17 @@ class StreamChoiceService
|
||||||
): MediaStream? {
|
): MediaStream? {
|
||||||
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
|
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
|
||||||
return null
|
return null
|
||||||
} else if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
|
}
|
||||||
// Client-side manual override: User selected "Only Forced" in player menu
|
|
||||||
val seriesLang =
|
val seriesLang =
|
||||||
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
|
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
|
||||||
val subtitleLanguage =
|
val subtitleLanguage =
|
||||||
(seriesLang ?: userConfig?.subtitleLanguagePreference)
|
seriesLang ?: getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
|
||||||
?.takeIf { it.isNotNullOrBlank() }
|
if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
|
||||||
|
// Client-side manual override: User selected "Only Forced" in player menu
|
||||||
return findForcedTrack(candidates, subtitleLanguage, audioStreamLang)
|
return findForcedTrack(candidates, subtitleLanguage, audioStreamLang)
|
||||||
} else if (itemPlayback?.subtitleIndexEnabled == true) {
|
} else if (itemPlayback?.subtitleIndexEnabled == true) {
|
||||||
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
|
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
|
||||||
} else {
|
} else {
|
||||||
val seriesLang =
|
|
||||||
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
|
|
||||||
val subtitleLanguage =
|
|
||||||
(seriesLang ?: userConfig?.subtitleLanguagePreference)
|
|
||||||
?.takeIf { it.isNotNullOrBlank() }
|
|
||||||
|
|
||||||
val subtitleMode =
|
val subtitleMode =
|
||||||
when {
|
when {
|
||||||
playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> {
|
playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> {
|
||||||
|
|
@ -258,9 +254,17 @@ class StreamChoiceService
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
// Fallback to the user's preference
|
// Fallback to the user's preference
|
||||||
userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT
|
when (prefs.userPreferences?.subtitleMode) {
|
||||||
|
SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode
|
||||||
|
SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT
|
||||||
|
SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART
|
||||||
|
SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED
|
||||||
|
SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS
|
||||||
|
SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE
|
||||||
|
null -> SubtitlePlaybackMode.DEFAULT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} ?: SubtitlePlaybackMode.DEFAULT
|
||||||
val candidates =
|
val candidates =
|
||||||
candidates
|
candidates
|
||||||
.sortedWith(
|
.sortedWith(
|
||||||
|
|
@ -277,11 +281,13 @@ class StreamChoiceService
|
||||||
SubtitlePlaybackMode.ALWAYS -> {
|
SubtitlePlaybackMode.ALWAYS -> {
|
||||||
if (subtitleLanguage.isNotNullOrBlank()) {
|
if (subtitleLanguage.isNotNullOrBlank()) {
|
||||||
candidates.firstOrNull {
|
candidates.firstOrNull {
|
||||||
it.language.equals(subtitleLanguage, true) ||
|
// Prefer non-forced first
|
||||||
it.language.isUnknown
|
!it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage)
|
||||||
|
} ?: candidates.firstOrNull {
|
||||||
|
it.language.equalsLangOrUnknown(subtitleLanguage)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
candidates.firstOrNull()
|
candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -371,3 +377,38 @@ private val String?.isUnknown: Boolean
|
||||||
this.equals("undetermined", true) ||
|
this.equals("undetermined", true) ||
|
||||||
this.equals("mul", true) ||
|
this.equals("mul", true) ||
|
||||||
this.equals("zxx", true)
|
this.equals("zxx", true)
|
||||||
|
|
||||||
|
private fun String?.equalsLangOrUnknown(lang: String): Boolean = equals(lang, ignoreCase = true) || this.isUnknown
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Based on the user's preferences, get their preferred language for audio or subtitles
|
||||||
|
*
|
||||||
|
* @param prefs the [UserPreferences]
|
||||||
|
*/
|
||||||
|
fun getPreferredLanguage(
|
||||||
|
type: MediaStreamType,
|
||||||
|
prefs: UserPreferences,
|
||||||
|
userConfig: UserConfiguration?,
|
||||||
|
): String? {
|
||||||
|
val (pref, profileLang) =
|
||||||
|
when (type) {
|
||||||
|
MediaStreamType.AUDIO -> {
|
||||||
|
prefs.userPreferences?.preferredAudioLanguage to
|
||||||
|
userConfig?.audioLanguagePreference
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaStreamType.SUBTITLE -> {
|
||||||
|
prefs.userPreferences?.preferredSubtitleLanguage to
|
||||||
|
userConfig?.subtitleLanguagePreference
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
throw IllegalArgumentException("Only audio or subtitle supported, not $type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return when (pref) {
|
||||||
|
UserProfileSettings.USE_USER_PROFILE -> profileLang
|
||||||
|
UserProfileSettings.PREFER_ANY_LANGUAGE -> null
|
||||||
|
else -> pref
|
||||||
|
}?.takeIf { it.isNotNullOrBlank() }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import com.mayakapps.kache.InMemoryKache
|
import com.mayakapps.kache.InMemoryKache
|
||||||
import com.mayakapps.kache.ObjectKache
|
import com.mayakapps.kache.ObjectKache
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
|
@ -43,7 +43,7 @@ class SuggestionsCache
|
||||||
|
|
||||||
private val memoryCache =
|
private val memoryCache =
|
||||||
InMemoryKache<String, CachedSuggestions>(maxSize = 8) {
|
InMemoryKache<String, CachedSuggestions>(maxSize = 8) {
|
||||||
creationScope = CoroutineScope(Dispatchers.IO)
|
creationScope = CoroutineScope(WholphinDispatchers.IO)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cacheKey(
|
private fun cacheKey(
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
|
@ -81,7 +81,7 @@ class SuggestionsWorker
|
||||||
.takeIf { it > 0 }
|
.takeIf { it > 0 }
|
||||||
?: AppPreference.HomePageItems.defaultValue.toInt()
|
?: AppPreference.HomePageItems.defaultValue.toInt()
|
||||||
|
|
||||||
val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions")
|
val context = WholphinDispatchers.IO.limitedParallelism(2, "fetchSuggestions")
|
||||||
if (requestedParentId != null && requestedItemKind != null) {
|
if (requestedParentId != null && requestedItemKind != null) {
|
||||||
Timber.d(
|
Timber.d(
|
||||||
"Fetching on-demand suggestions for parent=%s kind=%s",
|
"Fetching on-demand suggestions for parent=%s kind=%s",
|
||||||
|
|
@ -128,7 +128,7 @@ class SuggestionsWorker
|
||||||
supervisorScope {
|
supervisorScope {
|
||||||
supportedViews
|
supportedViews
|
||||||
.map { view ->
|
.map { view ->
|
||||||
async(Dispatchers.IO) {
|
async(WholphinDispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
Timber.v("Fetching suggestions for parent=%s kind=%s", view.id, view.itemKind)
|
Timber.v("Fetching suggestions for parent=%s kind=%s", view.id, view.itemKind)
|
||||||
fetchAndCacheSuggestions(
|
fetchAndCacheSuggestions(
|
||||||
|
|
@ -293,7 +293,7 @@ class SuggestionsWorker
|
||||||
limit = freshLimit,
|
limit = freshLimit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Default) {
|
withContext(WholphinDispatchers.Default) {
|
||||||
val contextual = contextualDeferred.await()
|
val contextual = contextualDeferred.await()
|
||||||
val random = randomDeferred.await()
|
val random = randomDeferred.await()
|
||||||
val fresh = freshDeferred.await()
|
val fresh = freshDeferred.await()
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,10 @@ import com.github.damontecres.wholphin.preferences.ThemeSongVolume
|
||||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import com.github.damontecres.wholphin.util.profile.Codec
|
import com.github.damontecres.wholphin.util.profile.Codec
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -102,7 +102,7 @@ class ThemeSongPlayer
|
||||||
Codec.Audio.FLAC,
|
Codec.Audio.FLAC,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
player.apply {
|
player.apply {
|
||||||
stop()
|
stop()
|
||||||
volume = volumeLevel
|
volume = volumeLevel
|
||||||
|
|
@ -127,7 +127,7 @@ class ThemeSongPlayer
|
||||||
scope.launch {
|
scope.launch {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
state.value.job?.cancelAndJoin()
|
state.value.job?.cancelAndJoin()
|
||||||
withContext(Dispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
Timber.v("Stopping theme song")
|
Timber.v("Stopping theme song")
|
||||||
player.stop()
|
player.stop()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class TrailerService
|
||||||
// TODO would be nice to clean up the trailer name
|
// TODO would be nice to clean up the trailer name
|
||||||
// ?.replace(item.name ?: "", "")
|
// ?.replace(item.name ?: "", "")
|
||||||
// ?.removePrefix(" - ")
|
// ?.removePrefix(" - ")
|
||||||
?: context.getString(R.string.trailer)
|
?: context.resources.getQuantityString(R.plurals.trailers, 1)
|
||||||
val subtitle =
|
val subtitle =
|
||||||
when (url.toUri().host) {
|
when (url.toUri().host) {
|
||||||
"youtube.com", "www.youtube.com" -> "YouTube"
|
"youtube.com", "www.youtube.com" -> "YouTube"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
|
@ -21,8 +22,8 @@ import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.util.Version
|
import com.github.damontecres.wholphin.util.Version
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
@ -61,8 +62,6 @@ class UpdateChecker
|
||||||
|
|
||||||
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
|
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
|
||||||
|
|
||||||
private const val PERMISSION_REQUEST_CODE = 123456
|
|
||||||
|
|
||||||
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
|
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
|
||||||
|
|
||||||
val ACTIVE = BuildConfig.UPDATING_ENABLED
|
val ACTIVE = BuildConfig.UPDATING_ENABLED
|
||||||
|
|
@ -131,7 +130,7 @@ class UpdateChecker
|
||||||
suspend fun getRelease(version: Version): Release? {
|
suspend fun getRelease(version: Version): Release? {
|
||||||
val url =
|
val url =
|
||||||
"https://api.github.com/repos/damontecres/Wholphin/releases/tags/v${version.major}.${version.minor}.${version.patch}"
|
"https://api.github.com/repos/damontecres/Wholphin/releases/tags/v${version.major}.${version.minor}.${version.patch}"
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(WholphinDispatchers.IO) {
|
||||||
val request =
|
val request =
|
||||||
Request
|
Request
|
||||||
.Builder()
|
.Builder()
|
||||||
|
|
@ -146,7 +145,7 @@ class UpdateChecker
|
||||||
* Get the latest released version
|
* Get the latest released version
|
||||||
*/
|
*/
|
||||||
suspend fun getLatestRelease(updateUrl: String): Release? =
|
suspend fun getLatestRelease(updateUrl: String): Release? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(WholphinDispatchers.IO) {
|
||||||
val request =
|
val request =
|
||||||
Request
|
Request
|
||||||
.Builder()
|
.Builder()
|
||||||
|
|
@ -158,8 +157,8 @@ class UpdateChecker
|
||||||
|
|
||||||
private fun getRelease(request: Request): Release? {
|
private fun getRelease(request: Request): Release? {
|
||||||
return okHttpClient.newCall(request).execute().use {
|
return okHttpClient.newCall(request).execute().use {
|
||||||
if (it.isSuccessful && it.body != null) {
|
if (it.isSuccessful) {
|
||||||
val result = Json.parseToJsonElement(it.body!!.string())
|
val result = Json.parseToJsonElement(it.body.string())
|
||||||
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
|
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
|
||||||
val version = Version.tryFromString(name)
|
val version = Version.tryFromString(name)
|
||||||
val publishedAt =
|
val publishedAt =
|
||||||
|
|
@ -195,11 +194,12 @@ class UpdateChecker
|
||||||
/**
|
/**
|
||||||
* Download and install an update
|
* Download and install an update
|
||||||
*/
|
*/
|
||||||
|
@SuppressLint("RequestInstallPackagesPolicy")
|
||||||
suspend fun installRelease(
|
suspend fun installRelease(
|
||||||
release: Release,
|
release: Release,
|
||||||
callback: DownloadCallback,
|
callback: DownloadCallback,
|
||||||
) {
|
) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(WholphinDispatchers.IO) {
|
||||||
cleanup()
|
cleanup()
|
||||||
val request =
|
val request =
|
||||||
Request
|
Request
|
||||||
|
|
@ -208,9 +208,9 @@ class UpdateChecker
|
||||||
.get()
|
.get()
|
||||||
.build()
|
.build()
|
||||||
okHttpClient.newCall(request).execute().use {
|
okHttpClient.newCall(request).execute().use {
|
||||||
if (it.isSuccessful && it.body != null) {
|
if (it.isSuccessful) {
|
||||||
Timber.v("Request successful for ${release.downloadUrl}")
|
Timber.v("Request successful for ${release.downloadUrl}")
|
||||||
withContext(Dispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
callback.contentLength(it.body.contentLength())
|
callback.contentLength(it.body.contentLength())
|
||||||
}
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
|
@ -230,7 +230,7 @@ class UpdateChecker
|
||||||
contentValues,
|
contentValues,
|
||||||
)
|
)
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
it.body!!.byteStream().use { input ->
|
it.body.byteStream().use { input ->
|
||||||
resolver.openOutputStream(uri).use { output ->
|
resolver.openOutputStream(uri).use { output ->
|
||||||
copyTo(input, output!!, callback = callback)
|
copyTo(input, output!!, callback = callback)
|
||||||
}
|
}
|
||||||
|
|
@ -290,7 +290,7 @@ class UpdateChecker
|
||||||
downloadDir.mkdirs()
|
downloadDir.mkdirs()
|
||||||
val targetFile = File(downloadDir, APK_NAME)
|
val targetFile = File(downloadDir, APK_NAME)
|
||||||
targetFile.outputStream().use { output ->
|
targetFile.outputStream().use { output ->
|
||||||
response.body!!.byteStream().use { input ->
|
response.body.byteStream().use { input ->
|
||||||
copyTo(input, output, callback = callback)
|
copyTo(input, output, callback = callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -374,7 +374,7 @@ data class Release(
|
||||||
Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"),
|
Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"),
|
||||||
"#$1",
|
"#$1",
|
||||||
)
|
)
|
||||||
// Remove the last line for full changelog since its just a link
|
// Remove the last line for full changelog since it's just a link
|
||||||
.replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "")
|
.replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -387,29 +387,30 @@ interface DownloadCallback {
|
||||||
suspend fun copyTo(
|
suspend fun copyTo(
|
||||||
input: InputStream,
|
input: InputStream,
|
||||||
out: OutputStream,
|
out: OutputStream,
|
||||||
bufferSize: Int = 16 * 1024,
|
bufferSize: Int = 64 * 1024,
|
||||||
callback: DownloadCallback,
|
callback: DownloadCallback,
|
||||||
): Long {
|
): Long =
|
||||||
|
withContext(WholphinDispatchers.IO) {
|
||||||
var bytesCopied: Long = 0
|
var bytesCopied: Long = 0
|
||||||
val buffer = ByteArray(bufferSize)
|
val buffer = ByteArray(bufferSize)
|
||||||
var bytes = input.read(buffer)
|
var bytes = input.read(buffer)
|
||||||
while (bytes >= 0) {
|
while (bytes >= 0) {
|
||||||
out.write(buffer, 0, bytes)
|
out.write(buffer, 0, bytes)
|
||||||
bytesCopied += bytes
|
bytesCopied += bytes
|
||||||
withContext(Dispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
callback.bytesDownloaded(bytesCopied)
|
callback.bytesDownloaded(bytesCopied)
|
||||||
}
|
}
|
||||||
bytes = input.read(buffer)
|
bytes = input.read(buffer)
|
||||||
}
|
}
|
||||||
return bytesCopied
|
return@withContext bytesCopied
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDownloadUrl(
|
fun getDownloadUrl(
|
||||||
assets: JsonArray,
|
assets: JsonArray,
|
||||||
debug: Boolean,
|
debug: Boolean,
|
||||||
supportedAbis: List<String> = Build.SUPPORTED_ABIS.toList(),
|
supportedABIs: List<String> = Build.SUPPORTED_ABIS.toList(),
|
||||||
): String? {
|
): String? {
|
||||||
val abiSuffix = supportedAbis.firstOrNull().let { if (it != null) "-$it" else "" }
|
val abiSuffix = supportedABIs.firstOrNull().let { if (it != null) "-$it" else "" }
|
||||||
val releaseSuffix = if (debug) "-debug" else "-release"
|
val releaseSuffix = if (debug) "-debug" else "-release"
|
||||||
val preferredNames =
|
val preferredNames =
|
||||||
buildList {
|
buildList {
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@ import androidx.datastore.core.DataStore
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -19,13 +20,10 @@ class UserPreferencesService
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||||
) {
|
) {
|
||||||
val flow = preferencesDataStore.data.map { UserPreferences(it) }
|
val flow: Flow<UserPreferences> =
|
||||||
|
preferencesDataStore.data.combine(serverRepository.currentUserFlow) { appPrefs, user ->
|
||||||
|
UserPreferences(appPrefs, user?.appPreferences)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getCurrent(): UserPreferences =
|
suspend fun getCurrent(): UserPreferences = flow.first()
|
||||||
serverRepository.currentUserDto!!.configuration.let { userConfig ->
|
|
||||||
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
|
||||||
UserPreferences(
|
|
||||||
appPrefs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,9 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
|
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import dagger.hilt.android.qualifiers.ActivityContext
|
import dagger.hilt.android.qualifiers.ActivityContext
|
||||||
import dagger.hilt.android.scopes.ActivityScoped
|
import dagger.hilt.android.scopes.ActivityScoped
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.supervisorScope
|
import kotlinx.coroutines.supervisorScope
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
@ -56,7 +56,7 @@ class UserSwitchListener
|
||||||
user.uiLanguage?.let { LocaleListCompat.forLanguageTags(it) }
|
user.uiLanguage?.let { LocaleListCompat.forLanguageTags(it) }
|
||||||
?: LocaleListCompat.getEmptyLocaleList()
|
?: LocaleListCompat.getEmptyLocaleList()
|
||||||
Timber.i("Switching locale to %s", localeList)
|
Timber.i("Switching locale to %s", localeList)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
AppCompatDelegate.setApplicationLocales(localeList)
|
AppCompatDelegate.setApplicationLocales(localeList)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,13 @@
|
||||||
package com.github.damontecres.wholphin.services.hilt
|
package com.github.damontecres.wholphin.services.hilt
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
|
||||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
|
||||||
import com.github.damontecres.wholphin.services.SeerrApi
|
import com.github.damontecres.wholphin.services.SeerrApi
|
||||||
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
|
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
|
@ -20,9 +15,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.jellyfin.sdk.Jellyfin
|
import org.jellyfin.sdk.Jellyfin
|
||||||
import org.jellyfin.sdk.android.androidDevice
|
import org.jellyfin.sdk.android.androidDevice
|
||||||
|
|
@ -49,21 +42,21 @@ annotation class AuthOkHttpClient
|
||||||
annotation class StandardOkHttpClient
|
annotation class StandardOkHttpClient
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [CoroutineScope] with [Dispatchers.IO]
|
* A [CoroutineScope] with [WholphinDispatchers.IO]
|
||||||
*/
|
*/
|
||||||
@Qualifier
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class IoCoroutineScope
|
annotation class IoCoroutineScope
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [CoroutineScope] with [Dispatchers.Default]
|
* A [CoroutineScope] with [WholphinDispatchers.Default]
|
||||||
*/
|
*/
|
||||||
@Qualifier
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class DefaultCoroutineScope
|
annotation class DefaultCoroutineScope
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [Dispatchers.IO]
|
* [WholphinDispatchers.IO]
|
||||||
*
|
*
|
||||||
* @see IoCoroutineScope
|
* @see IoCoroutineScope
|
||||||
*/
|
*/
|
||||||
|
|
@ -72,7 +65,7 @@ annotation class DefaultCoroutineScope
|
||||||
annotation class IoDispatcher
|
annotation class IoDispatcher
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [Dispatchers.Default]
|
* [WholphinDispatchers.Default]
|
||||||
*
|
*
|
||||||
* @see DefaultCoroutineScope
|
* @see DefaultCoroutineScope
|
||||||
*/
|
*/
|
||||||
|
|
@ -160,55 +153,10 @@ object AppModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
fun apiClient(jellyfin: Jellyfin) = jellyfin.createApi()
|
fun apiClient(jellyfin: Jellyfin) = jellyfin.createApi()
|
||||||
|
|
||||||
/**
|
|
||||||
* Implementation of [RememberTabManager] which remembers by server, user, & item
|
|
||||||
*/
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun rememberTabManager(
|
|
||||||
serverRepository: ServerRepository,
|
|
||||||
appPreference: DataStore<AppPreferences>,
|
|
||||||
@IoCoroutineScope scope: CoroutineScope,
|
|
||||||
) = object : RememberTabManager {
|
|
||||||
fun key(itemId: String): String =
|
|
||||||
serverRepository.current.value.let {
|
|
||||||
"${it?.server?.id}_${it?.user?.id}_$itemId"
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getRememberedTab(
|
|
||||||
preferences: UserPreferences,
|
|
||||||
itemId: String,
|
|
||||||
defaultTab: Int,
|
|
||||||
): Int {
|
|
||||||
if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) {
|
|
||||||
return preferences.appPreferences.interfacePreferences
|
|
||||||
.getRememberedTabsOrDefault(key(itemId), defaultTab)
|
|
||||||
} else {
|
|
||||||
return defaultTab
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun saveRememberedTab(
|
|
||||||
preferences: UserPreferences,
|
|
||||||
itemId: String,
|
|
||||||
tabIndex: Int,
|
|
||||||
) {
|
|
||||||
if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) {
|
|
||||||
scope.launch(ExceptionHandler()) {
|
|
||||||
appPreference.updateData {
|
|
||||||
it.updateInterfacePreferences {
|
|
||||||
putRememberedTabs(key(itemId), tabIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@IoDispatcher
|
@IoDispatcher
|
||||||
fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
fun ioDispatcher(): CoroutineDispatcher = WholphinDispatchers.IO
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|
@ -220,7 +168,7 @@ object AppModule {
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@DefaultDispatcher
|
@DefaultDispatcher
|
||||||
fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
|
fun defaultDispatcher(): CoroutineDispatcher = WholphinDispatchers.Default
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
||||||
import com.github.damontecres.wholphin.data.Migrations
|
import com.github.damontecres.wholphin.data.Migrations
|
||||||
import com.github.damontecres.wholphin.data.PlaybackEffectDao
|
import com.github.damontecres.wholphin.data.PlaybackEffectDao
|
||||||
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
||||||
|
import com.github.damontecres.wholphin.data.RememberedTabDao
|
||||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||||
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -43,7 +44,7 @@ object DatabaseModule {
|
||||||
).addMigrations(Migrations.Migrate2to3)
|
).addMigrations(Migrations.Migrate2to3)
|
||||||
// .setQueryCallback({ sqlQuery, args ->
|
// .setQueryCallback({ sqlQuery, args ->
|
||||||
// Timber.v("sqlQuery=$sqlQuery, args=$args")
|
// Timber.v("sqlQuery=$sqlQuery, args=$args")
|
||||||
// }, Dispatchers.IO.asExecutor())
|
// }, WholphinDispatchers.IO.asExecutor())
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|
@ -74,6 +75,10 @@ object DatabaseModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao()
|
fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun userPreferencesDataStore(
|
fun userPreferencesDataStore(
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.services.IntentService
|
||||||
import com.github.damontecres.wholphin.services.LatestNextUpService
|
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
|
|
@ -226,27 +227,27 @@ class TvProviderWorker
|
||||||
|
|
||||||
setIntent(
|
setIntent(
|
||||||
Intent(context, MainActivity::class.java)
|
Intent(context, MainActivity::class.java)
|
||||||
.putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
|
.putExtra(IntentService.INTENT_ITEM_ID, item.id.toString())
|
||||||
.putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
|
.putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName)
|
||||||
.apply {
|
.apply {
|
||||||
if (item.type == BaseItemKind.EPISODE) {
|
if (item.type == BaseItemKind.EPISODE) {
|
||||||
putExtra(
|
putExtra(
|
||||||
MainActivity.INTENT_SERIES_ID,
|
IntentService.INTENT_SERIES_ID,
|
||||||
dto.seriesId?.toString(),
|
dto.seriesId?.toString(),
|
||||||
)
|
)
|
||||||
putExtra(
|
putExtra(
|
||||||
MainActivity.INTENT_SEASON_ID,
|
IntentService.INTENT_SEASON_ID,
|
||||||
dto.seasonId?.toString(),
|
dto.seasonId?.toString(),
|
||||||
)
|
)
|
||||||
dto.parentIndexNumber?.let {
|
dto.parentIndexNumber?.let {
|
||||||
putExtra(
|
putExtra(
|
||||||
MainActivity.INTENT_SEASON_NUMBER,
|
IntentService.INTENT_SEASON_NUMBER,
|
||||||
it,
|
it,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
dto.indexNumber?.let {
|
dto.indexNumber?.let {
|
||||||
putExtra(
|
putExtra(
|
||||||
MainActivity.INTENT_EPISODE_NUMBER,
|
IntentService.INTENT_EPISODE_NUMBER,
|
||||||
it,
|
it,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -264,6 +265,7 @@ class TvProviderWorker
|
||||||
.getLatestMedia(
|
.getLatestMedia(
|
||||||
GetLatestMediaRequest(
|
GetLatestMediaRequest(
|
||||||
fields = SlimItemFields,
|
fields = SlimItemFields,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
|
||||||
imageTypeLimit = 1,
|
imageTypeLimit = 1,
|
||||||
parentId = null,
|
parentId = null,
|
||||||
groupItems = true,
|
groupItems = true,
|
||||||
|
|
@ -362,27 +364,27 @@ class TvProviderWorker
|
||||||
|
|
||||||
setIntent(
|
setIntent(
|
||||||
Intent(context, MainActivity::class.java)
|
Intent(context, MainActivity::class.java)
|
||||||
.putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
|
.putExtra(IntentService.INTENT_ITEM_ID, item.id.toString())
|
||||||
.putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
|
.putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName)
|
||||||
.apply {
|
.apply {
|
||||||
if (item.type == BaseItemKind.EPISODE) {
|
if (item.type == BaseItemKind.EPISODE) {
|
||||||
putExtra(
|
putExtra(
|
||||||
MainActivity.INTENT_SERIES_ID,
|
IntentService.INTENT_SERIES_ID,
|
||||||
dto.seriesId?.toString(),
|
dto.seriesId?.toString(),
|
||||||
)
|
)
|
||||||
putExtra(
|
putExtra(
|
||||||
MainActivity.INTENT_SEASON_ID,
|
IntentService.INTENT_SEASON_ID,
|
||||||
dto.seasonId?.toString(),
|
dto.seasonId?.toString(),
|
||||||
)
|
)
|
||||||
dto.parentIndexNumber?.let {
|
dto.parentIndexNumber?.let {
|
||||||
putExtra(
|
putExtra(
|
||||||
MainActivity.INTENT_SEASON_NUMBER,
|
IntentService.INTENT_SEASON_NUMBER,
|
||||||
it,
|
it,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
dto.indexNumber?.let {
|
dto.indexNumber?.let {
|
||||||
putExtra(
|
putExtra(
|
||||||
MainActivity.INTENT_EPISODE_NUMBER,
|
IntentService.INTENT_EPISODE_NUMBER,
|
||||||
it,
|
it,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import coil3.disk.DiskCache
|
||||||
import coil3.disk.directory
|
import coil3.disk.directory
|
||||||
import coil3.memory.MemoryCache
|
import coil3.memory.MemoryCache
|
||||||
import coil3.network.CacheStrategy
|
import coil3.network.CacheStrategy
|
||||||
|
import coil3.network.ConnectivityChecker
|
||||||
import coil3.network.NetworkRequest
|
import coil3.network.NetworkRequest
|
||||||
import coil3.network.NetworkResponse
|
import coil3.network.NetworkResponse
|
||||||
import coil3.network.cachecontrol.CacheControlCacheStrategy
|
import coil3.network.cachecontrol.CacheControlCacheStrategy
|
||||||
|
|
@ -95,6 +96,8 @@ fun CoilConfig(
|
||||||
OkHttpNetworkFetcherFactory(
|
OkHttpNetworkFetcherFactory(
|
||||||
cacheStrategy = { WholphinCacheStrategy(CacheControlCacheStrategy()) },
|
cacheStrategy = { WholphinCacheStrategy(CacheControlCacheStrategy()) },
|
||||||
callFactory = { client },
|
callFactory = { client },
|
||||||
|
// Disable connectivity checker which may be unreliable for VPNs and/or some versions of Android
|
||||||
|
connectivityChecker = { ConnectivityChecker { true } },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}.build()
|
}.build()
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,9 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumnSaver
|
import com.github.damontecres.wholphin.ui.data.RowColumnSaver
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.CoroutineStart
|
import kotlinx.coroutines.CoroutineStart
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
|
@ -74,6 +74,8 @@ inline fun <T> List<T>.indexOfFirstOrNull(predicate: (T) -> Boolean): Int? {
|
||||||
return if (index >= 0) index else null
|
return if (index >= 0) index else null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Iterable<CharSequence?>.joinNotBlank(sep: String): String? = filter { it.isNotNullOrBlank() }.letNotEmpty { it.joinToString(sep) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Try to call [FocusRequester.requestFocus], but catch & log the exception if something is not configured properly
|
* Try to call [FocusRequester.requestFocus], but catch & log the exception if something is not configured properly
|
||||||
*/
|
*/
|
||||||
|
|
@ -242,7 +244,7 @@ val BaseItemDto.timeRemaining: Duration?
|
||||||
get() =
|
get() =
|
||||||
userData?.playbackPositionTicks?.let {
|
userData?.playbackPositionTicks?.let {
|
||||||
if (it > 0) {
|
if (it > 0) {
|
||||||
runTimeTicks?.minus(it)?.ticks
|
runTimeTicks?.minus(it)?.ticks?.takeIf { it > Duration.ZERO }
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
@ -359,34 +361,34 @@ suspend fun showToast(
|
||||||
context: Context,
|
context: Context,
|
||||||
text: CharSequence,
|
text: CharSequence,
|
||||||
duration: Int,
|
duration: Int,
|
||||||
) = withContext(Dispatchers.Main) {
|
) = withContext(WholphinDispatchers.Main) {
|
||||||
Toast.makeText(context, text, duration).show()
|
Toast.makeText(context, text, duration).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun showToast(
|
suspend fun showToast(
|
||||||
context: Context,
|
context: Context,
|
||||||
text: CharSequence,
|
text: CharSequence,
|
||||||
) = withContext(Dispatchers.Main) {
|
) = withContext(WholphinDispatchers.Main) {
|
||||||
Toast.makeText(context, text, Toast.LENGTH_LONG).show()
|
Toast.makeText(context, text, Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launches a coroutine with [Dispatchers.IO] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler]
|
* Launches a coroutine with [WholphinDispatchers.IO] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler]
|
||||||
*/
|
*/
|
||||||
fun CoroutineScope.launchIO(
|
fun CoroutineScope.launchIO(
|
||||||
context: CoroutineContext = ExceptionHandler(),
|
context: CoroutineContext = ExceptionHandler(),
|
||||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||||
block: suspend CoroutineScope.() -> Unit,
|
block: suspend CoroutineScope.() -> Unit,
|
||||||
): Job = launch(context = Dispatchers.IO + context, start = start, block = block)
|
): Job = launch(context = WholphinDispatchers.IO + context, start = start, block = block)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launches a coroutine with [Dispatchers.Default] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler]
|
* Launches a coroutine with [WholphinDispatchers.Default] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler]
|
||||||
*/
|
*/
|
||||||
fun CoroutineScope.launchDefault(
|
fun CoroutineScope.launchDefault(
|
||||||
context: CoroutineContext = ExceptionHandler(),
|
context: CoroutineContext = ExceptionHandler(),
|
||||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||||
block: suspend CoroutineScope.() -> Unit,
|
block: suspend CoroutineScope.() -> Unit,
|
||||||
): Job = launch(context = Dispatchers.Default + context, start = start, block = block)
|
): Job = launch(context = WholphinDispatchers.Default + context, start = start, block = block)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a UUID to the format used server-side (ie without hyphens).
|
* Converts a UUID to the format used server-side (ie without hyphens).
|
||||||
|
|
@ -410,7 +412,7 @@ fun logTab(
|
||||||
ACRA.errorReporter.putCustomData("tabInfo", info)
|
ACRA.errorReporter.putCustomData("tabInfo", info)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
|
suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(WholphinDispatchers.Main, block)
|
||||||
|
|
||||||
fun Modifier.dimAndBlur(enabled: Boolean) =
|
fun Modifier.dimAndBlur(enabled: Boolean) =
|
||||||
this.ifElse(
|
this.ifElse(
|
||||||
|
|
@ -425,6 +427,9 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
|
||||||
useSeriesForPrimary: Boolean,
|
useSeriesForPrimary: Boolean,
|
||||||
) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
||||||
|
|
||||||
|
fun Response<BaseItemDtoQueryResult>.toBaseItems(useSeriesForPrimary: Boolean = false) =
|
||||||
|
this.content.items.map { BaseItem(it, useSeriesForPrimary = useSeriesForPrimary) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if this, coalescing nulls to zero, is greater than that
|
* Check if this, coalescing nulls to zero, is greater than that
|
||||||
*/
|
*/
|
||||||
|
|
@ -446,3 +451,15 @@ fun <T> Flow<T>.collectLatestIn(
|
||||||
* Easy way to combine two flows into a [Pair]
|
* Easy way to combine two flows into a [Pair]
|
||||||
*/
|
*/
|
||||||
fun <T1, T2> Flow<T1>.combinePair(flow: Flow<T2>): Flow<Pair<T1, T2>> = combine(flow) { t1, t2 -> Pair(t1, t2) }
|
fun <T1, T2> Flow<T1>.combinePair(flow: Flow<T2>): Flow<Pair<T1, T2>> = combine(flow) { t1, t2 -> Pair(t1, t2) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Easy way to combine three flows into a [Triple]
|
||||||
|
*/
|
||||||
|
fun <T1, T2, T3> Flow<T1>.combineTriple(
|
||||||
|
flow: Flow<T2>,
|
||||||
|
flow2: Flow<T3>,
|
||||||
|
): Flow<Triple<T1, T2, T3>> =
|
||||||
|
combine(flow) { t1, t2 -> Pair(t1, t2) }
|
||||||
|
.combine(flow2) { (t1, t2), t3 ->
|
||||||
|
Triple(t1, t2, t3)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
package com.github.damontecres.wholphin.ui
|
package com.github.damontecres.wholphin.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.text.format.DateFormat
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.text.appendInlineContent
|
import androidx.compose.foundation.text.appendInlineContent
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
|
@ -12,19 +15,36 @@ import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.DateTimeParseException
|
import java.time.format.DateTimeParseException
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
private var timeFormatter: DateTimeFormatter =
|
/**
|
||||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault())
|
* Format the time-of-day of [time] honouring the device's 12/24-hour setting and the current
|
||||||
|
* locale. [context] should be an Activity/composition context (e.g. `LocalContext.current`) so an
|
||||||
|
* in-app language switch (`AppCompatDelegate.setApplicationLocales`) is reflected; the application
|
||||||
|
* context may carry a stale locale.
|
||||||
|
*/
|
||||||
|
fun formatTime(
|
||||||
|
context: Context,
|
||||||
|
time: LocalDateTime,
|
||||||
|
): String = formatTime(context, time.toLocalTime())
|
||||||
|
|
||||||
fun getTimeFormatter(): DateTimeFormatter {
|
fun formatTime(
|
||||||
if (timeFormatter.locale != Locale.getDefault()) {
|
context: Context,
|
||||||
timeFormatter = timeFormatter.withLocale(Locale.getDefault())
|
time: LocalTime,
|
||||||
}
|
): String {
|
||||||
return timeFormatter
|
val instant =
|
||||||
|
time
|
||||||
|
.atDate(LocalDate.now())
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.toInstant()
|
||||||
|
return DateFormat.getTimeFormat(context).format(Date.from(instant))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var dateFormatter: DateTimeFormatter =
|
private var dateFormatter: DateTimeFormatter =
|
||||||
|
|
@ -59,36 +79,74 @@ fun toLocalDate(date: String?): LocalDate? =
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Falls back to the default format if the application is not initialized (e.g. unit tests)
|
||||||
|
private fun formatStringWithFallback(
|
||||||
|
@StringRes resId: Int,
|
||||||
|
defaultFormat: String,
|
||||||
|
vararg args: Any,
|
||||||
|
): String {
|
||||||
|
val resources =
|
||||||
|
try {
|
||||||
|
WholphinApplication.instance.resources
|
||||||
|
} catch (_: UninitializedPropertyAccessException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
return resources?.getString(resId, *args)
|
||||||
|
?: String.format(Locale.getDefault(), defaultFormat, *args)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the item has season & episode info, format as `S# E#`
|
* Format season & episode numbers using localized string resources.
|
||||||
|
* Returns null if [season] or [episode] is missing.
|
||||||
|
*/
|
||||||
|
fun formatSeasonEpisode(
|
||||||
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
|
episodeEnd: Int? = null,
|
||||||
|
padded: Boolean = false,
|
||||||
|
): String? {
|
||||||
|
if (season == null || episode == null) return null
|
||||||
|
return if (episodeEnd != null) {
|
||||||
|
if (padded) {
|
||||||
|
formatStringWithFallback(R.string.season_episode_number_padded_range, "S%1\$02dE%2\$02d-E%3\$02d", season, episode, episodeEnd)
|
||||||
|
} else {
|
||||||
|
formatStringWithFallback(R.string.season_episode_number_range, "S%1\$d E%2\$d-E%3\$d", season, episode, episodeEnd)
|
||||||
|
}
|
||||||
|
} else if (padded) {
|
||||||
|
formatStringWithFallback(R.string.season_episode_number_padded, "S%1\$02dE%2\$02d", season, episode)
|
||||||
|
} else {
|
||||||
|
formatStringWithFallback(R.string.season_episode_number, "S%1\$d E%2\$d", season, episode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact episode-only label, e.g. `E3`. */
|
||||||
|
fun formatEpisodeNumber(episode: Int): String = formatStringWithFallback(R.string.episode_number_short, "E%1\$d", episode)
|
||||||
|
|
||||||
|
/** Fallback season title when the library has no season name, e.g. `Season 2`. */
|
||||||
|
fun formatSeasonNumber(season: Int): String = formatStringWithFallback(R.string.season_number, "Season %1\$d", season)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the item has season & episode info, format as localized `S# E#`
|
||||||
*/
|
*/
|
||||||
val BaseItemDto.seasonEpisode: String?
|
val BaseItemDto.seasonEpisode: String?
|
||||||
get() =
|
get() =
|
||||||
if (parentIndexNumber != null && indexNumber != null && indexNumberEnd != null) {
|
formatSeasonEpisode(
|
||||||
"S$parentIndexNumber E$indexNumber-E$indexNumberEnd"
|
parentIndexNumber,
|
||||||
} else if (parentIndexNumber != null && indexNumber != null) {
|
indexNumber,
|
||||||
"S$parentIndexNumber E$indexNumber"
|
indexNumberEnd,
|
||||||
} else {
|
)
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the item has season & episode info, format padded as `S## E##`
|
* If the item has season & episode info, format padded as localized `S##E##`
|
||||||
*/
|
*/
|
||||||
val BaseItemDto.seasonEpisodePadded: String?
|
val BaseItemDto.seasonEpisodePadded: String?
|
||||||
get() =
|
get() =
|
||||||
if (parentIndexNumber != null && indexNumber != null) {
|
formatSeasonEpisode(
|
||||||
val season = parentIndexNumber?.toString()?.padStart(2, '0')
|
parentIndexNumber,
|
||||||
val episode = indexNumber?.toString()?.padStart(2, '0')
|
indexNumber,
|
||||||
val endEpisode = indexNumberEnd?.toString()?.padStart(2, '0')
|
indexNumberEnd,
|
||||||
if (endEpisode != null) {
|
padded = true,
|
||||||
"S${season}E$episode-E$endEpisode"
|
)
|
||||||
} else {
|
|
||||||
"S${season}E$episode"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
val BaseItemDto.seriesProductionYears: String?
|
val BaseItemDto.seriesProductionYears: String?
|
||||||
get() =
|
get() =
|
||||||
|
|
@ -200,44 +258,64 @@ fun listToDotString(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val BaseItemKind.titleStringRes: Int
|
||||||
|
get() = formatTypeName(this)
|
||||||
|
|
||||||
@StringRes
|
@StringRes
|
||||||
fun formatTypeName(type: BaseItemKind): Int =
|
fun formatTypeName(type: BaseItemKind): Int =
|
||||||
when (type) {
|
when (type) {
|
||||||
BaseItemKind.MOVIE -> R.string.movies
|
BaseItemKind.MOVIE -> R.string.movies_title
|
||||||
BaseItemKind.SERIES -> R.string.tv_shows
|
BaseItemKind.SERIES -> R.string.tv_shows_title
|
||||||
BaseItemKind.EPISODE -> R.string.episodes
|
BaseItemKind.EPISODE -> R.string.episodes
|
||||||
BaseItemKind.VIDEO -> R.string.videos
|
BaseItemKind.VIDEO -> R.string.videos
|
||||||
BaseItemKind.PLAYLIST -> R.string.playlists
|
BaseItemKind.PLAYLIST -> R.string.playlists
|
||||||
BaseItemKind.PERSON -> R.string.people
|
BaseItemKind.PERSON -> R.string.people_title
|
||||||
BaseItemKind.BOX_SET -> R.string.collections
|
BaseItemKind.BOX_SET -> R.string.collections
|
||||||
BaseItemKind.AUDIO -> TODO()
|
BaseItemKind.AUDIO -> R.string.songs
|
||||||
BaseItemKind.CHANNEL -> R.string.channels
|
BaseItemKind.CHANNEL -> R.string.channels
|
||||||
BaseItemKind.GENRE -> R.string.genres
|
BaseItemKind.GENRE -> R.string.genres
|
||||||
BaseItemKind.LIVE_TV_CHANNEL -> R.string.channels
|
BaseItemKind.LIVE_TV_CHANNEL -> R.string.channels
|
||||||
BaseItemKind.MUSIC_ALBUM -> TODO()
|
BaseItemKind.MUSIC_ALBUM -> R.string.albums
|
||||||
BaseItemKind.MUSIC_ARTIST -> TODO()
|
BaseItemKind.MUSIC_ARTIST -> R.string.artists
|
||||||
BaseItemKind.MUSIC_GENRE -> TODO()
|
BaseItemKind.MUSIC_GENRE -> R.string.genres
|
||||||
BaseItemKind.MUSIC_VIDEO -> TODO()
|
BaseItemKind.MUSIC_VIDEO -> R.string.music_videos
|
||||||
BaseItemKind.PHOTO -> R.string.photos
|
BaseItemKind.PHOTO -> R.string.photos
|
||||||
BaseItemKind.PHOTO_ALBUM -> TODO()
|
BaseItemKind.PHOTO_ALBUM -> R.string.photo_albums
|
||||||
BaseItemKind.PROGRAM -> TODO()
|
BaseItemKind.PROGRAM -> R.string.programs
|
||||||
BaseItemKind.RECORDING -> TODO()
|
BaseItemKind.RECORDING -> R.string.recordings
|
||||||
BaseItemKind.SEASON -> R.string.tv_seasons
|
BaseItemKind.SEASON -> R.string.tv_seasons
|
||||||
BaseItemKind.STUDIO -> R.string.studios
|
BaseItemKind.STUDIO -> R.string.studios
|
||||||
BaseItemKind.TRAILER -> R.string.trailers
|
BaseItemKind.TRAILER -> R.string.trailers_title
|
||||||
BaseItemKind.TV_CHANNEL -> R.string.channels
|
BaseItemKind.TV_CHANNEL -> R.string.channels
|
||||||
BaseItemKind.TV_PROGRAM -> TODO()
|
BaseItemKind.TV_PROGRAM -> R.string.programs
|
||||||
BaseItemKind.USER_ROOT_FOLDER -> TODO()
|
BaseItemKind.USER_ROOT_FOLDER -> R.string.folders_title
|
||||||
BaseItemKind.USER_VIEW -> TODO()
|
BaseItemKind.USER_VIEW -> R.string.user_views
|
||||||
BaseItemKind.YEAR -> TODO()
|
BaseItemKind.YEAR -> R.string.year
|
||||||
BaseItemKind.AGGREGATE_FOLDER -> TODO()
|
BaseItemKind.AGGREGATE_FOLDER -> R.string.folders_title
|
||||||
BaseItemKind.AUDIO_BOOK -> TODO()
|
BaseItemKind.AUDIO_BOOK -> R.string.audio_books
|
||||||
BaseItemKind.BASE_PLUGIN_FOLDER -> TODO()
|
BaseItemKind.BASE_PLUGIN_FOLDER -> R.string.folders_title
|
||||||
BaseItemKind.BOOK -> TODO()
|
BaseItemKind.BOOK -> R.string.books
|
||||||
BaseItemKind.CHANNEL_FOLDER_ITEM -> TODO()
|
BaseItemKind.CHANNEL_FOLDER_ITEM -> R.string.folders_title
|
||||||
BaseItemKind.COLLECTION_FOLDER -> TODO()
|
BaseItemKind.COLLECTION_FOLDER -> R.string.library
|
||||||
BaseItemKind.FOLDER -> TODO()
|
BaseItemKind.FOLDER -> R.string.folders_title
|
||||||
BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> TODO()
|
BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> R.string.playlists
|
||||||
BaseItemKind.LIVE_TV_PROGRAM -> TODO()
|
BaseItemKind.LIVE_TV_PROGRAM -> R.string.programs
|
||||||
BaseItemKind.PLAYLISTS_FOLDER -> TODO()
|
BaseItemKind.PLAYLISTS_FOLDER -> R.string.playlists
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a [Duration] into a localized "Xh Ym Zs" string using string resources.
|
||||||
|
*/
|
||||||
|
fun Resources.formatDuration(duration: Duration): String =
|
||||||
|
duration.toComponents { hours, minutes, seconds, _ ->
|
||||||
|
when {
|
||||||
|
hours > 0 && minutes > 0 && seconds > 0 -> getString(R.string.duration_hours_minutes_seconds, hours.toInt(), minutes, seconds)
|
||||||
|
hours > 0 && minutes > 0 -> getString(R.string.duration_hours_minutes, hours.toInt(), minutes)
|
||||||
|
hours > 0 && seconds > 0 -> getString(R.string.duration_hours_seconds, hours.toInt(), seconds)
|
||||||
|
hours > 0 -> getString(R.string.duration_hours, hours.toInt())
|
||||||
|
minutes > 0 && seconds > 0 -> getString(R.string.duration_minutes_seconds, minutes, seconds)
|
||||||
|
minutes > 0 -> getString(R.string.duration_minutes, minutes)
|
||||||
|
else -> getString(R.string.duration_seconds, seconds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,27 +38,25 @@ object AppColors {
|
||||||
val Purple = Color(147, 51, 234)
|
val Purple = Color(147, 51, 234)
|
||||||
val Green = Color(74, 222, 128)
|
val Green = Color(74, 222, 128)
|
||||||
val Yellow = Color(234, 179, 8)
|
val Yellow = Color(234, 179, 8)
|
||||||
|
val Red = Color(204, 38, 38)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const val DEFAULT_PAGE_SIZE = 100
|
const val DEFAULT_PAGE_SIZE = 100
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default [ItemFields] to fetch for most queries
|
* The [ItemFields] to fetch for detailed paged and playback
|
||||||
*/
|
*/
|
||||||
val DefaultItemFields =
|
val DetailItemFields =
|
||||||
listOf(
|
listOf(
|
||||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
|
||||||
ItemFields.SEASON_USER_DATA,
|
|
||||||
ItemFields.CHILD_COUNT,
|
|
||||||
ItemFields.OVERVIEW,
|
ItemFields.OVERVIEW,
|
||||||
ItemFields.TRICKPLAY,
|
ItemFields.TRICKPLAY,
|
||||||
ItemFields.SORT_NAME,
|
ItemFields.SORT_NAME,
|
||||||
ItemFields.CHAPTERS,
|
ItemFields.CHAPTERS,
|
||||||
ItemFields.MEDIA_SOURCES,
|
ItemFields.MEDIA_SOURCES,
|
||||||
ItemFields.MEDIA_SOURCE_COUNT,
|
ItemFields.MEDIA_SOURCE_COUNT,
|
||||||
ItemFields.PARENT_ID,
|
|
||||||
ItemFields.CAN_DELETE,
|
ItemFields.CAN_DELETE,
|
||||||
|
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -66,27 +64,37 @@ val DefaultItemFields =
|
||||||
*/
|
*/
|
||||||
val SlimItemFields =
|
val SlimItemFields =
|
||||||
listOf(
|
listOf(
|
||||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
|
||||||
ItemFields.SEASON_USER_DATA,
|
|
||||||
ItemFields.CHILD_COUNT,
|
|
||||||
ItemFields.OVERVIEW,
|
ItemFields.OVERVIEW,
|
||||||
ItemFields.SORT_NAME,
|
ItemFields.SORT_NAME,
|
||||||
ItemFields.MEDIA_SOURCE_COUNT,
|
ItemFields.MEDIA_SOURCE_COUNT,
|
||||||
ItemFields.PARENT_ID,
|
ItemFields.CAN_DELETE,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ItemFields for displaying items in rows such as in a [com.github.damontecres.wholphin.ui.cards.ItemRow] with [com.github.damontecres.wholphin.ui.cards.SeasonCard]
|
||||||
|
*/
|
||||||
|
val ItemRowFields = SlimItemFields + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO
|
||||||
|
|
||||||
|
val HomeItemFields =
|
||||||
|
listOf(
|
||||||
|
ItemFields.OVERVIEW,
|
||||||
ItemFields.CAN_DELETE,
|
ItemFields.CAN_DELETE,
|
||||||
)
|
)
|
||||||
|
|
||||||
val PhotoItemFields =
|
val PhotoItemFields =
|
||||||
DefaultItemFields +
|
DetailItemFields +
|
||||||
listOf(
|
listOf(
|
||||||
ItemFields.WIDTH,
|
ItemFields.WIDTH,
|
||||||
ItemFields.HEIGHT,
|
ItemFields.HEIGHT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val ProgramItemFields = DetailItemFields + listOf(ItemFields.CHANNEL_INFO)
|
||||||
|
|
||||||
object Cards {
|
object Cards {
|
||||||
const val HEIGHT_2X3_DP = 172
|
const val HEIGHT_2X3_DP = 172
|
||||||
val height2x3 = HEIGHT_2X3_DP.dp
|
val height2x3 = HEIGHT_2X3_DP.dp
|
||||||
const val HEIGHT_EPISODE = 128
|
const val HEIGHT_EPISODE = 128
|
||||||
|
const val HEIGHT_LIVE_TV = 96
|
||||||
val heightEpisode = HEIGHT_EPISODE.dp
|
val heightEpisode = HEIGHT_EPISODE.dp
|
||||||
val playedPercentHeight = 6.dp
|
val playedPercentHeight = 6.dp
|
||||||
val serverUserCircle = height2x3 * .75f
|
val serverUserCircle = height2x3 * .75f
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.github.damontecres.wholphin.ui.cards
|
package com.github.damontecres.wholphin.ui.cards
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
|
@ -74,7 +73,7 @@ fun BannerCard(
|
||||||
val imageUrlService = LocalImageUrlService.current
|
val imageUrlService = LocalImageUrlService.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val fillHeight =
|
val fillHeight =
|
||||||
remember(cardHeight) {
|
remember(cardHeight, density) {
|
||||||
if (cardHeight.isSpecified) {
|
if (cardHeight.isSpecified) {
|
||||||
with(density) {
|
with(density) {
|
||||||
cardHeight.roundToPx()
|
cardHeight.roundToPx()
|
||||||
|
|
@ -219,13 +218,11 @@ fun BannerCardWithTitle(
|
||||||
useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true,
|
useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true,
|
||||||
) {
|
) {
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
|
||||||
val spaceBelow by animateDpAsState(if (focused) 0.dp else 8.dp)
|
|
||||||
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
||||||
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||||
val width = cardHeight * aspectRationToUse
|
val width = cardHeight * aspectRationToUse
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = modifier.width(width),
|
modifier = modifier.width(width),
|
||||||
) {
|
) {
|
||||||
BannerCard(
|
BannerCard(
|
||||||
|
|
@ -245,13 +242,7 @@ fun BannerCardWithTitle(
|
||||||
imageContentScale = imageContentScale,
|
imageContentScale = imageContentScale,
|
||||||
useSeriesForPrimary = useSeriesForPrimary,
|
useSeriesForPrimary = useSeriesForPrimary,
|
||||||
)
|
)
|
||||||
Column(
|
SlidingCardText(focused) {
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(bottom = spaceBelow)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = title ?: "",
|
text = title ?: "",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
|
|
||||||
|
|
@ -5,39 +5,62 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalResources
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.times
|
||||||
import androidx.tv.material3.Card
|
import androidx.tv.material3.Card
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.github.damontecres.wholphin.data.model.Chapter
|
||||||
import com.github.damontecres.wholphin.ui.AppColors
|
import com.github.damontecres.wholphin.ui.AppColors
|
||||||
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.ui.formatDuration
|
||||||
import com.github.damontecres.wholphin.ui.roundSeconds
|
import com.github.damontecres.wholphin.ui.roundSeconds
|
||||||
import kotlin.time.Duration
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card for a [com.github.damontecres.wholphin.data.model.Chapter]
|
* Card for a [com.github.damontecres.wholphin.data.model.Chapter]
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ChapterCard(
|
fun ChapterCard(
|
||||||
name: String?,
|
chapter: Chapter,
|
||||||
position: Duration,
|
|
||||||
imageUrl: String?,
|
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
aspectRatio: Float,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
cardHeight: Dp = 120.dp,
|
cardHeight: Dp = 120.dp,
|
||||||
onLongClick: (() -> Unit)? = null,
|
onLongClick: (() -> Unit)? = null,
|
||||||
interactionSource: MutableInteractionSource? = null,
|
interactionSource: MutableInteractionSource? = null,
|
||||||
) {
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val imageUrlService = LocalImageUrlService.current
|
||||||
|
val imageUrl =
|
||||||
|
remember(chapter, cardHeight, density) {
|
||||||
|
imageUrlService.getItemImageUrl(
|
||||||
|
itemId = chapter.itemId,
|
||||||
|
imageType = ImageType.CHAPTER,
|
||||||
|
tag = chapter.tag,
|
||||||
|
imageIndex = chapter.index,
|
||||||
|
fillHeight = with(density) { cardHeight.roundToPx() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var width by remember { mutableStateOf(AspectRatios.WIDE * cardHeight) }
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
|
modifier = modifier.height(cardHeight),
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
|
|
@ -48,22 +71,36 @@ fun ChapterCard(
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
onSuccess = {
|
||||||
|
width =
|
||||||
|
with(density) {
|
||||||
|
it.painter.intrinsicSize.width
|
||||||
|
.toDp()
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
.width(width)
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.fillMaxWidth()
|
|
||||||
.background(AppColors.TransparentBlack50),
|
.background(AppColors.TransparentBlack50),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(4.dp)) {
|
Column(modifier = Modifier.padding(4.dp)) {
|
||||||
name?.let {
|
chapter.name?.let {
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val resources = LocalResources.current
|
||||||
|
val positionText =
|
||||||
|
remember(chapter.position) { resources.formatDuration(chapter.position.roundSeconds) }
|
||||||
Text(
|
Text(
|
||||||
text = position.roundSeconds.toString(),
|
text = positionText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import com.github.damontecres.wholphin.data.model.Chapter
|
||||||
@Composable
|
@Composable
|
||||||
fun ChapterRow(
|
fun ChapterRow(
|
||||||
chapters: List<Chapter>,
|
chapters: List<Chapter>,
|
||||||
aspectRatio: Float,
|
|
||||||
onClick: (Chapter) -> Unit,
|
onClick: (Chapter) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onLongClick: ((Chapter) -> Unit)? = null,
|
onLongClick: ((Chapter) -> Unit)? = null,
|
||||||
|
|
@ -47,11 +46,8 @@ fun ChapterRow(
|
||||||
) {
|
) {
|
||||||
itemsIndexed(chapters) { index, item ->
|
itemsIndexed(chapters) { index, item ->
|
||||||
ChapterCard(
|
ChapterCard(
|
||||||
name = item.name,
|
chapter = item,
|
||||||
position = item.position,
|
|
||||||
imageUrl = item.imageUrl,
|
|
||||||
onClick = { onClick(item) },
|
onClick = { onClick(item) },
|
||||||
aspectRatio = aspectRatio,
|
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
onLongClick = onLongClick?.let { { it(item) } },
|
onLongClick = onLongClick?.let { { it(item) } },
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,6 @@ fun DiscoverItemCard(
|
||||||
width: Dp = Cards.height2x3 * AspectRatios.TALL,
|
width: Dp = Cards.height2x3 * AspectRatios.TALL,
|
||||||
) {
|
) {
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
|
||||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
|
||||||
var focusedAfterDelay by remember { mutableStateOf(false) }
|
var focusedAfterDelay by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val hideOverlayDelay = 500L
|
val hideOverlayDelay = 500L
|
||||||
|
|
@ -78,7 +76,7 @@ fun DiscoverItemCard(
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = modifier.size(width, Dp.Unspecified),
|
modifier = modifier.size(width, Dp.Unspecified),
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
|
|
@ -130,7 +128,16 @@ fun DiscoverItemCard(
|
||||||
AvailableIndicator(Modifier.align(Alignment.TopEnd))
|
AvailableIndicator(Modifier.align(Alignment.TopEnd))
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {}
|
SeerrAvailability.BLOCKLISTED -> {
|
||||||
|
// TODO handle block listed
|
||||||
|
// BlocklistedIndicator(Modifier.align(Alignment.TopEnd))
|
||||||
|
}
|
||||||
|
|
||||||
|
SeerrAvailability.UNKNOWN,
|
||||||
|
SeerrAvailability.DELETED,
|
||||||
|
null,
|
||||||
|
-> {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (showOverlay) {
|
if (showOverlay) {
|
||||||
val color =
|
val color =
|
||||||
|
|
@ -171,13 +178,7 @@ fun DiscoverItemCard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(
|
SlidingCardText(focused) {
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(bottom = spaceBelow)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = item?.title ?: "",
|
text = item?.title ?: "",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|
@ -284,6 +285,31 @@ fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BlocklistedIndicator(modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.border(
|
||||||
|
width = .5.dp,
|
||||||
|
color = Color.White,
|
||||||
|
shape = CircleShape,
|
||||||
|
).background(
|
||||||
|
color = AppColors.Discover.Red.copy(alpha = .85f),
|
||||||
|
shape = CircleShape,
|
||||||
|
).size(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.fa_xmark),
|
||||||
|
fontFamily = FontAwesome,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PreviewTvSpec
|
@PreviewTvSpec
|
||||||
@Composable
|
@Composable
|
||||||
private fun Preview() {
|
private fun Preview() {
|
||||||
|
|
@ -292,6 +318,7 @@ private fun Preview() {
|
||||||
PendingIndicator()
|
PendingIndicator()
|
||||||
AvailableIndicator()
|
AvailableIndicator()
|
||||||
PartiallyAvailableIndicator()
|
PartiallyAvailableIndicator()
|
||||||
|
BlocklistedIndicator()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
package com.github.damontecres.wholphin.ui.cards
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.tv.material3.Card
|
|
||||||
import androidx.tv.material3.CardDefaults
|
|
||||||
import androidx.tv.material3.MaterialTheme
|
|
||||||
import androidx.tv.material3.Text
|
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
|
||||||
import com.github.damontecres.wholphin.ui.AppColors
|
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
|
||||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
|
||||||
import com.github.damontecres.wholphin.ui.seasonEpisode
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun EpisodeCard(
|
|
||||||
item: BaseItem?,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
onLongClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
imageHeight: Dp = Dp.Unspecified,
|
|
||||||
imageWidth: Dp = Dp.Unspecified,
|
|
||||||
showImageOverlay: Boolean = false,
|
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
|
||||||
) {
|
|
||||||
val dto = item?.data
|
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
|
||||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
|
||||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
|
||||||
var focusedAfterDelay by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val hideOverlayDelay = 500L
|
|
||||||
if (focused) {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
delay(hideOverlayDelay)
|
|
||||||
if (focused) {
|
|
||||||
focusedAfterDelay = true
|
|
||||||
} else {
|
|
||||||
focusedAfterDelay = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
focusedAfterDelay = false
|
|
||||||
}
|
|
||||||
val aspectRatio = item?.aspectRatio?.coerceAtLeast(AspectRatios.MIN) ?: AspectRatios.MIN
|
|
||||||
val width = imageHeight * aspectRatio
|
|
||||||
val height = imageWidth * (1f / aspectRatio)
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } }
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
|
||||||
modifier = modifier.size(width, height),
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.size(imageWidth, imageHeight)
|
|
||||||
.aspectRatio(aspectRatio),
|
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
colors =
|
|
||||||
CardDefaults.colors(
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize(),
|
|
||||||
) {
|
|
||||||
ItemCardImage(
|
|
||||||
item = item,
|
|
||||||
name = item?.name,
|
|
||||||
showOverlay = showImageOverlay,
|
|
||||||
favorite = dto?.userData?.isFavorite ?: false,
|
|
||||||
watched = dto?.userData?.played ?: false,
|
|
||||||
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
|
|
||||||
watchedPercent = dto?.userData?.playedPercentage,
|
|
||||||
numberOfVersions = dto?.mediaSourceCount ?: 0,
|
|
||||||
useFallbackText = false,
|
|
||||||
fillWidth = imageWidthPx,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize(),
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
.background(
|
|
||||||
AppColors.TransparentBlack50,
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = dto?.seasonEpisode ?: "",
|
|
||||||
modifier = Modifier.padding(4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(bottom = spaceBelow)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = dto?.seriesName ?: "",
|
|
||||||
maxLines = 1,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 4.dp)
|
|
||||||
.enableMarquee(focusedAfterDelay),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = item?.name ?: "",
|
|
||||||
maxLines = 1,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 4.dp)
|
|
||||||
.enableMarquee(focusedAfterDelay),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -17,7 +17,7 @@ fun ExtrasRow(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
ItemRow(
|
ItemRow(
|
||||||
title = stringResource(R.string.extras),
|
title = stringResource(R.string.extras_title),
|
||||||
items = extras,
|
items = extras,
|
||||||
onClickItem = onClickItem,
|
onClickItem = onClickItem,
|
||||||
onLongClickItem = onLongClickItem,
|
onLongClickItem = onLongClickItem,
|
||||||
|
|
@ -38,7 +38,7 @@ fun ExtrasRow(
|
||||||
unplayedItemCount = -1,
|
unplayedItemCount = -1,
|
||||||
playedPercentage = item?.playedPercentage ?: 0.0,
|
playedPercentage = item?.playedPercentage ?: 0.0,
|
||||||
numberOfVersions = -1,
|
numberOfVersions = -1,
|
||||||
aspectRatio = AspectRatios.FOUR_THREE, // TODO
|
aspectRatio = item?.aspectRatio ?: AspectRatios.WIDE,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import coil3.request.crossfade
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||||
import com.github.damontecres.wholphin.ui.components.Genre
|
import com.github.damontecres.wholphin.ui.components.Genre
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
|
||||||
import com.github.damontecres.wholphin.ui.setup.rememberIdColor
|
import com.github.damontecres.wholphin.ui.setup.rememberIdColor
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,6 @@ fun GridCard(
|
||||||
) {
|
) {
|
||||||
val dto = item?.data
|
val dto = item?.data
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
|
||||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
|
||||||
var focusedAfterDelay by remember { mutableStateOf(false) }
|
var focusedAfterDelay by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val hideOverlayDelay = 500L
|
val hideOverlayDelay = 500L
|
||||||
|
|
@ -71,7 +69,7 @@ fun GridCard(
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
|
|
@ -107,13 +105,7 @@ fun GridCard(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(showTitle) {
|
AnimatedVisibility(showTitle) {
|
||||||
Column(
|
SlidingCardText(focused) {
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(bottom = spaceBelow)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = item?.title ?: "",
|
text = item?.title ?: "",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import com.github.damontecres.wholphin.ui.AppColors
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.ui.gt
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.logCoilError
|
import com.github.damontecres.wholphin.ui.logCoilError
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
|
@ -67,14 +68,16 @@ fun ItemCardImage(
|
||||||
) {
|
) {
|
||||||
val imageUrlService = LocalImageUrlService.current
|
val imageUrlService = LocalImageUrlService.current
|
||||||
val imageUrl =
|
val imageUrl =
|
||||||
remember(item) {
|
remember(item, imageType, fillWidth, fillHeight) {
|
||||||
item?.let {
|
if (item != null && (fillWidth.gt(0) || fillHeight.gt(0))) {
|
||||||
imageUrlService.getItemImageUrl(
|
imageUrlService.getItemImageUrl(
|
||||||
item,
|
item,
|
||||||
imageType,
|
imageType,
|
||||||
fillWidth = fillWidth,
|
fillWidth = fillWidth,
|
||||||
fillHeight = fillHeight,
|
fillHeight = fillHeight,
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ItemCardImage(
|
ItemCardImage(
|
||||||
|
|
@ -262,7 +265,7 @@ fun ItemCardImageOverlay(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watchedPercent?.let { percent ->
|
if (watchedPercent != null && watchedPercent > 0 && watchedPercent < 100) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -271,7 +274,7 @@ fun ItemCardImageOverlay(
|
||||||
MaterialTheme.colorScheme.tertiary,
|
MaterialTheme.colorScheme.tertiary,
|
||||||
).clip(RectangleShape)
|
).clip(RectangleShape)
|
||||||
.height(Cards.playedPercentHeight)
|
.height(Cards.playedPercentHeight)
|
||||||
.fillMaxWidth((percent / 100.0).toFloat()),
|
.fillMaxWidth((watchedPercent / 100.0).toFloat()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.github.damontecres.wholphin.ui.cards
|
package com.github.damontecres.wholphin.ui.cards
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
|
@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
|
@ -26,11 +24,9 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.layout
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.tv.material3.Border
|
import androidx.tv.material3.Border
|
||||||
|
|
@ -99,10 +95,6 @@ fun PersonCard(
|
||||||
focusedAfterDelay = false
|
focusedAfterDelay = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates
|
|
||||||
val spaceBetweenState = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween")
|
|
||||||
val spaceBelowState = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow")
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing
|
verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -170,21 +162,7 @@ fun PersonCard(
|
||||||
.clip(CircleShape),
|
.clip(CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column(
|
SlidingCardText(focused) {
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
// Optimization: move animation reads to layout/draw phase
|
|
||||||
.offset {
|
|
||||||
IntOffset(0, (spaceBetweenState.value - 4.dp).roundToPx())
|
|
||||||
}.layout { measurable, constraints ->
|
|
||||||
val paddingPx = spaceBelowState.value.roundToPx()
|
|
||||||
val placeable = measurable.measure(constraints)
|
|
||||||
layout(placeable.width, placeable.height + paddingPx) {
|
|
||||||
placeable.placeRelative(0, 0)
|
|
||||||
}
|
|
||||||
}.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = name ?: "",
|
text = name ?: "",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ fun PersonRow(
|
||||||
people: List<Person>,
|
people: List<Person>,
|
||||||
onClick: (Person) -> Unit,
|
onClick: (Person) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@StringRes title: Int = R.string.people,
|
@StringRes title: Int = R.string.people_title,
|
||||||
onLongClick: ((Int, Person) -> Unit)? = null,
|
onLongClick: ((Int, Person) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val firstFocus = remember { FocusRequester() }
|
val firstFocus = remember { FocusRequester() }
|
||||||
|
|
@ -84,7 +84,7 @@ fun DiscoverPersonRow(
|
||||||
people: List<DiscoverItem>,
|
people: List<DiscoverItem>,
|
||||||
onClick: (DiscoverItem) -> Unit,
|
onClick: (DiscoverItem) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@StringRes title: Int = R.string.people,
|
@StringRes title: Int = R.string.people_title,
|
||||||
onLongClick: ((Int, DiscoverItem) -> Unit)? = null,
|
onLongClick: ((Int, DiscoverItem) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val firstFocus = remember { FocusRequester() }
|
val firstFocus = remember { FocusRequester() }
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -24,8 +23,8 @@ import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.isSpecified
|
||||||
import androidx.tv.material3.Card
|
import androidx.tv.material3.Card
|
||||||
import androidx.tv.material3.CardDefaults
|
import androidx.tv.material3.CardDefaults
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
|
@ -38,51 +37,21 @@ import kotlinx.coroutines.delay
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Card for a TV Show Season, but can generically show most items
|
* A Card for a TV Show Season, but can generally show most items
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SeasonCard(
|
fun SeasonCard(
|
||||||
item: BaseItem?,
|
item: BaseItem?,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
|
imageHeight: Dp,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
imageHeight: Dp = Dp.Unspecified,
|
|
||||||
imageWidth: Dp = Dp.Unspecified,
|
imageWidth: Dp = Dp.Unspecified,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
showImageOverlay: Boolean = false,
|
showImageOverlay: Boolean = false,
|
||||||
aspectRatio: Float = item?.aspectRatio ?: AspectRatios.TALL,
|
aspectRatio: Float = item?.aspectRatio ?: AspectRatios.TALL,
|
||||||
) {
|
) {
|
||||||
val imageUrlService = LocalImageUrlService.current
|
val imageUrl = rememberImageUrl(item, imageHeight, imageWidth)
|
||||||
val density = LocalDensity.current
|
|
||||||
val imageUrl =
|
|
||||||
remember(item, imageHeight, imageWidth, density) {
|
|
||||||
if (item != null) {
|
|
||||||
val fillHeight =
|
|
||||||
if (imageHeight != Dp.Unspecified) {
|
|
||||||
with(density) {
|
|
||||||
imageHeight.roundToPx()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
val fillWidth =
|
|
||||||
if (imageWidth != Dp.Unspecified) {
|
|
||||||
with(density) {
|
|
||||||
imageWidth.roundToPx()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
imageUrlService.getItemImageUrl(
|
|
||||||
item,
|
|
||||||
ImageType.PRIMARY,
|
|
||||||
fillWidth = fillWidth,
|
|
||||||
fillHeight = fillHeight,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SeasonCard(
|
SeasonCard(
|
||||||
title = item?.title,
|
title = item?.title,
|
||||||
subtitle = item?.subtitle,
|
subtitle = item?.subtitle,
|
||||||
|
|
@ -104,6 +73,47 @@ fun SeasonCard(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberImageUrl(
|
||||||
|
item: BaseItem?,
|
||||||
|
imageHeight: Dp,
|
||||||
|
imageWidth: Dp = Dp.Unspecified,
|
||||||
|
): String? {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val imageUrlService = LocalImageUrlService.current
|
||||||
|
return remember(item, imageHeight, imageWidth, density) {
|
||||||
|
if (item != null) {
|
||||||
|
val fillHeight =
|
||||||
|
if (imageHeight.isSpecified) {
|
||||||
|
with(density) {
|
||||||
|
imageHeight.roundToPx()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val fillWidth =
|
||||||
|
if (imageWidth.isSpecified) {
|
||||||
|
with(density) {
|
||||||
|
imageWidth.roundToPx()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
imageUrlService.getItemImageUrl(
|
||||||
|
item,
|
||||||
|
ImageType.PRIMARY,
|
||||||
|
fillWidth = fillWidth,
|
||||||
|
fillHeight = fillHeight,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val spaceBetweenFocused = 4.dp
|
||||||
|
private val spaceBetweenUnfocused = 12.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Card for a TV Show Season, but can generically show most items
|
* A Card for a TV Show Season, but can generically show most items
|
||||||
*/
|
*/
|
||||||
|
|
@ -129,8 +139,11 @@ fun SeasonCard(
|
||||||
) {
|
) {
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
// Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates
|
// Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates
|
||||||
val spaceBetween = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween")
|
val spaceBetween =
|
||||||
val spaceBelow = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow")
|
animateDpAsState(
|
||||||
|
if (focused) spaceBetweenFocused else spaceBetweenUnfocused,
|
||||||
|
label = "spaceBetween",
|
||||||
|
)
|
||||||
|
|
||||||
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
||||||
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||||
|
|
@ -179,14 +192,12 @@ fun SeasonCard(
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
// Optimization: move animation reads to layout/draw phase
|
.layout { measurable, constraints ->
|
||||||
.offset {
|
val topPaddingPx = (spaceBetweenUnfocused - spaceBetween.value).roundToPx()
|
||||||
IntOffset(0, (spaceBetween.value - 4.dp).roundToPx())
|
val bottomPaddingPx = spaceBetween.value.roundToPx()
|
||||||
}.layout { measurable, constraints ->
|
|
||||||
val paddingPx = spaceBelow.value.roundToPx()
|
|
||||||
val placeable = measurable.measure(constraints)
|
val placeable = measurable.measure(constraints)
|
||||||
layout(placeable.width, placeable.height + paddingPx) {
|
layout(placeable.width, placeable.height + bottomPaddingPx + topPaddingPx) {
|
||||||
placeable.placeRelative(0, 0)
|
placeable.placeRelative(0, topPaddingPx)
|
||||||
}
|
}
|
||||||
}.fillMaxWidth(),
|
}.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.cards
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slides the [content] up/down as the card is [focused] or not
|
||||||
|
*
|
||||||
|
* This is used for the card's title, moving it down when focused as the card itself scales up
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SlidingCardText(
|
||||||
|
focused: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
spaceBetweenFocused: Dp = 4.dp,
|
||||||
|
spaceBetweenUnfocused: Dp = 12.dp,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
val spaceBetween =
|
||||||
|
animateDpAsState(
|
||||||
|
if (focused) spaceBetweenFocused else spaceBetweenUnfocused,
|
||||||
|
label = "spaceBetween",
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.layout { measurable, constraints ->
|
||||||
|
val topPaddingPx = (spaceBetweenUnfocused - spaceBetween.value).roundToPx()
|
||||||
|
val bottomPaddingPx = spaceBetween.value.roundToPx()
|
||||||
|
val placeable = measurable.measure(constraints)
|
||||||
|
layout(placeable.width, placeable.height + bottomPaddingPx + topPaddingPx) {
|
||||||
|
placeable.placeRelative(0, topPaddingPx)
|
||||||
|
}
|
||||||
|
}.fillMaxWidth(),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -32,9 +32,7 @@ import coil3.request.ImageRequest
|
||||||
import coil3.request.crossfade
|
import coil3.request.crossfade
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||||
import com.github.damontecres.wholphin.ui.components.Genre
|
|
||||||
import com.github.damontecres.wholphin.ui.components.Studio
|
import com.github.damontecres.wholphin.ui.components.Studio
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
|
||||||
import com.github.damontecres.wholphin.ui.setup.rememberIdColor
|
import com.github.damontecres.wholphin.ui.setup.rememberIdColor
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,6 @@ fun ViewMoreCard(
|
||||||
showTitle: Boolean = true,
|
showTitle: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
|
||||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
|
||||||
var focusedAfterDelay by remember { mutableStateOf(false) }
|
var focusedAfterDelay by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val hideOverlayDelay = 500L
|
val hideOverlayDelay = 500L
|
||||||
|
|
@ -79,7 +77,7 @@ fun ViewMoreCard(
|
||||||
size.height.takeIf { it.isSpecified } ?: (size.height * (1f / aspectRatio.ratio))
|
size.height.takeIf { it.isSpecified } ?: (size.height * (1f / aspectRatio.ratio))
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
|
|
@ -95,24 +93,20 @@ fun ViewMoreCard(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Box {
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.ArrowForward,
|
imageVector = Icons.Default.ArrowForward,
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
contentDescription = "View more",
|
contentDescription = "View more",
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(.66f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (showTitle) {
|
if (showTitle) {
|
||||||
Column(
|
SlidingCardText(focused) {
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.width(width)
|
|
||||||
.padding(bottom = spaceBelow),
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.view_more),
|
text = stringResource(R.string.view_more),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ fun WatchedIconBackground(): Color =
|
||||||
AppThemeColors.GREEN,
|
AppThemeColors.GREEN,
|
||||||
AppThemeColors.ORANGE,
|
AppThemeColors.ORANGE,
|
||||||
AppThemeColors.BOLD_BLUE,
|
AppThemeColors.BOLD_BLUE,
|
||||||
|
AppThemeColors.RED,
|
||||||
|
AppThemeColors.BROWN,
|
||||||
-> MaterialTheme.colorScheme.border.copy(alpha = 1f)
|
-> MaterialTheme.colorScheme.border.copy(alpha = 1f)
|
||||||
|
|
||||||
AppThemeColors.OLED_BLACK -> MaterialTheme.colorScheme.secondaryContainer
|
AppThemeColors.OLED_BLACK -> MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
|
@ -60,6 +62,8 @@ fun WatchedIconColor(): Color =
|
||||||
AppThemeColors.ORANGE,
|
AppThemeColors.ORANGE,
|
||||||
AppThemeColors.BOLD_BLUE,
|
AppThemeColors.BOLD_BLUE,
|
||||||
AppThemeColors.OLED_BLACK,
|
AppThemeColors.OLED_BLACK,
|
||||||
|
AppThemeColors.RED,
|
||||||
|
AppThemeColors.BROWN,
|
||||||
-> Color.White // MaterialTheme.colorScheme.onSurface
|
-> Color.White // MaterialTheme.colorScheme.onSurface
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
|
@ -15,6 +18,9 @@ import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CircularProgress(modifier: Modifier = Modifier) {
|
fun CircularProgress(modifier: Modifier = Modifier) {
|
||||||
|
|
@ -58,3 +64,41 @@ fun LoadingPage(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill the space and take focus, showing a loading indicator after the specified delay
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DelayedLoadingPage(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
focusEnabled: Boolean = true,
|
||||||
|
delay: Duration = 300.milliseconds,
|
||||||
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
if (focusEnabled) {
|
||||||
|
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||||
|
}
|
||||||
|
var show by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(delay)
|
||||||
|
show = true
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.focusable(focusEnabled),
|
||||||
|
) {
|
||||||
|
if (show) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color = MaterialTheme.colorScheme.border,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.size(48.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
|
@ -21,6 +22,7 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
|
@ -40,6 +42,7 @@ import com.github.damontecres.wholphin.ui.cards.GridCard
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||||
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
|
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
|
||||||
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
||||||
import com.github.damontecres.wholphin.ui.playback.scale
|
import com.github.damontecres.wholphin.ui.playback.scale
|
||||||
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
|
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
|
||||||
|
|
@ -112,7 +115,7 @@ fun CollectionFolderGrid(
|
||||||
columns = viewOptions.columns,
|
columns = viewOptions.columns,
|
||||||
spacing = viewOptions.spacing.dp,
|
spacing = viewOptions.spacing.dp,
|
||||||
bringIntoViewSpec =
|
bringIntoViewSpec =
|
||||||
remember(viewOptions) {
|
remember(viewOptions, density) {
|
||||||
val spacingPx = with(density) { viewOptions.spacing.dp.toPx() }
|
val spacingPx = with(density) { viewOptions.spacing.dp.toPx() }
|
||||||
if (viewOptions.showDetails) {
|
if (viewOptions.showDetails) {
|
||||||
ScrollToTopBringIntoViewSpec(spacingPx)
|
ScrollToTopBringIntoViewSpec(spacingPx)
|
||||||
|
|
@ -204,7 +207,7 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
|
||||||
}
|
}
|
||||||
|
|
||||||
CollectionType.TVSHOWS -> {
|
CollectionType.TVSHOWS -> {
|
||||||
listOf(BaseItemKind.SERIES)
|
listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE)
|
||||||
}
|
}
|
||||||
|
|
||||||
CollectionType.HOMEVIDEOS -> {
|
CollectionType.HOMEVIDEOS -> {
|
||||||
|
|
@ -219,6 +222,10 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CollectionType.MUSICVIDEOS -> {
|
||||||
|
listOf(BaseItemKind.MUSIC_VIDEO)
|
||||||
|
}
|
||||||
|
|
||||||
CollectionType.BOXSETS -> {
|
CollectionType.BOXSETS -> {
|
||||||
listOf(BaseItemKind.BOX_SET)
|
listOf(BaseItemKind.BOX_SET)
|
||||||
}
|
}
|
||||||
|
|
@ -227,7 +234,33 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
|
||||||
listOf(BaseItemKind.PLAYLIST)
|
listOf(BaseItemKind.PLAYLIST)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
CollectionType.PHOTOS -> {
|
||||||
|
listOf(BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM)
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectionType.LIVETV -> {
|
||||||
|
listOf(
|
||||||
|
BaseItemKind.TV_CHANNEL,
|
||||||
|
BaseItemKind.LIVE_TV_CHANNEL,
|
||||||
|
BaseItemKind.PROGRAM,
|
||||||
|
BaseItemKind.TV_PROGRAM,
|
||||||
|
BaseItemKind.LIVE_TV_PROGRAM,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectionType.TRAILERS -> {
|
||||||
|
listOf(BaseItemKind.TRAILER)
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectionType.BOOKS -> {
|
||||||
|
listOf(BaseItemKind.BOOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectionType.FOLDERS -> {
|
||||||
|
listOf(BaseItemKind.FOLDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectionType.UNKNOWN -> {
|
||||||
listOf()
|
listOf()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -256,6 +289,7 @@ data class GridClickActions(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CollectionFolderHeader(
|
fun CollectionFolderHeader(
|
||||||
|
listIsNotEmpty: Boolean,
|
||||||
showHeader: Boolean,
|
showHeader: Boolean,
|
||||||
showTitle: Boolean,
|
showTitle: Boolean,
|
||||||
playEnabled: Boolean,
|
playEnabled: Boolean,
|
||||||
|
|
@ -266,10 +300,14 @@ fun CollectionFolderHeader(
|
||||||
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
|
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
|
||||||
onClickShowViewOptions: () -> Unit,
|
onClickShowViewOptions: () -> Unit,
|
||||||
onClickPlayAll: (Boolean) -> Unit,
|
onClickPlayAll: (Boolean) -> Unit,
|
||||||
|
onClickRandom: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
currentFilter: GetItemsFilter = GetItemsFilter(),
|
currentFilter: GetItemsFilter = GetItemsFilter(),
|
||||||
filterOptions: List<ItemFilterBy<*>> = listOf(),
|
filterOptions: List<ItemFilterBy<*>> = listOf(),
|
||||||
onFilterChange: (GetItemsFilter) -> Unit = {},
|
onFilterChange: (GetItemsFilter) -> Unit = {},
|
||||||
|
onShowFilterDropdown: ((Boolean) -> Unit)? = null,
|
||||||
|
filterButtonFocusRequester: FocusRequester = remember { FocusRequester() },
|
||||||
|
randomButtonFocusRequester: FocusRequester = remember { FocusRequester() },
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
showHeader,
|
showHeader,
|
||||||
|
|
@ -277,6 +315,7 @@ fun CollectionFolderHeader(
|
||||||
exit = shrinkVertically(),
|
exit = shrinkVertically(),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
@ -286,15 +325,15 @@ fun CollectionFolderHeader(
|
||||||
}
|
}
|
||||||
val endPadding =
|
val endPadding =
|
||||||
16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp
|
16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(start = 16.dp, end = endPadding)
|
.padding(start = 16.dp, end = endPadding)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) {
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|
@ -305,7 +344,7 @@ fun CollectionFolderHeader(
|
||||||
sortOptions = sortOptions,
|
sortOptions = sortOptions,
|
||||||
current = sortAndDirection,
|
current = sortAndDirection,
|
||||||
onSortChange = onSortChange,
|
onSortChange = onSortChange,
|
||||||
modifier = Modifier,
|
modifier = Modifier.focusRequester(focusRequester),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (filterOptions.isNotEmpty()) {
|
if (filterOptions.isNotEmpty()) {
|
||||||
|
|
@ -314,33 +353,56 @@ fun CollectionFolderHeader(
|
||||||
current = currentFilter,
|
current = currentFilter,
|
||||||
onFilterChange = onFilterChange,
|
onFilterChange = onFilterChange,
|
||||||
getPossibleValues = getPossibleFilterValues,
|
getPossibleValues = getPossibleFilterValues,
|
||||||
modifier = Modifier,
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusRequester(filterButtonFocusRequester)
|
||||||
|
.ifElse(
|
||||||
|
sortOptions.isEmpty(),
|
||||||
|
Modifier.focusRequester(focusRequester),
|
||||||
|
),
|
||||||
|
onShow = onShowFilterDropdown,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ExpandableFaButton(
|
ExpandableFaButton(
|
||||||
title = R.string.view_options,
|
title = R.string.view_options,
|
||||||
iconStringRes = R.string.fa_sliders,
|
iconStringRes = R.string.fa_sliders,
|
||||||
onClick = onClickShowViewOptions,
|
onClick = onClickShowViewOptions,
|
||||||
modifier = Modifier,
|
modifier =
|
||||||
|
Modifier.ifElse(
|
||||||
|
sortOptions.isEmpty() && filterOptions.isEmpty(),
|
||||||
|
Modifier.focusRequester(focusRequester),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (playEnabled) {
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
ExpandableFaButton(
|
||||||
|
title = R.string.sort_by_random,
|
||||||
|
iconStringRes = R.string.fa_dice,
|
||||||
|
onClick = onClickRandom,
|
||||||
|
modifier = Modifier.focusRequester(randomButtonFocusRequester),
|
||||||
|
enabled = listIsNotEmpty,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (playEnabled) {
|
||||||
ExpandablePlayButton(
|
ExpandablePlayButton(
|
||||||
title = R.string.play,
|
title = R.string.play,
|
||||||
resume = Duration.ZERO,
|
resume = Duration.ZERO,
|
||||||
icon = Icons.Default.PlayArrow,
|
icon = Icons.Default.PlayArrow,
|
||||||
onClick = { onClickPlayAll.invoke(false) },
|
onClick = { onClickPlayAll.invoke(false) },
|
||||||
|
enabled = listIsNotEmpty,
|
||||||
)
|
)
|
||||||
ExpandableFaButton(
|
ExpandableFaButton(
|
||||||
title = R.string.shuffle,
|
title = R.string.shuffle,
|
||||||
iconStringRes = R.string.fa_shuffle,
|
iconStringRes = R.string.fa_shuffle,
|
||||||
onClick = { onClickPlayAll.invoke(true) },
|
onClick = { onClickPlayAll.invoke(true) },
|
||||||
|
enabled = listIsNotEmpty,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -38,6 +39,7 @@ import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalResources
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.ListItemDefaults
|
import androidx.tv.material3.ListItemDefaults
|
||||||
|
|
@ -58,6 +60,7 @@ import com.github.damontecres.wholphin.ui.cards.WatchedIcon
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
import com.github.damontecres.wholphin.ui.detail.AlphabetButtons
|
import com.github.damontecres.wholphin.ui.detail.AlphabetButtons
|
||||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||||
|
import com.github.damontecres.wholphin.ui.formatDuration
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
|
|
@ -97,15 +100,17 @@ fun CollectionFolderList(
|
||||||
val positionFocusRequester = remember { FocusRequester() }
|
val positionFocusRequester = remember { FocusRequester() }
|
||||||
val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME
|
val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME
|
||||||
|
|
||||||
var showHeader by remember { mutableStateOf(true) }
|
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||||
|
|
||||||
fun jumpTo(newPosition: Int) {
|
fun jumpTo(newPosition: Int) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
if (newPosition >= 0) {
|
||||||
position = newPosition
|
position = newPosition
|
||||||
listState.animateScrollToItem(newPosition)
|
listState.animateScrollToItem(newPosition)
|
||||||
positionFocusRequester.tryRequestFocus()
|
positionFocusRequester.tryRequestFocus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BackHandler(contentHashFocus && position > 0) {
|
BackHandler(contentHashFocus && position > 0) {
|
||||||
jumpTo(0)
|
jumpTo(0)
|
||||||
|
|
@ -357,7 +362,7 @@ fun ListItemGeneric(
|
||||||
?.runTimeTicks
|
?.runTimeTicks
|
||||||
?.ticks
|
?.ticks
|
||||||
?.roundMinutes
|
?.roundMinutes
|
||||||
?.toString() ?: "",
|
?.let { LocalResources.current.formatDuration(it) } ?: "",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
scale = scale,
|
scale = scale,
|
||||||
|
|
@ -419,7 +424,7 @@ fun ListItemMovie(
|
||||||
?.runTimeTicks
|
?.runTimeTicks
|
||||||
?.ticks
|
?.ticks
|
||||||
?.roundMinutes
|
?.roundMinutes
|
||||||
?.toString() ?: "",
|
?.let { LocalResources.current.formatDuration(it) } ?: "",
|
||||||
)
|
)
|
||||||
if (item?.played == true) {
|
if (item?.played == true) {
|
||||||
WatchedIcon(padding = 0.dp)
|
WatchedIcon(padding = 0.dp)
|
||||||
|
|
@ -546,7 +551,7 @@ fun ListItemAlbum(
|
||||||
.runTimeTicks
|
.runTimeTicks
|
||||||
?.ticks
|
?.ticks
|
||||||
?.roundMinutes
|
?.roundMinutes
|
||||||
?.toString() ?: "",
|
?.let { LocalResources.current.formatDuration(it) } ?: "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue