mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-11-08 16:28: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"
|
||||
description: Report a bug
|
||||
title: "<title>"
|
||||
labels: [
|
||||
"bug"
|
||||
]
|
||||
|
|
@ -23,6 +22,16 @@ body:
|
|||
3. See error
|
||||
validations:
|
||||
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
|
||||
id: app-version
|
||||
attributes:
|
||||
|
|
|
|||
21
.github/ISSUE_TEMPLATE/01-playback.yml
vendored
21
.github/ISSUE_TEMPLATE/01-playback.yml
vendored
|
|
@ -1,6 +1,5 @@
|
|||
name: "Playback Problem"
|
||||
description: Report an issue with playback
|
||||
title: "<title>"
|
||||
labels: [
|
||||
"bug", "playback"
|
||||
]
|
||||
|
|
@ -12,12 +11,14 @@ body:
|
|||
description: Please enter a description of the playback issue
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: mediainfo
|
||||
- type: dropdown
|
||||
id: experimental
|
||||
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
|
||||
label: Experimental settings
|
||||
description: Do you have experiment settings turn on?
|
||||
options:
|
||||
- "Yes"
|
||||
- "No"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
|
@ -32,6 +33,14 @@ body:
|
|||
- External App (please specify)
|
||||
validations:
|
||||
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
|
||||
id: app-version
|
||||
attributes:
|
||||
|
|
|
|||
8
.github/actions/setup/action.yml
vendored
8
.github/actions/setup/action.yml
vendored
|
|
@ -6,11 +6,11 @@ runs:
|
|||
steps:
|
||||
# Setup the SDKs
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- name: Setup JDK
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6
|
||||
with:
|
||||
distribution: zulu
|
||||
java-version: '21'
|
||||
|
|
@ -21,9 +21,9 @@ runs:
|
|||
echo "BUILD_TOOLS_VERSION=36.0.0" >> $GITHUB_ENV
|
||||
echo "NDK_VERSION=29.0.14206865" >> $GITHUB_ENV
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v4
|
||||
uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4
|
||||
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
|
||||
shell: bash
|
||||
run: |
|
||||
|
|
|
|||
9
.github/workflows/main.yml
vendored
9
.github/workflows/main.yml
vendored
|
|
@ -16,7 +16,7 @@ concurrency:
|
|||
|
||||
env:
|
||||
BRANCH_NAME: ${{ github.ref_name }}
|
||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
|
||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
contents: write
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0 # Need the tags to build
|
||||
- name: Setup
|
||||
|
|
@ -54,7 +54,8 @@ jobs:
|
|||
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
|
||||
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
|
||||
run: |
|
||||
./gradlew clean assembleDefaultRelease assembleDefaultDebug --no-daemon
|
||||
./gradlew clean assembleDefaultDebug
|
||||
./gradlew assembleDefaultRelease
|
||||
|
||||
- name: Verify signatures
|
||||
run: |
|
||||
|
|
@ -76,7 +77,7 @@ jobs:
|
|||
echo "SHA256 checksums:"
|
||||
find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum
|
||||
- name: Upload mapping files
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: mapping
|
||||
path: |
|
||||
|
|
|
|||
10
.github/workflows/pr.yml
vendored
10
.github/workflows/pr.yml
vendored
|
|
@ -9,29 +9,29 @@ defaults:
|
|||
|
||||
env:
|
||||
BUILD_DIRS_ARTIFACT: build-dirs
|
||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx8g -XX:MaxMetaspaceSize=512m'"
|
||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'"
|
||||
|
||||
jobs:
|
||||
pre-commit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0 # Need the tags to build
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- name: Run pre-commit
|
||||
uses: pre-commit/action@v3.0.1
|
||||
uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: pre-commit
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0 # Need the tags to build
|
||||
- name: Setup
|
||||
|
|
|
|||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
|
|
@ -11,7 +11,7 @@ defaults:
|
|||
|
||||
env:
|
||||
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 }}
|
||||
|
||||
jobs:
|
||||
|
|
@ -21,7 +21,7 @@ jobs:
|
|||
contents: write
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0 # Need the tags to build
|
||||
- name: Setup
|
||||
|
|
@ -36,7 +36,8 @@ jobs:
|
|||
ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}"
|
||||
ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}"
|
||||
run: |
|
||||
./gradlew clean bundleAppstoreRelease bundleFiretvRelease
|
||||
./gradlew clean bundleAppstoreRelease
|
||||
./gradlew bundleFiretvRelease
|
||||
./gradlew assembleDefaultRelease
|
||||
- name: Verify signatures
|
||||
run: |
|
||||
|
|
@ -58,14 +59,14 @@ jobs:
|
|||
echo "SHA256 checksums:"
|
||||
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum
|
||||
- name: Upload AAB
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: AAB
|
||||
path: |
|
||||
app/build/outputs/bundle/**/*.aab
|
||||
compression-level: 0
|
||||
- name: Upload mapping files
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: mapping
|
||||
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.
|
||||
|
||||
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 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.
|
||||
|
||||
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!
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -20,12 +20,21 @@ plugins {
|
|||
alias(libs.plugins.openapi.generator)
|
||||
}
|
||||
|
||||
val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false
|
||||
val shouldSign = isCI && System.getenv("KEY_ALIAS") != null
|
||||
val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists()
|
||||
val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists()
|
||||
val mpvModuleExists = project.file("libs/wholphin-mpv-release.aar").exists()
|
||||
val extensionsRepoActive = project.hasProperty("WholphinExtensionsUsername")
|
||||
val isCI = providers.environmentVariable("CI").orElse("false").map { it.toBoolean() }
|
||||
val shouldSign =
|
||||
isCI.zip(
|
||||
providers.environmentVariable("KEY_ALIAS").orElse("").map { it.isNotBlank() },
|
||||
) { isCI, hasKey ->
|
||||
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
|
||||
val isBuildingBundle =
|
||||
|
|
@ -50,17 +59,22 @@ kotlin {
|
|||
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
|
||||
jvmTarget = JvmTarget.JVM_11
|
||||
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> {
|
||||
namespace = "com.github.damontecres.wholphin"
|
||||
compileSdk = 36
|
||||
compileSdk = libs.versions.compileSdk.getInt()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.github.damontecres.wholphin"
|
||||
minSdk = 23
|
||||
targetSdk = 36
|
||||
minSdk = libs.versions.minSdk.getInt()
|
||||
targetSdk = libs.versions.targetSdk.getInt()
|
||||
versionCode = gitTags.trim().lines().size
|
||||
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
|
||||
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
|
||||
|
|
@ -69,7 +83,7 @@ configure<ApplicationExtension> {
|
|||
}
|
||||
|
||||
signingConfigs {
|
||||
if (shouldSign) {
|
||||
if (shouldSign.get()) {
|
||||
create("ci") {
|
||||
file("ci.keystore").writeBytes(
|
||||
Base64.getDecoder().decode(System.getenv("SIGNING_KEY")),
|
||||
|
|
@ -95,7 +109,7 @@ configure<ApplicationExtension> {
|
|||
"proguard-rules.pro",
|
||||
)
|
||||
isDebuggable = false
|
||||
if (shouldSign) {
|
||||
if (shouldSign.get()) {
|
||||
signingConfig = signingConfigs.getByName("ci")
|
||||
} else {
|
||||
val localPropertiesFile = project.rootProject.file("local.properties")
|
||||
|
|
@ -166,7 +180,7 @@ configure<ApplicationExtension> {
|
|||
isEnable = !isBuildingBundle.get()
|
||||
|
||||
reset()
|
||||
include("armeabi-v7a", "arm64-v8a")
|
||||
include("armeabi-v7a", "arm64-v8a", "x86_64")
|
||||
isUniversalApk = true
|
||||
}
|
||||
}
|
||||
|
|
@ -323,7 +337,6 @@ dependencies {
|
|||
implementation(libs.androidx.room.common.jvm)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
implementation(libs.androidx.preference.ktx)
|
||||
implementation(libs.androidx.room.testing)
|
||||
|
|
@ -351,40 +364,39 @@ dependencies {
|
|||
implementation(libs.kache)
|
||||
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)
|
||||
|
||||
if (ffmpegModuleExists) {
|
||||
if (ffmpegModuleExists.get()) {
|
||||
logger.info("Using local ffmpeg decoder")
|
||||
implementation(files("libs/lib-decoder-ffmpeg-release.aar"))
|
||||
} else if (extensionsRepoActive) {
|
||||
} else if (extensionsRepoActive.get()) {
|
||||
logger.info("Using prebuilt ffmpeg decoder")
|
||||
implementation(libs.wholphin.extensions.ffmpeg)
|
||||
} else {
|
||||
logger.warn("Media3 ffmpeg decoder was NOT found")
|
||||
}
|
||||
if (av1ModuleExists) {
|
||||
if (av1ModuleExists.get()) {
|
||||
logger.info("Using local av1 decoder")
|
||||
implementation(files("libs/lib-decoder-av1-release.aar"))
|
||||
} else if (extensionsRepoActive) {
|
||||
} else if (extensionsRepoActive.get()) {
|
||||
logger.info("Using prebuilt av1 decoder")
|
||||
implementation(libs.wholphin.extensions.av1)
|
||||
} else {
|
||||
logger.warn("Media3 av1 decoder was NOT found")
|
||||
}
|
||||
if (mpvModuleExists) {
|
||||
if (mpvModuleExists.get()) {
|
||||
logger.info("Using local libMPV build")
|
||||
implementation(files("libs/wholphin-mpv-release.aar"))
|
||||
} else if (extensionsRepoActive) {
|
||||
} else if (extensionsRepoActive.get()) {
|
||||
logger.info("Using prebuilt libMPV")
|
||||
implementation(libs.wholphin.extensions.mpv)
|
||||
} 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.agent)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
|
|
@ -392,7 +404,8 @@ dependencies {
|
|||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.hilt.android.testing)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.mockk.android)
|
||||
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
|
||||
|
||||
-keep class com.github.damontecres.wholphin.util.mpv.* { *; }
|
||||
-keep class com.github.damontecres.wholphin.mpv.MPVLib { *; }
|
||||
|
||||
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; }
|
||||
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
|
||||
|
|
@ -11,3 +11,14 @@
|
|||
|
||||
# TODO investigate using smaller scope
|
||||
-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">
|
||||
|
||||
<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.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
|
@ -51,13 +52,21 @@
|
|||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize">
|
||||
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|density">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</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>
|
||||
|
||||
<provider
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import android.content.res.Configuration
|
|||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.WindowManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
|
|
@ -27,6 +28,8 @@ import androidx.lifecycle.lifecycleScope
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
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.preferences.AppPreferences
|
||||
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.DeviceProfileService
|
||||
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.NavigationManager
|
||||
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.SuggestionsSchedulerService
|
||||
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.hilt.AuthOkHttpClient
|
||||
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.collectLatestIn
|
||||
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.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
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.util.DebugLogTree
|
||||
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.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
|
@ -72,14 +79,15 @@ import kotlinx.coroutines.flow.firstOrNull
|
|||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
|
@ -89,6 +97,9 @@ class MainActivity : AppCompatActivity() {
|
|||
@Inject
|
||||
lateinit var userPreferencesDataStore: DataStore<AppPreferences>
|
||||
|
||||
@Inject
|
||||
lateinit var userPreferencesService: UserPreferencesService
|
||||
|
||||
@AuthOkHttpClient
|
||||
@Inject
|
||||
lateinit var okHttpClient: OkHttpClient
|
||||
|
|
@ -137,11 +148,16 @@ class MainActivity : AppCompatActivity() {
|
|||
@Inject
|
||||
lateinit var screensaverService: ScreensaverService
|
||||
|
||||
@Inject
|
||||
lateinit var intentService: IntentService
|
||||
|
||||
private var signInAuto = true
|
||||
private var playerBackend: PlayerBackend? = null
|
||||
|
||||
private val json =
|
||||
Json {
|
||||
classDiscriminator = "_type"
|
||||
serializersModule = requestSerializersModule
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
|
|
@ -152,37 +168,51 @@ class MainActivity : AppCompatActivity() {
|
|||
lifecycle.addObserver(playbackLifecycleObserver)
|
||||
|
||||
val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK)
|
||||
if (backStackStr != null) {
|
||||
val restoredBackStack =
|
||||
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")
|
||||
var backStack = json.decodeFromString<List<Destination>>(backStackStr)
|
||||
|
||||
var backStack = restoredBackStack
|
||||
if (!playExternalViewModel.launched.value) {
|
||||
val lastDest = backStack.lastOrNull()
|
||||
if (lastDest.isPlayback) {
|
||||
Timber.v("Restoring back stack with playback")
|
||||
Timber.v("Restoring back stack without playback")
|
||||
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
|
||||
}
|
||||
}
|
||||
navigationManager.backStack = NavBackStack(*backStack.toTypedArray())
|
||||
} else {
|
||||
val startDestination = intent?.let(::extractDestination) ?: Destination.Home()
|
||||
navigationManager.backStack = NavBackStack(startDestination)
|
||||
navigationManager.backStack = NavBackStack(Destination.Home())
|
||||
}
|
||||
|
||||
viewModel.serverRepository.currentUserFlow.collectLatestIn(lifecycleScope) { user ->
|
||||
if (user?.hasPin == true) {
|
||||
window?.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
)
|
||||
} else {
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
viewModel.serverRepository.currentUserFlow
|
||||
.onEach { user ->
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
if (user?.hasPin == true) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
}.catch { ex ->
|
||||
Timber.e(ex, "Error with settings flag secure")
|
||||
}.launchIn(lifecycleScope)
|
||||
|
||||
screensaverService.keepScreenOn
|
||||
.onEach { keepScreenOn ->
|
||||
Timber.v("keepScreenOn: %s", keepScreenOn)
|
||||
withContext(Dispatchers.Main) {
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
if (keepScreenOn) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
|
|
@ -193,55 +223,63 @@ class MainActivity : AppCompatActivity() {
|
|||
Timber.e(ex, "Error with keepScreenOn")
|
||||
}.launchIn(lifecycleScope)
|
||||
|
||||
viewModel.appStart()
|
||||
userPreferencesDataStore.data.collectLatestIn(lifecycleScope) { prefs ->
|
||||
signInAuto = prefs.signInAutomatically
|
||||
playerBackend = prefs.playbackPreferences.playerBackend
|
||||
}
|
||||
|
||||
viewModel.appStart(intent)
|
||||
setContent {
|
||||
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
|
||||
if (appPreferences == null) {
|
||||
// Show loading page if it is taking a while to get app preferences
|
||||
var showLoading by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(500)
|
||||
Timber.i("Showing loading page")
|
||||
showLoading = true
|
||||
}
|
||||
if (showLoading) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
) {
|
||||
LoadingPage()
|
||||
}
|
||||
}
|
||||
}
|
||||
appPreferences?.let { appPreferences ->
|
||||
LaunchedEffect(appPreferences.signInAutomatically) {
|
||||
signInAuto = appPreferences.signInAutomatically
|
||||
}
|
||||
CoilConfig(
|
||||
prefs = appPreferences,
|
||||
okHttpClient = okHttpClient,
|
||||
debugLogging = false,
|
||||
enableCache = true,
|
||||
)
|
||||
LaunchedEffect(appPreferences.debugLogging) {
|
||||
DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging
|
||||
}
|
||||
CompositionLocalProvider(LocalImageUrlService provides imageUrlService) {
|
||||
WholphinTheme(
|
||||
true,
|
||||
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
|
||||
) {
|
||||
ProvideLocalClock {
|
||||
MainContent(
|
||||
backStack = setupNavigationManager.backStack,
|
||||
navigationManager = navigationManager,
|
||||
appPreferences = appPreferences,
|
||||
backdropService = backdropService,
|
||||
screensaverService = screensaverService,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
MaterialTheme(colorScheme = PurpleThemeColors.darkScheme) {
|
||||
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
|
||||
var showLoading by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(500.milliseconds)
|
||||
Timber.i("Showing loading page")
|
||||
showLoading = true
|
||||
}
|
||||
if (showLoading) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
) {
|
||||
LoadingPage()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
userPreferences?.let { userPreferences ->
|
||||
val appPreferences = userPreferences.appPreferences
|
||||
CoilConfig(
|
||||
prefs = appPreferences,
|
||||
okHttpClient = okHttpClient,
|
||||
debugLogging = false,
|
||||
enableCache = true,
|
||||
)
|
||||
LaunchedEffect(appPreferences.debugLogging) {
|
||||
DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging
|
||||
}
|
||||
CompositionLocalProvider(LocalImageUrlService provides imageUrlService) {
|
||||
WholphinTheme(
|
||||
true,
|
||||
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
|
||||
) {
|
||||
ProvideLocalClock {
|
||||
MainContent(
|
||||
backStack = setupNavigationManager.backStack,
|
||||
navigationManager = navigationManager,
|
||||
userPreferences = userPreferences,
|
||||
backdropService = backdropService,
|
||||
screensaverService = screensaverService,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -250,7 +288,7 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (screensaverService.state.value.show) {
|
||||
if (screensaverService.state.value.run { show || showDim }) {
|
||||
screensaverService.stop(false)
|
||||
screensaverService.pulse()
|
||||
return true
|
||||
|
|
@ -263,6 +301,7 @@ class MainActivity : AppCompatActivity() {
|
|||
override fun onResume() {
|
||||
super.onResume()
|
||||
Timber.d("onResume")
|
||||
viewModel.appResume()
|
||||
lifecycleScope.launchDefault {
|
||||
screensaverService.pulse()
|
||||
}
|
||||
|
|
@ -271,7 +310,7 @@ class MainActivity : AppCompatActivity() {
|
|||
override fun onRestart() {
|
||||
super.onRestart()
|
||||
Timber.d("onRestart")
|
||||
viewModel.appStart()
|
||||
viewModel.appStart(null)
|
||||
if (!playExternalViewModel.launched.value) {
|
||||
// If restarting during playback that is not external, go back a page
|
||||
val lastDest = navigationManager.backStack.lastOrNull()
|
||||
|
|
@ -318,10 +357,14 @@ class MainActivity : AppCompatActivity() {
|
|||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
Timber.d("onSaveInstanceState")
|
||||
val str = json.encodeToString(navigationManager.backStack.toList())
|
||||
outState.putString(KEY_BACK_STACK, str)
|
||||
val playerBackend =
|
||||
runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend
|
||||
try {
|
||||
val str = json.encodeToString(navigationManager.backStack.toList())
|
||||
outState.putString(KEY_BACK_STACK, str)
|
||||
} catch (ex: Exception) {
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
|
@ -337,67 +380,34 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
Timber.d("onConfigurationChanged")
|
||||
Timber.d("onConfigurationChanged: newConfig=%s", newConfig)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
Timber.v("onNewIntent")
|
||||
setIntent(intent)
|
||||
extractDestination(intent)?.let {
|
||||
navigationManager.replace(it)
|
||||
}
|
||||
viewModel.appStart(intent)
|
||||
}
|
||||
|
||||
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) {
|
||||
lifecycleScope.launch(Dispatchers.Main + ExceptionHandler(autoToast = true)) {
|
||||
val attrs = window.attributes
|
||||
if (attrs.preferredDisplayModeId != modeId) {
|
||||
Timber.d("Switch preferredDisplayModeId to %s", modeId)
|
||||
window.attributes = attrs.apply { preferredDisplayModeId = modeId }
|
||||
lifecycleScope.launch(WholphinDispatchers.Main + ExceptionHandler(autoToast = true)) {
|
||||
try {
|
||||
val attrs = window.attributes
|
||||
if (attrs.preferredDisplayModeId != modeId) {
|
||||
Timber.d("Switch preferredDisplayModeId to %s", 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 {
|
||||
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_EXTERNAL_PLAYER = "extPlayer"
|
||||
|
||||
|
|
@ -413,68 +423,137 @@ class MainActivityViewModel
|
|||
@param:ApplicationContext private val context: Context,
|
||||
private val preferences: DataStore<AppPreferences>,
|
||||
val serverRepository: ServerRepository,
|
||||
private val navigationManager: SetupNavigationManager,
|
||||
private val setupNavigationManager: SetupNavigationManager,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val deviceProfileService: DeviceProfileService,
|
||||
private val backdropService: BackdropService,
|
||||
private val appUpgradeHandler: AppUpgradeHandler,
|
||||
private val intentService: IntentService,
|
||||
) : ViewModel() {
|
||||
fun appStart() {
|
||||
private val mutex = Mutex()
|
||||
|
||||
fun appStart(intent: Intent?) {
|
||||
viewModelScope.launchDefault {
|
||||
try {
|
||||
val needUpgrade = appUpgradeHandler.needUpgrade()
|
||||
if (needUpgrade) {
|
||||
showToast(
|
||||
context,
|
||||
context.getString(
|
||||
R.string.updated_toast,
|
||||
appUpgradeHandler.currentVersion.toString(),
|
||||
),
|
||||
)
|
||||
appUpgradeHandler.run()
|
||||
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")
|
||||
}
|
||||
appUpgradeHandler.copySubfont(false)
|
||||
val prefs =
|
||||
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||
val profileProtected =
|
||||
serverRepository.current.value?.user?.let {
|
||||
it.hasPin || it.requireLogin
|
||||
} == true
|
||||
if (prefs.signInAutomatically && !profileProtected) {
|
||||
val current =
|
||||
serverRepository.restoreSession(
|
||||
prefs.currentServerId?.toUUIDOrNull(),
|
||||
prefs.currentUserId?.toUUIDOrNull(),
|
||||
|
||||
try {
|
||||
val needUpgrade = appUpgradeHandler.needUpgrade()
|
||||
if (needUpgrade) {
|
||||
showToast(
|
||||
context,
|
||||
context.getString(
|
||||
R.string.updated_toast,
|
||||
appUpgradeHandler.currentVersion.toString(),
|
||||
),
|
||||
)
|
||||
if (current != null) {
|
||||
if (current.user.hasPin || current.user.requireLogin) {
|
||||
navigationManager.navigateTo(SetupDestination.UserList(current.server))
|
||||
appUpgradeHandler.run()
|
||||
}
|
||||
appUpgradeHandler.copySubfont(false)
|
||||
val prefs =
|
||||
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||
val profileProtected =
|
||||
serverRepository.current.value
|
||||
?.user
|
||||
?.isProtected == true
|
||||
if (prefs.signInAutomatically && !profileProtected) {
|
||||
val current =
|
||||
serverRepository.restoreSession(
|
||||
prefs.currentServerId?.toUUIDOrNull(),
|
||||
prefs.currentUserId?.toUUIDOrNull(),
|
||||
)
|
||||
if (current != null) {
|
||||
if (current.user.isProtected) {
|
||||
setupNavigationManager.navigateTo(
|
||||
SetupDestination.UserList(
|
||||
current.server,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
// Restored
|
||||
setupNavigationManager.navigateTo(
|
||||
SetupDestination.AppContent(
|
||||
current,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Restored
|
||||
navigationManager.navigateTo(SetupDestination.AppContent(current))
|
||||
// Did not restore
|
||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
} else {
|
||||
// Did not restore
|
||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
} else {
|
||||
navigationManager.navigateTo(SetupDestination.Loading)
|
||||
backdropService.clearBackdrop()
|
||||
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
|
||||
if (currentServerId != null) {
|
||||
val currentServer =
|
||||
serverRepository.serverDao.getServer(currentServerId)?.server
|
||||
if (currentServer != null) {
|
||||
navigationManager.navigateTo(SetupDestination.UserList(currentServer))
|
||||
setupNavigationManager.navigateTo(SetupDestination.Loading)
|
||||
backdropService.clearBackdrop()
|
||||
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
|
||||
if (currentServerId != null) {
|
||||
val currentServer =
|
||||
serverRepository.serverDao.getServer(currentServerId)?.server
|
||||
if (currentServer != null) {
|
||||
setupNavigationManager.navigateTo(
|
||||
SetupDestination.UserList(
|
||||
currentServer,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
} else {
|
||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
} else {
|
||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error during appStart")
|
||||
setupNavigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error during appStart")
|
||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||
}
|
||||
}
|
||||
viewModelScope.launchDefault {
|
||||
|
|
@ -482,6 +561,19 @@ class MainActivityViewModel
|
|||
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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import androidx.compose.runtime.rememberUpdatedState
|
|||
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.graphics.RectangleShape
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
|
|
@ -27,7 +28,6 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
|||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
|
|
@ -44,12 +44,12 @@ import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
|
|||
fun MainContent(
|
||||
backStack: MutableList<SetupDestination>,
|
||||
navigationManager: NavigationManager,
|
||||
appPreferences: AppPreferences,
|
||||
userPreferences: UserPreferences,
|
||||
backdropService: BackdropService,
|
||||
screensaverService: ScreensaverService,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val preferences by rememberUpdatedState(UserPreferences(appPreferences))
|
||||
val preferences by rememberUpdatedState(userPreferences)
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
|
|
@ -59,7 +59,7 @@ fun MainContent(
|
|||
// val backStack = rememberNavBackStack(SetupDestination.Loading)
|
||||
// setupNavigationManager.backStack = backStack
|
||||
val interfaceCustomization =
|
||||
remember(appPreferences) { InterfaceCustomization(appPreferences) }
|
||||
remember(userPreferences.appPreferences) { InterfaceCustomization(userPreferences.appPreferences) }
|
||||
CompositionLocalProvider(LocalInterfaceCustomization provides interfaceCustomization) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
|
|
@ -105,7 +105,7 @@ fun MainContent(
|
|||
mutableStateOf(true)
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
if (!appPreferences.signInAutomatically) {
|
||||
if (!userPreferences.appPreferences.signInAutomatically) {
|
||||
showContent = false
|
||||
}
|
||||
}
|
||||
|
|
@ -137,12 +137,28 @@ fun MainContent(
|
|||
val screenSaverState by screensaverService.state.collectAsState()
|
||||
if (screenSaverState.enabled || screenSaverState.enabledTemp) {
|
||||
AnimatedVisibility(
|
||||
screenSaverState.show,
|
||||
Modifier.fillMaxSize(),
|
||||
visible = screenSaverState.show,
|
||||
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.data.StringFormat
|
||||
import org.acra.ktx.initAcra
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.model.ServerVersion
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -120,5 +122,7 @@ class WholphinApplication :
|
|||
companion object {
|
||||
lateinit var instance: WholphinApplication
|
||||
private set
|
||||
|
||||
val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.github.damontecres.wholphin
|
||||
|
||||
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.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
|
|
@ -9,6 +11,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.lifecycle.Lifecycle
|
||||
|
|
@ -104,13 +107,23 @@ class WholphinDreamService :
|
|||
ProvideLocalClock {
|
||||
val screensaverPrefs = prefs.interfacePreferences.screensaverPreference
|
||||
val currentItem by itemFlow.collectAsState(null)
|
||||
AppScreensaverContent(
|
||||
currentItem = currentItem,
|
||||
showClock = screensaverPrefs.showClock,
|
||||
duration = screensaverPrefs.duration.milliseconds,
|
||||
animate = screensaverPrefs.animate,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AppScreensaverContent(
|
||||
currentItem = currentItem,
|
||||
showClock = screensaverPrefs.showClock,
|
||||
duration = screensaverPrefs.duration.milliseconds,
|
||||
animate = screensaverPrefs.animate,
|
||||
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.PlaybackEffect
|
||||
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.SeerrUser
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||
|
|
@ -46,8 +47,9 @@ import java.util.UUID
|
|||
ItemTrackModification::class,
|
||||
SeerrServer::class,
|
||||
SeerrUser::class,
|
||||
RememberedTab::class,
|
||||
],
|
||||
version = 33,
|
||||
version = 35,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(3, 4),
|
||||
|
|
@ -64,6 +66,8 @@ import java.util.UUID
|
|||
AutoMigration(30, 31),
|
||||
AutoMigration(31, 32),
|
||||
AutoMigration(32, 33),
|
||||
AutoMigration(33, 34),
|
||||
AutoMigration(34, 35),
|
||||
],
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
|
@ -81,6 +85,8 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
abstract fun seerrServerDao(): SeerrServerDao
|
||||
|
||||
abstract fun playbackEffectDao(): PlaybackEffectDao
|
||||
|
||||
abstract fun rememberedTabDao(): RememberedTabDao
|
||||
}
|
||||
|
||||
class Converters {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ sealed interface ExtrasItem {
|
|||
val subtitle: String?
|
||||
val isPlayed: Boolean
|
||||
val playedPercentage: Double
|
||||
val aspectRatio: Float?
|
||||
|
||||
/**
|
||||
* Represents multiple extras of the same type
|
||||
|
|
@ -39,6 +40,7 @@ sealed interface ExtrasItem {
|
|||
override val title: String,
|
||||
override val subtitle: String,
|
||||
override val isPlayed: Boolean,
|
||||
override val aspectRatio: Float?,
|
||||
) : ExtrasItem {
|
||||
override val destination: Destination =
|
||||
Destination.ItemGrid(
|
||||
|
|
@ -82,6 +84,8 @@ sealed interface ExtrasItem {
|
|||
get() = item.played
|
||||
override val playedPercentage
|
||||
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
|
||||
get() =
|
||||
when (this) {
|
||||
ExtraType.UNKNOWN -> R.string.other_extras
|
||||
ExtraType.CLIP -> R.string.clips
|
||||
ExtraType.TRAILER -> R.string.trailers
|
||||
ExtraType.BEHIND_THE_SCENES -> R.string.behind_the_scenes
|
||||
ExtraType.DELETED_SCENE -> R.string.deleted_scenes
|
||||
ExtraType.INTERVIEW -> R.string.interviews
|
||||
ExtraType.SCENE -> R.string.scenes
|
||||
ExtraType.SAMPLE -> R.string.samples
|
||||
ExtraType.THEME_SONG -> R.string.theme_songs
|
||||
ExtraType.THEME_VIDEO -> R.string.theme_videos
|
||||
ExtraType.FEATURETTE -> R.string.featurettes
|
||||
ExtraType.SHORT -> R.string.shorts
|
||||
ExtraType.UNKNOWN -> R.string.other_extras_title
|
||||
ExtraType.CLIP -> R.string.clips_title
|
||||
ExtraType.TRAILER -> R.string.trailers_title
|
||||
ExtraType.BEHIND_THE_SCENES -> R.string.behind_the_scenes_title
|
||||
ExtraType.DELETED_SCENE -> R.string.deleted_scenes_title
|
||||
ExtraType.INTERVIEW -> R.string.interviews_title
|
||||
ExtraType.SCENE -> R.string.scenes_title
|
||||
ExtraType.SAMPLE -> R.string.samples_title
|
||||
ExtraType.THEME_SONG -> R.string.theme_songs_title
|
||||
ExtraType.THEME_VIDEO -> R.string.theme_videos_title
|
||||
ExtraType.FEATURETTE -> R.string.featurettes_title
|
||||
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.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
|
|
@ -19,6 +19,9 @@ import javax.inject.Inject
|
|||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Wrapper for [ItemPlaybackDao] to save [ItemPlayback] objects and also convert them into [ChosenStreams]
|
||||
*/
|
||||
@Singleton
|
||||
class ItemPlaybackRepository
|
||||
@Inject
|
||||
|
|
@ -28,6 +31,13 @@ class ItemPlaybackRepository
|
|||
private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
|
||||
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(
|
||||
itemId: UUID,
|
||||
item: BaseItem,
|
||||
|
|
@ -40,6 +50,11 @@ class ItemPlaybackRepository
|
|||
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(
|
||||
item: BaseItem,
|
||||
itemPlayback: ItemPlayback?,
|
||||
|
|
@ -86,11 +101,14 @@ class ItemPlaybackRepository
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the choice for a specific media source for an item
|
||||
*/
|
||||
suspend fun savePlayVersion(
|
||||
itemId: UUID,
|
||||
sourceId: UUID,
|
||||
): ItemPlayback? =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
serverRepository.currentUser?.let { user ->
|
||||
val 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(
|
||||
item: BaseItem,
|
||||
itemPlayback: ItemPlayback?,
|
||||
|
|
@ -180,6 +203,9 @@ class ItemPlaybackRepository
|
|||
return toSave.copy(rowId = id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for [ItemPlaybackDao.getTrackModifications] to get the [ItemTrackModification] for an item, if any
|
||||
*/
|
||||
suspend fun getTrackModifications(
|
||||
itemId: UUID,
|
||||
trackIndex: Int,
|
||||
|
|
@ -188,6 +214,9 @@ class ItemPlaybackRepository
|
|||
itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and save a [ItemTrackModification] via [ItemPlaybackDao.saveItem]
|
||||
*/
|
||||
suspend fun saveTrackModifications(
|
||||
itemId: UUID,
|
||||
trackIndex: Int,
|
||||
|
|
@ -206,6 +235,9 @@ class ItemPlaybackRepository
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the user's source/stream choices for an item
|
||||
*/
|
||||
suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) {
|
||||
Timber.d("deleteChosenStreams: %s", chosenStreams)
|
||||
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(
|
||||
val itemPlayback: ItemPlayback?,
|
||||
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.JellyfinServerUsers
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.UUID
|
||||
|
||||
@Dao
|
||||
|
|
@ -56,6 +57,12 @@ interface JellyfinServerDao {
|
|||
userId: UUID,
|
||||
): 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")
|
||||
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.services.hilt.IoDispatcher
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
|
|
@ -56,7 +60,18 @@ class ServerRepository
|
|||
val currentServer: JellyfinServer? get() = _current.value?.server
|
||||
val currentServerFlow: Flow<JellyfinServer?> get() = _current.map { it?.server }
|
||||
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
|
||||
|
|
@ -109,7 +124,7 @@ class ServerRepository
|
|||
}.build()
|
||||
}
|
||||
val currentUser = CurrentUser(updatedServer, updatedUser)
|
||||
withContext(Dispatchers.Main) {
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
_current.value = currentUser
|
||||
_currentUserDto.value = userDto
|
||||
}
|
||||
|
|
@ -222,7 +237,7 @@ class ServerRepository
|
|||
|
||||
suspend fun removeUser(user: JellyfinUser) {
|
||||
if (current.value?.user?.id == user.id) {
|
||||
withContext(Dispatchers.Main) {
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
_current.value = null
|
||||
}
|
||||
userPreferencesDataStore.updateData {
|
||||
|
|
@ -241,7 +256,7 @@ class ServerRepository
|
|||
|
||||
suspend fun removeServer(server: JellyfinServer) {
|
||||
if (current.value?.server?.id == server.id) {
|
||||
withContext(Dispatchers.Main) {
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
_current.value = null
|
||||
}
|
||||
userPreferencesDataStore.updateData {
|
||||
|
|
@ -298,6 +313,16 @@ class ServerRepository
|
|||
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 {
|
||||
fun getServerSharedPreferences(context: Context): SharedPreferences =
|
||||
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
|
||||
*
|
||||
* Gets and sets values within a [GetItemsFilter]
|
||||
* Abstract way to filter data
|
||||
*/
|
||||
sealed interface ItemFilterBy<T> {
|
||||
sealed interface FilterBy<FilterType, DataType> {
|
||||
@get:StringRes
|
||||
val stringRes: Int
|
||||
|
||||
val supportMultiple: Boolean
|
||||
|
||||
fun get(filter: GetItemsFilter): T?
|
||||
fun get(filter: FilterType): DataType?
|
||||
|
||||
fun set(
|
||||
value: T?,
|
||||
filter: GetItemsFilter,
|
||||
): GetItemsFilter
|
||||
value: DataType?,
|
||||
filter: FilterType,
|
||||
): 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>> {
|
||||
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.ui.text.AnnotatedString
|
||||
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.detail.CardGridItem
|
||||
import com.github.damontecres.wholphin.ui.detail.music.artistsString
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||
import com.github.damontecres.wholphin.ui.dot
|
||||
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.joinNotBlank
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.playback.playable
|
||||
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.seriesProductionYears
|
||||
import com.github.damontecres.wholphin.ui.timeRemaining
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
|
@ -48,7 +55,7 @@ data class BaseItem(
|
|||
get() = type.playable
|
||||
|
||||
override val sortName: String
|
||||
get() = data.sortName ?: data.name ?: ""
|
||||
get() = data.alphabetSortName
|
||||
|
||||
val type get() = data.type
|
||||
|
||||
|
|
@ -56,14 +63,14 @@ data class BaseItem(
|
|||
|
||||
val title get() = if (type == BaseItemKind.EPISODE) data.seriesName else name
|
||||
|
||||
val subtitle
|
||||
get() =
|
||||
when (type) {
|
||||
BaseItemKind.EPISODE -> data.seasonEpisode + " - " + name
|
||||
BaseItemKind.SERIES -> data.seriesProductionYears
|
||||
BaseItemKind.AUDIO -> listOfNotNull(data.album, artistsString).joinToString(" - ")
|
||||
else -> data.productionYear?.toString()
|
||||
}
|
||||
val subtitle: String? by lazy {
|
||||
when (type) {
|
||||
BaseItemKind.EPISODE -> listOf(data.seasonEpisode, name).joinNotBlank(" - ")
|
||||
BaseItemKind.SERIES -> data.seriesProductionYears
|
||||
BaseItemKind.AUDIO -> listOf(data.album, artistsString).joinNotBlank(" - ")
|
||||
else -> data.productionYear?.toString()
|
||||
}
|
||||
}
|
||||
|
||||
val subtitleLong: String? by lazy {
|
||||
if (type == BaseItemKind.EPISODE) {
|
||||
|
|
@ -71,7 +78,7 @@ data class BaseItem(
|
|||
add(data.seasonEpisodePadded)
|
||||
add(data.name)
|
||||
add(data.premiereDate?.let { formatDateTime(it) })
|
||||
}.filterNotNull().joinToString(" - ")
|
||||
}.joinNotBlank(" - ")
|
||||
} else {
|
||||
data.productionYear?.toString()
|
||||
}
|
||||
|
|
@ -91,7 +98,12 @@ data class BaseItem(
|
|||
|
||||
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
|
||||
|
|
@ -100,7 +112,7 @@ data class BaseItem(
|
|||
val ui =
|
||||
BaseItemUi(
|
||||
episodeCornerText =
|
||||
data.indexNumber?.let { "E$it" }
|
||||
data.indexNumber?.let { formatEpisodeNumber(it) }
|
||||
?: data.premiereDate?.let(::formatDateTime),
|
||||
episodeUnplayedCornerText =
|
||||
if (type == BaseItemKind.SERIES ||
|
||||
|
|
@ -108,7 +120,7 @@ data class BaseItem(
|
|||
type == BaseItemKind.EPISODE ||
|
||||
type == BaseItemKind.BOX_SET
|
||||
) {
|
||||
data.indexNumber?.let { "E$it" }
|
||||
data.indexNumber?.let { formatEpisodeNumber(it) }
|
||||
?: data.userData
|
||||
?.unplayedItemCount
|
||||
?.takeIf { it > 0 }
|
||||
|
|
@ -136,16 +148,36 @@ data class BaseItem(
|
|||
} else if (type == BaseItemKind.BOX_SET) {
|
||||
data.productionYear?.let { add(it.toString()) }
|
||||
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 {
|
||||
data.productionYear?.let { add(it.toString()) }
|
||||
}
|
||||
data.runTimeTicks
|
||||
?.ticks
|
||||
?.roundMinutes
|
||||
?.let { add(it.toString()) }
|
||||
?.takeIf { it > Duration.ZERO }
|
||||
?.let { add(WholphinApplication.instance.resources.formatDuration(it)) }
|
||||
data.timeRemaining
|
||||
?.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 ->
|
||||
append(string)
|
||||
|
|
@ -197,25 +229,38 @@ data class BaseItem(
|
|||
*/
|
||||
fun destination(index: Int? = null): Destination {
|
||||
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 =
|
||||
// Redirect episodes & seasons to their series if possible
|
||||
when (type) {
|
||||
BaseItemKind.EPISODE -> {
|
||||
data.seasonId?.let { seasonId ->
|
||||
val seasonId = data.seasonId
|
||||
val seriesId = data.seriesId
|
||||
if (seasonId != null && seriesId != null) {
|
||||
Destination.SeriesOverview(
|
||||
data.seriesId!!,
|
||||
seriesId,
|
||||
BaseItemKind.SERIES,
|
||||
SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber),
|
||||
)
|
||||
} ?: Destination.MediaItem(this)
|
||||
} else {
|
||||
Destination.MediaItem(this)
|
||||
}
|
||||
}
|
||||
|
||||
BaseItemKind.SEASON -> {
|
||||
Destination.SeriesOverview(
|
||||
data.seriesId!!,
|
||||
BaseItemKind.SERIES,
|
||||
SeasonEpisodeIds(id, indexNumber, null, null),
|
||||
)
|
||||
val seriesId = data.seriesId
|
||||
if (seriesId != null) {
|
||||
Destination.SeriesOverview(
|
||||
seriesId,
|
||||
BaseItemKind.SERIES,
|
||||
SeasonEpisodeIds(id, indexNumber, null, null),
|
||||
)
|
||||
} else {
|
||||
Destination.MediaItem(this)
|
||||
}
|
||||
}
|
||||
|
||||
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 -> {
|
||||
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() } }
|
||||
|
||||
/** The server strips leading articles (eg "The ") from [BaseItemDto.sortName]. */
|
||||
val BaseItemDto.alphabetSortName: String get() = sortName ?: name ?: ""
|
||||
|
||||
@Immutable
|
||||
data class BaseItemUi(
|
||||
val episodeCornerText: String?,
|
||||
|
|
@ -283,6 +341,7 @@ fun createGenreDestination(
|
|||
parentId: UUID,
|
||||
parentName: String?,
|
||||
includeItemTypes: List<BaseItemKind>?,
|
||||
collectionType: CollectionType,
|
||||
) = Destination.FilteredCollection(
|
||||
itemId = parentId,
|
||||
parentType = BaseItemKind.GENRE,
|
||||
|
|
@ -298,9 +357,11 @@ fun createGenreDestination(
|
|||
genres = listOf(genreId),
|
||||
includeItemTypes = includeItemTypes,
|
||||
),
|
||||
useSavedLibraryDisplayInfo = false,
|
||||
useSavedLibraryDisplayInfo = true,
|
||||
libraryDisplayInfoIdOverride = "${parentId.toServerString()}_genres",
|
||||
),
|
||||
recursive = true,
|
||||
collectionType = collectionType,
|
||||
)
|
||||
|
||||
fun createStudioDestination(
|
||||
|
|
@ -324,9 +385,11 @@ fun createStudioDestination(
|
|||
studios = listOf(studioId),
|
||||
includeItemTypes = includeItemTypes,
|
||||
),
|
||||
useSavedLibraryDisplayInfo = false,
|
||||
useSavedLibraryDisplayInfo = true,
|
||||
libraryDisplayInfoIdOverride = "${parentId.toServerString()}_studios",
|
||||
),
|
||||
recursive = true,
|
||||
collectionType = CollectionType.UNKNOWN,
|
||||
)
|
||||
|
||||
val BaseItem.studioNames get() = data.studios?.mapNotNull { it.name }.orEmpty()
|
||||
|
|
|
|||
|
|
@ -1,38 +1,30 @@
|
|||
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.ImageType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Represents a chapter within a video
|
||||
*/
|
||||
data class Chapter(
|
||||
val itemId: UUID,
|
||||
val name: String?,
|
||||
val position: Duration,
|
||||
val imageUrl: String?,
|
||||
val tag: String?,
|
||||
val index: Int,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDto(
|
||||
dto: BaseItemDto,
|
||||
api: ApiClient,
|
||||
): List<Chapter> =
|
||||
fun fromDto(dto: BaseItemDto): List<Chapter> =
|
||||
dto.chapters
|
||||
?.mapIndexed { index, chapter ->
|
||||
Chapter(
|
||||
chapter.name,
|
||||
chapter.startPositionTicks.ticks,
|
||||
chapter.imageTag?.let {
|
||||
api.imageApi.getItemImageUrl(
|
||||
itemId = dto.id,
|
||||
imageType = ImageType.CHAPTER,
|
||||
tag = it,
|
||||
imageIndex = index,
|
||||
)
|
||||
},
|
||||
itemId = dto.id,
|
||||
name = chapter.name,
|
||||
position = chapter.startPositionTicks.ticks,
|
||||
tag = chapter.imageTag,
|
||||
index = index,
|
||||
)
|
||||
}?.sortedBy { it.position }
|
||||
.orEmpty()
|
||||
|
|
|
|||
|
|
@ -56,12 +56,14 @@ enum class SeerrItemType(
|
|||
enum class SeerrAvailability(
|
||||
val status: Int,
|
||||
) {
|
||||
// https://github.com/seerr-team/seerr/blob/develop/server/constants/media.ts#L14
|
||||
UNKNOWN(1),
|
||||
PENDING(2),
|
||||
PROCESSING(3),
|
||||
PARTIALLY_AVAILABLE(4),
|
||||
AVAILABLE(5),
|
||||
DELETED(6),
|
||||
BLOCKLISTED(6),
|
||||
DELETED(7),
|
||||
;
|
||||
|
||||
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].
|
||||
*/
|
||||
|
|
@ -84,6 +103,7 @@ data class DiscoverItem(
|
|||
@Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?,
|
||||
val posterUrl: String?,
|
||||
val backDropUrl: String?,
|
||||
val logoUrl: String?,
|
||||
val jellyfinItemId: UUID?,
|
||||
) : CardGridItem {
|
||||
override val gridId: String get() = id.toString()
|
||||
|
|
@ -92,13 +112,7 @@ data class DiscoverItem(
|
|||
|
||||
val destination: Destination
|
||||
get() {
|
||||
val jfType =
|
||||
when (type) {
|
||||
SeerrItemType.MOVIE -> BaseItemKind.MOVIE
|
||||
SeerrItemType.TV -> BaseItemKind.SERIES
|
||||
SeerrItemType.PERSON -> BaseItemKind.PERSON
|
||||
SeerrItemType.UNKNOWN -> null
|
||||
}
|
||||
val jfType = type.baseItemKind
|
||||
return if (jellyfinItemId != null && jfType != null) {
|
||||
Destination.MediaItem(
|
||||
itemId = jellyfinItemId,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import kotlinx.serialization.Serializable
|
|||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
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.GetPersonsRequest
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
|
|
@ -22,9 +23,13 @@ data class CollectionFolderFilter(
|
|||
val nameOverride: String? = null,
|
||||
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,
|
||||
/**
|
||||
* 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,
|
||||
)
|
||||
|
||||
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
|
||||
*/
|
||||
|
|
@ -158,4 +179,5 @@ data class GetItemsFilter(
|
|||
enum class GetItemsFilterOverride {
|
||||
NONE,
|
||||
PERSON,
|
||||
ARTIST,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,15 +109,7 @@ sealed interface HomeRowConfig {
|
|||
@SerialName("Favorite")
|
||||
data class Favorite(
|
||||
val kind: BaseItemKind,
|
||||
override val viewOptions: HomeRowViewOptions =
|
||||
if (kind == BaseItemKind.EPISODE) {
|
||||
HomeRowViewOptions(
|
||||
heightDp = Cards.HEIGHT_EPISODE,
|
||||
aspectRatio = AspectRatio.WIDE,
|
||||
)
|
||||
} else {
|
||||
HomeRowViewOptions()
|
||||
},
|
||||
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
) : HomeRowConfig {
|
||||
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions)
|
||||
}
|
||||
|
|
@ -243,7 +235,7 @@ data class HomeRowViewOptions(
|
|||
|
||||
val liveTvDefault =
|
||||
HomeRowViewOptions(
|
||||
heightDp = 96,
|
||||
heightDp = Cards.HEIGHT_LIVE_TV,
|
||||
aspectRatio = AspectRatio.WIDE,
|
||||
contentScale = PrefContentScale.FIT,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import androidx.room.Index
|
|||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Relation
|
||||
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 kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
|
|
@ -65,11 +67,17 @@ data class JellyfinUser(
|
|||
val requireLogin: Boolean = false,
|
||||
val lastUsed: ZonedDateTime? = null,
|
||||
val uiLanguage: String? = null,
|
||||
@Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(),
|
||||
) {
|
||||
val hasPin: Boolean get() = pin.isNotNullOrBlank()
|
||||
|
||||
val isProtected: Boolean get() = hasPin || requireLogin
|
||||
|
||||
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>,
|
||||
)
|
||||
|
||||
@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
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -11,38 +8,13 @@ import java.util.UUID
|
|||
*
|
||||
* This is not the same thing as a Jellyfin server playlist
|
||||
*/
|
||||
class Playlist(
|
||||
items: List<PlaylistItem>,
|
||||
startIndex: Int = 0,
|
||||
data class Playlist(
|
||||
val items: List<PlaylistItem>,
|
||||
) {
|
||||
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 {
|
||||
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(
|
||||
private val flag: Int,
|
||||
vararg val parents: SeerrPermission,
|
||||
) {
|
||||
// Source: https://github.com/seerr-team/seerr/blob/develop/server/lib/permissions.ts
|
||||
NONE(0),
|
||||
|
|
@ -17,28 +18,28 @@ enum class SeerrPermission(
|
|||
REQUEST(32),
|
||||
VOTE(64),
|
||||
AUTO_APPROVE(128),
|
||||
AUTO_APPROVE_MOVIE(256),
|
||||
AUTO_APPROVE_TV(512),
|
||||
AUTO_APPROVE_MOVIE(256, AUTO_APPROVE),
|
||||
AUTO_APPROVE_TV(512, AUTO_APPROVE),
|
||||
REQUEST_4K(1024),
|
||||
REQUEST_4K_MOVIE(2048),
|
||||
REQUEST_4K_TV(4096),
|
||||
REQUEST_ADVANCED(8192),
|
||||
REQUEST_VIEW(16384),
|
||||
REQUEST_4K_MOVIE(2048, REQUEST_4K),
|
||||
REQUEST_4K_TV(4096, REQUEST_4K),
|
||||
REQUEST_ADVANCED(8192, MANAGE_REQUESTS),
|
||||
REQUEST_VIEW(16384, MANAGE_REQUESTS),
|
||||
AUTO_APPROVE_4K(32768),
|
||||
AUTO_APPROVE_4K_MOVIE(65536),
|
||||
AUTO_APPROVE_4K_TV(131072),
|
||||
REQUEST_MOVIE(262144),
|
||||
REQUEST_TV(524288),
|
||||
AUTO_APPROVE_4K_MOVIE(65536, AUTO_APPROVE_4K),
|
||||
AUTO_APPROVE_4K_TV(131072, AUTO_APPROVE_4K),
|
||||
REQUEST_MOVIE(262144, REQUEST),
|
||||
REQUEST_TV(524288, REQUEST),
|
||||
MANAGE_ISSUES(1048576),
|
||||
VIEW_ISSUES(2097152),
|
||||
CREATE_ISSUES(4194304),
|
||||
VIEW_ISSUES(2097152, MANAGE_ISSUES),
|
||||
CREATE_ISSUES(4194304, MANAGE_ISSUES),
|
||||
AUTO_REQUEST(8388608),
|
||||
AUTO_REQUEST_MOVIE(16777216),
|
||||
AUTO_REQUEST_TV(33554432),
|
||||
RECENT_VIEW(67108864),
|
||||
WATCHLIST_VIEW(134217728),
|
||||
MANAGE_BLACKLIST(268435456),
|
||||
VIEW_BLACKLIST(1073741824),
|
||||
AUTO_REQUEST_MOVIE(16777216, AUTO_REQUEST),
|
||||
AUTO_REQUEST_TV(33554432, AUTO_REQUEST),
|
||||
RECENT_VIEW(67108864, MANAGE_REQUESTS),
|
||||
WATCHLIST_VIEW(134217728, MANAGE_REQUESTS),
|
||||
MANAGE_BLOCKLIST(268435456),
|
||||
VIEW_BLOCKLIST(1073741824, MANAGE_BLOCKLIST),
|
||||
;
|
||||
|
||||
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)
|
||||
*/
|
||||
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?,
|
||||
): String? = null
|
||||
|
||||
fun validate(value: T): PreferenceValidation = PreferenceValidation.Valid
|
||||
fun validate(
|
||||
prefs: Pref,
|
||||
value: T,
|
||||
): PreferenceValidation = PreferenceValidation.Valid
|
||||
|
||||
companion object {
|
||||
val SkipForward =
|
||||
|
|
@ -215,15 +218,16 @@ sealed interface AppPreference<Pref, T> {
|
|||
)
|
||||
|
||||
val HomeClickToPlay =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
AppChoicePreference<AppPreferences, Boolean>(
|
||||
title = R.string.continue_watching_click_behavior,
|
||||
defaultValue = false,
|
||||
getter = { it.homePagePreferences.clickToPlay },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateHomePagePreferences { clickToPlay = value }
|
||||
},
|
||||
summaryOn = R.string.continue_watching_click_summary_on,
|
||||
summaryOff = R.string.continue_watching_click_summary_off,
|
||||
displayValues = R.array.home_click_to_play_options,
|
||||
indexToValue = { it != 0 },
|
||||
valueToIndex = { if (it) 1 else 0 },
|
||||
)
|
||||
|
||||
val PlayThemeMusic =
|
||||
|
|
@ -391,18 +395,25 @@ sealed interface AppPreference<Pref, T> {
|
|||
defaultValue = true,
|
||||
getter = { it.playbackPreferences.overrides.ac3Supported },
|
||||
setter = { prefs, value ->
|
||||
prefs.updatePlaybackOverrides { ac3Supported = value }
|
||||
if (!value) prefs.updateExperimentalPreferences { preferAc3Surround = false }
|
||||
prefs.updatePlaybackOverrides {
|
||||
ac3Supported = value
|
||||
}
|
||||
},
|
||||
summaryOn = R.string.enabled,
|
||||
summaryOff = R.string.disabled,
|
||||
)
|
||||
|
||||
val DownMixStereo =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.downmix_stereo,
|
||||
defaultValue = false,
|
||||
getter = { it.playbackPreferences.overrides.downmixStereo },
|
||||
setter = { prefs, value ->
|
||||
prefs.updatePlaybackOverrides { downmixStereo = value }
|
||||
if (value) prefs.updateExperimentalPreferences { preferAc3Surround = false }
|
||||
prefs.updatePlaybackOverrides {
|
||||
downmixStereo = value
|
||||
}
|
||||
},
|
||||
summaryOn = R.string.enabled,
|
||||
summaryOff = R.string.disabled,
|
||||
|
|
@ -572,6 +583,13 @@ sealed interface AppPreference<Pref, T> {
|
|||
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 =
|
||||
AppChoicePreference<AppPreferences, SkipSegmentBehavior>(
|
||||
title = R.string.skip_intro_behavior,
|
||||
|
|
@ -644,6 +662,20 @@ sealed interface AppPreference<Pref, T> {
|
|||
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 =
|
||||
AppChoicePreference<AppPreferences, PrefContentScale>(
|
||||
title = R.string.global_content_scale,
|
||||
|
|
@ -1106,6 +1138,7 @@ val basicPreferences =
|
|||
AppPreference.CustomizeHome,
|
||||
AppPreference.UserPinnedNavDrawerItems,
|
||||
AppPreference.UserInterfaceLanguage,
|
||||
AppPreference.UserProfileSettings,
|
||||
),
|
||||
),
|
||||
PreferenceGroup(
|
||||
|
|
@ -1208,6 +1241,7 @@ val advancedPreferences =
|
|||
AppPreference.CinemaMode,
|
||||
AppPreference.GlobalContentScale,
|
||||
AppPreference.SkipSegments,
|
||||
AppPreference.DpadSeekModePref,
|
||||
AppPreference.MaxBitrate,
|
||||
AppPreference.RefreshRateSwitching,
|
||||
AppPreference.ResolutionSwitching,
|
||||
|
|
@ -1266,8 +1300,16 @@ val advancedPreferences =
|
|||
AppPreference.DebugLogging,
|
||||
AppPreference.ImageDiskCacheSize,
|
||||
AppPreference.ClearImageCache,
|
||||
ExperimentalPreference.Enable,
|
||||
AppPreference.OssLicenseInfo,
|
||||
),
|
||||
conditionalPreferences =
|
||||
listOf(
|
||||
ConditionalPreferences(
|
||||
condition = { it.experimentalPreferences.enabled },
|
||||
preferences = listOf(ExperimentalPreference.ExperimentalSettings),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -1296,6 +1338,14 @@ val screensaverPreferences =
|
|||
ScreensaverPreference.Start,
|
||||
),
|
||||
),
|
||||
PreferenceGroup(
|
||||
title = R.string.dim_screen,
|
||||
preferences =
|
||||
listOf(
|
||||
ScreensaverPreference.DimToggle,
|
||||
ScreensaverPreference.DimPercentage,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
data class AppSwitchPreference<Pref>(
|
||||
|
|
@ -1303,11 +1353,16 @@ data class AppSwitchPreference<Pref>(
|
|||
override val defaultValue: Boolean,
|
||||
override val getter: (prefs: Pref) -> Boolean,
|
||||
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 summaryOn: Int? = null,
|
||||
@param:StringRes val summaryOff: Int? = null,
|
||||
) : AppPreference<Pref, Boolean> {
|
||||
override fun validate(
|
||||
prefs: Pref,
|
||||
value: Boolean,
|
||||
): PreferenceValidation = validator.invoke(prefs, value)
|
||||
|
||||
override fun summary(
|
||||
context: Context,
|
||||
value: Boolean?,
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ class AppPreferencesSerializer
|
|||
AppPreference.RefreshRateSwitching.defaultValue
|
||||
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
|
||||
cinemaMode = AppPreference.CinemaMode.defaultValue
|
||||
dpadSeekMode = AppPreference.DpadSeekModePref.defaultValue
|
||||
|
||||
overrides =
|
||||
PlaybackOverrides
|
||||
|
|
@ -174,6 +175,16 @@ class AppPreferencesSerializer
|
|||
showLyrics = true
|
||||
showAlbumArt = true
|
||||
}.build()
|
||||
|
||||
experimentalPreferences =
|
||||
ExperimentalPreferences
|
||||
.newBuilder()
|
||||
.apply {
|
||||
enabled = false
|
||||
videoTunnelingEnabled = false
|
||||
preferAc3Surround = ExperimentalPreference.PreferAc3ForSurround.defaultValue
|
||||
disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue
|
||||
}.build()
|
||||
}.build()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): AppPreferences {
|
||||
|
|
@ -252,6 +263,11 @@ inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder
|
|||
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() {
|
||||
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
|
||||
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
|
||||
|
||||
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.Companion.milliseconds
|
||||
|
||||
|
|
@ -10,3 +24,48 @@ val PlaybackPreferences.skipBackOnResume: Duration?
|
|||
} else {
|
||||
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 =
|
||||
AppClickablePreference<AppPreferences>(
|
||||
title = R.string.start_screensaver,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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.UserConfiguration
|
||||
|
||||
|
|
@ -8,6 +9,7 @@ import org.jellyfin.sdk.model.api.UserConfiguration
|
|||
*/
|
||||
data class UserPreferences(
|
||||
val appPreferences: AppPreferences,
|
||||
val userPreferences: JellyfinUserPreferences?,
|
||||
)
|
||||
|
||||
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.datastore.core.DataStore
|
||||
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.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.data.model.RememberedTab
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
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.update
|
||||
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.updateInterfacePreferences
|
||||
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.ui.preferences.PreferencesViewModel
|
||||
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.util.Version
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -44,6 +54,8 @@ class AppUpgradeHandler
|
|||
@param:ApplicationContext private val context: Context,
|
||||
private val appPreferences: DataStore<AppPreferences>,
|
||||
private val seerrServerDao: SeerrServerDao,
|
||||
private val serverDao: JellyfinServerDao,
|
||||
private val rememberedTabDao: RememberedTabDao,
|
||||
) {
|
||||
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
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.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.BackdropStyle
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -55,7 +55,7 @@ class BackdropService
|
|||
* Update the backdrop to use the specified item
|
||||
*/
|
||||
suspend fun submit(item: BaseItem) =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
val imageUrl =
|
||||
if (item.type == BaseItemKind.GENRE) {
|
||||
item.imageUrlOverride
|
||||
|
|
@ -76,7 +76,7 @@ class BackdropService
|
|||
suspend fun submit(
|
||||
itemId: String,
|
||||
imageUrl: String?,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
) = withContext(WholphinDispatchers.IO) {
|
||||
if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) {
|
||||
_backdropFlow.update {
|
||||
it.copy(
|
||||
|
|
@ -132,7 +132,7 @@ class BackdropService
|
|||
}
|
||||
|
||||
suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
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.ui.collectLatestIn
|
||||
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import com.google.common.cache.CacheBuilder
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
import dagger.hilt.android.scopes.ActivityScoped
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
|
|
@ -56,7 +56,7 @@ class DatePlayedService
|
|||
adjacentTo = item.id,
|
||||
limit = 1,
|
||||
)
|
||||
return scope.async(Dispatchers.IO) {
|
||||
return scope.async(WholphinDispatchers.IO) {
|
||||
val premiereDate = item.data.premiereDate
|
||||
try {
|
||||
val result =
|
||||
|
|
@ -92,7 +92,7 @@ class DatePlayedService
|
|||
* the previous episode's last played timestamp, and the episode's premiere date
|
||||
*/
|
||||
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
val seriesId = item.data.seriesId
|
||||
return@withContext if (seriesId != null) {
|
||||
datePlayedCache
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
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.createDeviceProfile
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -34,20 +37,17 @@ class DeviceProfileService
|
|||
private var deviceProfile: DeviceProfile? = null
|
||||
|
||||
suspend fun getOrCreateDeviceProfile(
|
||||
prefs: PlaybackPreferences,
|
||||
appPrefs: AppPreferences,
|
||||
serverVersion: ServerVersion?,
|
||||
): DeviceProfile =
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(WholphinDispatchers.Default) {
|
||||
val prefs = appPrefs.playbackPreferences
|
||||
mutex.withLock {
|
||||
val newConfig =
|
||||
DeviceProfileConfiguration(
|
||||
maxBitrate = prefs.maxBitrate.toInt(),
|
||||
isAC3Enabled = prefs.overrides.ac3Supported,
|
||||
downMixAudio = prefs.overrides.downmixStereo,
|
||||
assPlaybackMode = prefs.overrides.assPlaybackMode,
|
||||
pgsDirectPlay = prefs.overrides.directPlayPgs,
|
||||
dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL,
|
||||
decodeAv1 = prefs.overrides.decodeAv1,
|
||||
overrides = prefs.overrides,
|
||||
experimental = appPrefs.experimentalPreferences,
|
||||
jellyfinTenEleven =
|
||||
serverVersion != null && serverVersion >= ServerVersion(10, 11, 0),
|
||||
)
|
||||
|
|
@ -57,12 +57,13 @@ class DeviceProfileService
|
|||
createDeviceProfile(
|
||||
mediaTest = mediaCodecCapabilitiesTest,
|
||||
maxBitrate = newConfig.maxBitrate,
|
||||
isAC3Enabled = newConfig.isAC3Enabled,
|
||||
downMixAudio = newConfig.downMixAudio,
|
||||
assDirectPlay = newConfig.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
|
||||
pgsDirectPlay = newConfig.pgsDirectPlay,
|
||||
dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay,
|
||||
isAC3Enabled = newConfig.overrides.ac3Supported,
|
||||
downMixAudio = newConfig.overrides.downmixStereo,
|
||||
assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
|
||||
pgsDirectPlay = newConfig.overrides.directPlayPgs,
|
||||
dolbyVisionELDirectPlay = newConfig.overrides.directPlayDolbyVisionEL,
|
||||
decodeAv1 = prefs.overrides.decodeAv1,
|
||||
preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround },
|
||||
jellyfinTenEleven = newConfig.jellyfinTenEleven,
|
||||
)
|
||||
}
|
||||
|
|
@ -76,11 +77,7 @@ class DeviceProfileService
|
|||
*/
|
||||
data class DeviceProfileConfiguration(
|
||||
val maxBitrate: Int,
|
||||
val isAC3Enabled: Boolean,
|
||||
val downMixAudio: Boolean,
|
||||
val assPlaybackMode: AssPlaybackMode,
|
||||
val pgsDirectPlay: Boolean,
|
||||
val dolbyVisionELDirectPlay: Boolean,
|
||||
val decodeAv1: Boolean,
|
||||
val overrides: PlaybackOverrides,
|
||||
val experimental: ExperimentalPreferences,
|
||||
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.model.BaseItem
|
||||
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 org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
|
|
@ -48,9 +50,10 @@ class ExtrasService
|
|||
if (items.size == 1) {
|
||||
val item = items.first()
|
||||
val title =
|
||||
item.title ?: context.resources.getQuantityString(type.pluralRes, 1)
|
||||
item.title.takeIf { it.isNotNullOrBlank() }
|
||||
?: context.resources.getQuantityString(type.pluralRes, 1)
|
||||
val subtitle =
|
||||
if (item.title == null) {
|
||||
if (item.title.isNotNullOrBlank()) {
|
||||
context.resources.getQuantityString(type.pluralRes, 1)
|
||||
} else {
|
||||
null
|
||||
|
|
@ -64,14 +67,14 @@ class ExtrasService
|
|||
imageUrl = imageUrlService.getItemImageUrl(item, ImageType.PRIMARY),
|
||||
)
|
||||
} else if (items.size > 1) {
|
||||
val title =
|
||||
context.resources.getQuantityString(type.pluralRes, items.size)
|
||||
val title = context.resources.getString(type.stringRes)
|
||||
val subtitle =
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.items,
|
||||
items.size,
|
||||
items.size,
|
||||
)
|
||||
val itemForImage = items.random()
|
||||
ExtrasItem.Group(
|
||||
parentId = itemId,
|
||||
type = type,
|
||||
|
|
@ -81,9 +84,10 @@ class ExtrasService
|
|||
isPlayed = items.all { it.played },
|
||||
imageUrl =
|
||||
imageUrlService.getItemImageUrl(
|
||||
items.random(),
|
||||
itemForImage,
|
||||
ImageType.PRIMARY,
|
||||
),
|
||||
aspectRatio = itemForImage.aspectRatio,
|
||||
)
|
||||
} else {
|
||||
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.HomePageSettings
|
||||
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.createGenreDestination
|
||||
import com.github.damontecres.wholphin.data.model.createStudioDestination
|
||||
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
|
||||
import com.github.damontecres.wholphin.preferences.HomePagePreferences
|
||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.AspectRatio
|
||||
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.formatTypeName
|
||||
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.toBaseItems
|
||||
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.StringStringProvider
|
||||
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.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler
|
||||
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.GetStudiosRequestHandler
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
|
||||
import com.github.damontecres.wholphin.util.supportedHomeCollectionTypes
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
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.userApi
|
||||
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.api.BaseItemKind
|
||||
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.ItemFields
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
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.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
|
||||
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.GetStudiosRequest
|
||||
import timber.log.Timber
|
||||
|
|
@ -239,7 +245,7 @@ class HomeSettingsService
|
|||
settings.rows.mapIndexed { index, config ->
|
||||
resolve(index, config)
|
||||
}
|
||||
HomePageResolvedSettings(resolvedRows)
|
||||
HomePageResolvedSettings(userId, resolvedRows)
|
||||
} else {
|
||||
createDefault(userId)
|
||||
}
|
||||
|
|
@ -250,12 +256,15 @@ class HomeSettingsService
|
|||
/**
|
||||
* 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 =
|
||||
settings.rows.mapIndexed { index, config ->
|
||||
resolve(index, config)
|
||||
}
|
||||
val resolvedSettings = HomePageResolvedSettings(resolvedRows)
|
||||
val resolvedSettings = HomePageResolvedSettings(userId, resolvedRows)
|
||||
currentSettings.update { resolvedSettings }
|
||||
}
|
||||
|
||||
|
|
@ -275,20 +284,25 @@ class HomeSettingsService
|
|||
|
||||
val includedIds =
|
||||
libraries
|
||||
.mapIndexed { index, it ->
|
||||
val parentId = it.itemId
|
||||
val title = getRecentlyAddedTitle(it)
|
||||
if (it.collectionType == CollectionType.LIVETV) {
|
||||
.mapIndexed { index, library ->
|
||||
val parentId = library.itemId
|
||||
val title = getRecentlyAddedTitle(library.name)
|
||||
if (library.collectionType == CollectionType.LIVETV) {
|
||||
HomeRowConfigDisplay(
|
||||
id = index,
|
||||
title = ResStringProvider(R.string.live_tv),
|
||||
title = ResStringProvider(R.string.watch_live),
|
||||
config = HomeRowConfig.TvPrograms(),
|
||||
)
|
||||
} else {
|
||||
val viewOptions = viewOptionsForCollectionType(library.collectionType)
|
||||
HomeRowConfigDisplay(
|
||||
id = index,
|
||||
title = title,
|
||||
config = HomeRowConfig.RecentlyAdded(parentId),
|
||||
config =
|
||||
HomeRowConfig.RecentlyAdded(
|
||||
parentId = parentId,
|
||||
viewOptions = viewOptions,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -301,7 +315,7 @@ class HomeSettingsService
|
|||
),
|
||||
)
|
||||
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 config = userDto.configuration ?: DefaultUserConfiguration
|
||||
val libraries =
|
||||
api.userViewsApi
|
||||
.getUserViews(userId = userId)
|
||||
.content.items
|
||||
.filter {
|
||||
it.collectionType in supportedHomeCollectionTypes &&
|
||||
it.id !in config.latestItemsExcludes
|
||||
navDrawerService
|
||||
.getAllUserLibraries(userId, userDto.tvAccess)
|
||||
.filterNot {
|
||||
it.itemId in config.latestItemsExcludes
|
||||
}
|
||||
|
||||
return if (customPrefs.isNotEmpty()) {
|
||||
|
|
@ -367,7 +379,7 @@ class HomeSettingsService
|
|||
if (userDto.tvAccess) {
|
||||
HomeRowConfigDisplay(
|
||||
id = id++,
|
||||
title = ResStringProvider(R.string.live_tv),
|
||||
title = ResStringProvider(R.string.watch_live),
|
||||
config = HomeRowConfig.TvPrograms(),
|
||||
)
|
||||
} else {
|
||||
|
|
@ -396,15 +408,21 @@ class HomeSettingsService
|
|||
}
|
||||
}
|
||||
if (sectionType == HomeSectionType.LATEST_MEDIA) {
|
||||
libraries.map {
|
||||
libraries.map { library ->
|
||||
val viewOptions =
|
||||
viewOptionsForCollectionType(library.collectionType)
|
||||
HomeRowConfigDisplay(
|
||||
id = id++,
|
||||
title =
|
||||
ResArgStringProvider(
|
||||
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) {
|
||||
|
|
@ -413,7 +431,7 @@ class HomeSettingsService
|
|||
null
|
||||
}
|
||||
}.flatten()
|
||||
HomePageResolvedSettings(rowConfigs)
|
||||
HomePageResolvedSettings(userId, rowConfigs)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
@ -503,8 +521,8 @@ class HomeSettingsService
|
|||
is HomeRowConfig.Favorite -> {
|
||||
val name =
|
||||
ResProviderStringProvider(
|
||||
R.string.favorite_items,
|
||||
ResStringProvider(favoriteOptions[config.kind]!!),
|
||||
R.string.favorite_items_title,
|
||||
ResStringProvider(formatTypeName(config.kind)),
|
||||
)
|
||||
HomeRowConfigDisplay(id, name, config)
|
||||
}
|
||||
|
|
@ -520,7 +538,7 @@ class HomeSettingsService
|
|||
is HomeRowConfig.TvPrograms -> {
|
||||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = ResStringProvider(R.string.live_tv),
|
||||
title = ResStringProvider(R.string.watch_live),
|
||||
config,
|
||||
)
|
||||
}
|
||||
|
|
@ -550,8 +568,10 @@ class HomeSettingsService
|
|||
): StringProvider =
|
||||
try {
|
||||
api.userLibraryApi
|
||||
.getItem(itemId = itemId)
|
||||
.content.name
|
||||
.getItem(
|
||||
userId = serverRepository.currentUser?.id,
|
||||
itemId = itemId,
|
||||
).content.name
|
||||
?.let {
|
||||
if (stringRes == null) {
|
||||
StringStringProvider(it)
|
||||
|
|
@ -592,6 +612,7 @@ class HomeSettingsService
|
|||
items = resume,
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = resume.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -611,6 +632,7 @@ class HomeSettingsService
|
|||
items = nextUp,
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = nextUp.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -631,17 +653,14 @@ class HomeSettingsService
|
|||
prefs.maxDaysNextUp,
|
||||
row.viewOptions.useSeries,
|
||||
)
|
||||
val combined = latestNextUpService.buildCombined(resume, nextUp)
|
||||
|
||||
Success(
|
||||
title = ResStringProvider(R.string.continue_watching),
|
||||
items =
|
||||
latestNextUpService
|
||||
.buildCombined(
|
||||
resume,
|
||||
nextUp,
|
||||
).take(limit),
|
||||
items = combined.take(limit),
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = combined.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -693,6 +712,9 @@ class HomeSettingsService
|
|||
listOf(it)
|
||||
}
|
||||
},
|
||||
collectionType =
|
||||
library?.collectionType
|
||||
?: CollectionType.UNKNOWN,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -702,6 +724,7 @@ class HomeSettingsService
|
|||
genres,
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = genres.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -753,17 +776,16 @@ class HomeSettingsService
|
|||
title,
|
||||
studios,
|
||||
viewOptions = row.viewOptions,
|
||||
showViewMore = studios.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.RecentlyAdded -> {
|
||||
val library =
|
||||
libraries
|
||||
.firstOrNull { it.itemId == row.parentId }
|
||||
val title = getRecentlyAddedTitle(library)
|
||||
val library = libraries.firstOrNull { it.itemId == row.parentId }
|
||||
val title = getRecentlyAddedTitle(library?.name)
|
||||
val request =
|
||||
GetLatestMediaRequest(
|
||||
fields = SlimItemFields,
|
||||
fields = library.itemFields,
|
||||
imageTypeLimit = 1,
|
||||
parentId = row.parentId,
|
||||
groupItems = true,
|
||||
|
|
@ -781,18 +803,16 @@ class HomeSettingsService
|
|||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
latest
|
||||
}
|
||||
|
||||
is HomeRowConfig.RecentlyReleased -> {
|
||||
val name =
|
||||
libraries
|
||||
.firstOrNull { it.itemId == row.parentId }
|
||||
?.name
|
||||
val library = libraries.firstOrNull { it.itemId == row.parentId }
|
||||
val title =
|
||||
name?.let {
|
||||
library?.name?.let {
|
||||
ResArgStringProvider(R.string.recently_released_in, it)
|
||||
} ?: ResStringProvider(R.string.recently_released)
|
||||
val request =
|
||||
|
|
@ -811,7 +831,7 @@ class HomeSettingsService
|
|||
SortOrder.ASCENDING,
|
||||
SortOrder.DESCENDING,
|
||||
),
|
||||
fields = DefaultItemFields,
|
||||
fields = library.itemFields,
|
||||
recursive = true,
|
||||
maxPremiereDate = LocalDateTime.now(),
|
||||
isUnaired = false,
|
||||
|
|
@ -835,11 +855,13 @@ class HomeSettingsService
|
|||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.ByParent -> {
|
||||
val library = libraries.firstOrNull { it.itemId == row.parentId }
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
userId = userDto.id,
|
||||
|
|
@ -848,27 +870,44 @@ class HomeSettingsService
|
|||
sortBy =
|
||||
row.sort?.let {
|
||||
buildList {
|
||||
add(it.sort)
|
||||
if (it.sort != ItemSortBy.SORT_NAME) {
|
||||
if (it.sort == ItemSortBy.RANDOM) {
|
||||
add(ItemSortBy.SORT_NAME)
|
||||
add(ItemSortBy.RANDOM)
|
||||
} else {
|
||||
add(it.sort)
|
||||
if (it.sort != ItemSortBy.SORT_NAME) {
|
||||
add(ItemSortBy.SORT_NAME)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
sortOrder =
|
||||
row.sort?.let {
|
||||
buildList {
|
||||
add(it.direction)
|
||||
if (it.sort != ItemSortBy.SORT_NAME) {
|
||||
if (it.sort == ItemSortBy.RANDOM) {
|
||||
add(SortOrder.ASCENDING)
|
||||
add(it.direction)
|
||||
} else {
|
||||
add(it.direction)
|
||||
if (it.sort != ItemSortBy.SORT_NAME) {
|
||||
add(SortOrder.ASCENDING)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
limit = limit,
|
||||
fields = DefaultItemFields,
|
||||
fields = library.itemFields,
|
||||
)
|
||||
|
||||
// Not using getItemName because we want to throw the 404
|
||||
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) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
|
|
@ -888,6 +927,7 @@ class HomeSettingsService
|
|||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -925,6 +965,7 @@ class HomeSettingsService
|
|||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -932,61 +973,87 @@ class HomeSettingsService
|
|||
is HomeRowConfig.Favorite -> {
|
||||
val title =
|
||||
ResProviderStringProvider(
|
||||
R.string.favorite_items,
|
||||
ResStringProvider(favoriteOptions[row.kind]!!),
|
||||
R.string.favorite_items_title,
|
||||
ResStringProvider(formatTypeName(row.kind)),
|
||||
)
|
||||
if (row.kind == BaseItemKind.PERSON) {
|
||||
val request =
|
||||
GetPersonsRequest(
|
||||
userId = userDto.id,
|
||||
limit = limit,
|
||||
fields = DefaultItemFields,
|
||||
isFavorite = true,
|
||||
enableImages = true,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY),
|
||||
)
|
||||
GetPersonsHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
.map { BaseItem(it, true) }
|
||||
.let {
|
||||
Success(
|
||||
title,
|
||||
it,
|
||||
row.viewOptions,
|
||||
)
|
||||
val resultList =
|
||||
when (row.kind) {
|
||||
BaseItemKind.PERSON -> {
|
||||
val request =
|
||||
GetPersonsRequest(
|
||||
userId = userDto.id,
|
||||
limit = limit,
|
||||
fields = HomeItemFields,
|
||||
isFavorite = true,
|
||||
enableImages = true,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY),
|
||||
)
|
||||
|
||||
GetPersonsHandler.execute(api, request).toBaseItems()
|
||||
}
|
||||
|
||||
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 {
|
||||
GetArtistsHandler
|
||||
.execute(api, request)
|
||||
.toBaseItems(row.viewOptions.useSeries)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val fields =
|
||||
if (row.kind == BaseItemKind.BOX_SET) {
|
||||
HomeItemFieldsBoxSets
|
||||
} else {
|
||||
HomeItemFields
|
||||
}
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
userId = userDto.id,
|
||||
recursive = true,
|
||||
limit = limit,
|
||||
fields = fields,
|
||||
includeItemTypes = listOf(row.kind),
|
||||
isFavorite = true,
|
||||
)
|
||||
if (usePaging) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetItemsRequestHandler,
|
||||
scope,
|
||||
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||
).init()
|
||||
} else {
|
||||
GetItemsRequestHandler
|
||||
.execute(api, request)
|
||||
.toBaseItems(row.viewOptions.useSeries)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
userId = userDto.id,
|
||||
recursive = true,
|
||||
limit = limit,
|
||||
fields = DefaultItemFields,
|
||||
includeItemTypes = listOf(row.kind),
|
||||
isFavorite = true,
|
||||
)
|
||||
if (usePaging) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetItemsRequestHandler,
|
||||
scope,
|
||||
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||
).init()
|
||||
} else {
|
||||
GetItemsRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||
}.let {
|
||||
Success(
|
||||
title,
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
resultList.let {
|
||||
Success(
|
||||
title,
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -995,7 +1062,7 @@ class HomeSettingsService
|
|||
GetRecordingsRequest(
|
||||
userId = userDto.id,
|
||||
isInProgress = true,
|
||||
fields = DefaultItemFields,
|
||||
fields = HomeItemFields,
|
||||
limit = limit,
|
||||
enableImages = true,
|
||||
enableUserData = true,
|
||||
|
|
@ -1020,41 +1087,53 @@ class HomeSettingsService
|
|||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.TvPrograms -> {
|
||||
val request =
|
||||
GetRecommendedProgramsRequest(
|
||||
GetProgramsDto(
|
||||
userId = userDto.id,
|
||||
fields = DefaultItemFields,
|
||||
fields = ProgramItemFields,
|
||||
limit = limit,
|
||||
enableUserData = true,
|
||||
enableImages = true,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
|
||||
imageTypeLimit = 1,
|
||||
isAiring = true,
|
||||
minEndDate = DateTime.now().plusMinutes(1),
|
||||
)
|
||||
// paging not supported
|
||||
api.liveTvApi
|
||||
.getRecommendedPrograms(request)
|
||||
.content.items
|
||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||
.let {
|
||||
Success(
|
||||
ResStringProvider(R.string.live_tv),
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
)
|
||||
}
|
||||
if (usePaging) {
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetProgramsDtoHandler,
|
||||
scope,
|
||||
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||
).init()
|
||||
} else {
|
||||
api.liveTvApi
|
||||
.getPrograms(request)
|
||||
.content.items
|
||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||
}.let {
|
||||
Success(
|
||||
ResStringProvider(R.string.watch_live),
|
||||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.TvChannels -> {
|
||||
val request =
|
||||
GetLiveTvChannelsRequest(
|
||||
userId = userDto.id,
|
||||
fields = DefaultItemFields,
|
||||
fields = HomeItemFields,
|
||||
limit = limit,
|
||||
enableImages = true,
|
||||
)
|
||||
|
|
@ -1076,6 +1155,7 @@ class HomeSettingsService
|
|||
it,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = it.size >= limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1108,6 +1188,7 @@ class HomeSettingsService
|
|||
suggestions.items,
|
||||
row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = suggestions.items.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1146,10 +1227,11 @@ data class HomeRowConfigDisplay(
|
|||
* @see HomePageSettings
|
||||
*/
|
||||
data class HomePageResolvedSettings(
|
||||
val userId: UUID,
|
||||
val rows: List<HomeRowConfigDisplay>,
|
||||
) {
|
||||
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,
|
||||
) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion")
|
||||
|
||||
fun getRecentlyAddedTitle(library: Library?): StringProvider =
|
||||
if (library?.isRecordingFolder == true) {
|
||||
ResStringProvider(R.string.recently_recorded)
|
||||
} else {
|
||||
library?.name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
|
||||
?: ResStringProvider(R.string.recently_added)
|
||||
fun getRecentlyAddedTitle(name: String?): StringProvider =
|
||||
name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
|
||||
?: 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,
|
||||
maxWidth = maxWidth,
|
||||
maxHeight = maxHeight,
|
||||
width = width,
|
||||
height = height,
|
||||
width = width?.takeIf { it > 0 },
|
||||
height = height?.takeIf { it > 0 },
|
||||
quality = quality,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
fillWidth = fillWidth?.takeIf { it > 0 },
|
||||
fillHeight = fillHeight?.takeIf { it > 0 },
|
||||
tag = tag,
|
||||
format = format,
|
||||
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.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.util.LocalDateTimeSerializer
|
||||
import com.github.damontecres.wholphin.util.supportItemKinds
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
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.model.api.BaseItemKind
|
||||
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.request.GetNextUpRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||
|
|
@ -60,16 +60,8 @@ class LatestNextUpService
|
|||
userId = userId,
|
||||
fields = SlimItemFields,
|
||||
limit = limit,
|
||||
includeItemTypes =
|
||||
if (includeEpisodes) {
|
||||
supportItemKinds
|
||||
} else {
|
||||
supportItemKinds
|
||||
.toMutableSet()
|
||||
.apply {
|
||||
remove(BaseItemKind.EPISODE)
|
||||
}
|
||||
},
|
||||
mediaTypes = listOf(MediaType.VIDEO),
|
||||
excludeItemTypes = if (!includeEpisodes) listOf(BaseItemKind.EPISODE) else null,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
val items =
|
||||
|
|
@ -143,14 +135,14 @@ class LatestNextUpService
|
|||
resume: List<BaseItem>,
|
||||
nextUp: List<BaseItem>,
|
||||
): List<BaseItem> =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
val start = System.currentTimeMillis()
|
||||
val semaphore = Semaphore(3)
|
||||
val deferred =
|
||||
nextUp
|
||||
.filter { it.data.seriesId != null }
|
||||
.map { item ->
|
||||
async(Dispatchers.IO) {
|
||||
async(WholphinDispatchers.IO) {
|
||||
try {
|
||||
semaphore.withPermit {
|
||||
datePlayedService.getLastPlayed(item)
|
||||
|
|
|
|||
|
|
@ -16,12 +16,12 @@ import androidx.work.workDataOf
|
|||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
import dagger.hilt.android.scopes.ActivityScoped
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
|
|
@ -92,7 +92,7 @@ class LatestNextUpSchedulerService
|
|||
)
|
||||
|
||||
// Exposed for testing
|
||||
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO
|
||||
|
||||
init {
|
||||
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.model.AudioItem
|
||||
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.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.onMain
|
||||
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.PlaybackItemState
|
||||
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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
|
@ -35,6 +39,7 @@ import kotlinx.coroutines.flow.catch
|
|||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
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.model.api.BaseItemKind
|
||||
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.PlaystateCommand
|
||||
import org.jellyfin.sdk.model.api.PlaystateMessage
|
||||
|
|
@ -72,6 +78,7 @@ class MusicService
|
|||
private val playerFactory: PlayerFactory,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
) {
|
||||
private val _state = MutableStateFlow(MusicServiceState.EMPTY)
|
||||
val state: StateFlow<MusicServiceState> = _state
|
||||
|
|
@ -79,8 +86,15 @@ class MusicService
|
|||
private val audioFormats by lazy { listOf(*supportedAudioCodecs) }
|
||||
|
||||
val player: Player by lazy {
|
||||
val disableAudioOffload =
|
||||
runBlocking {
|
||||
userPreferencesService
|
||||
.getCurrent()
|
||||
.appPreferences.experimentalPreferences
|
||||
.get { disableAudioOffload } ?: false
|
||||
}
|
||||
playerFactory
|
||||
.createAudioPlayer()
|
||||
.createAudioPlayer(disableAudioOffload = disableAudioOffload)
|
||||
.also {
|
||||
it.addListener(MusicPlayerListener(it, _state))
|
||||
}
|
||||
|
|
@ -92,6 +106,12 @@ class MusicService
|
|||
private var activityTracker: TrackActivityPlaybackListener? = null
|
||||
private var websocketJob: Job? = null
|
||||
|
||||
private suspend fun preferAc3Surround() =
|
||||
userPreferencesService
|
||||
.getCurrent()
|
||||
.appPreferences.experimentalPreferences
|
||||
.enabled { preferAc3Surround }
|
||||
|
||||
/**
|
||||
* Start music playback
|
||||
*
|
||||
|
|
@ -162,7 +182,7 @@ class MusicService
|
|||
userId = serverRepository.currentUser?.id,
|
||||
itemId = itemId,
|
||||
limit = 200,
|
||||
fields = DefaultItemFields,
|
||||
fields = DetailItemFields,
|
||||
).content.items
|
||||
.map { BaseItem(it, false) }
|
||||
setQueue(items, false)
|
||||
|
|
@ -177,9 +197,9 @@ class MusicService
|
|||
items: BlockingList<BaseItem?>,
|
||||
startIndex: Int,
|
||||
shuffled: Boolean,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
) = withContext(WholphinDispatchers.IO) {
|
||||
Timber.d("setQueue: %s items, startIndex=%s, shuffled=%s", items.size, startIndex, shuffled)
|
||||
withContext(Dispatchers.Main) {
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
player.setMediaItems(emptyList())
|
||||
player.shuffleModeEnabled = shuffled
|
||||
}
|
||||
|
|
@ -195,11 +215,12 @@ class MusicService
|
|||
shuffled: Boolean,
|
||||
) {
|
||||
Timber.d("setQueue: %s items, shuffled=%s", items.size, shuffled)
|
||||
val preferAc3Surround = preferAc3Surround()
|
||||
val mediaItems =
|
||||
items
|
||||
.filter { it.type == BaseItemKind.AUDIO }
|
||||
.map(::convert)
|
||||
withContext(Dispatchers.Main) {
|
||||
.map { convert(it, preferAc3Surround) }
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
player.setMediaItems(mediaItems)
|
||||
player.shuffleModeEnabled = shuffled
|
||||
updateQueueSize()
|
||||
|
|
@ -215,8 +236,9 @@ class MusicService
|
|||
index: Int? = null,
|
||||
) {
|
||||
if (item.type == BaseItemKind.AUDIO) {
|
||||
val mediaItem = convert(item)
|
||||
withContext(Dispatchers.Main) {
|
||||
val preferAc3Surround = preferAc3Surround()
|
||||
val mediaItem = convert(item, preferAc3Surround)
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
if (index != null) {
|
||||
player.addMediaItem(index, mediaItem)
|
||||
} else {
|
||||
|
|
@ -228,6 +250,8 @@ class MusicService
|
|||
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?>,
|
||||
startIndex: Int,
|
||||
) = loading {
|
||||
val preferAc3Surround = preferAc3Surround()
|
||||
var remaining = startIndex
|
||||
list.indices
|
||||
.chunked(25)
|
||||
|
|
@ -250,7 +275,7 @@ class MusicService
|
|||
list
|
||||
.getBlocking(it)
|
||||
?.takeIf { it.type == BaseItemKind.AUDIO }
|
||||
?.let(::convert)
|
||||
?.let { convert(it, preferAc3Surround) }
|
||||
} else {
|
||||
Timber.v("Skipping $remaining")
|
||||
remaining--
|
||||
|
|
@ -266,12 +291,36 @@ class MusicService
|
|||
/**
|
||||
* 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 =
|
||||
api.universalAudioApi.getUniversalAudioStreamUrl(
|
||||
itemId = audio.id,
|
||||
container = audioFormats,
|
||||
)
|
||||
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(
|
||||
itemId = audio.id,
|
||||
container = audioFormats,
|
||||
)
|
||||
}
|
||||
Timber.i("url=%s", url)
|
||||
val imageUrl =
|
||||
audio.data.albumId?.let { albumId ->
|
||||
|
|
@ -293,7 +342,7 @@ class MusicService
|
|||
*/
|
||||
private suspend fun updateQueueSize() {
|
||||
// val ids =
|
||||
// withContext(Dispatchers.Default) {
|
||||
// withContext(WholphinDispatchers.Default) {
|
||||
// (0..<player.mediaItemCount).map { player.getMediaItemAt(it).mediaId.toUUID() }
|
||||
// }
|
||||
val timeline = onMain { player.currentTimeline }
|
||||
|
|
@ -304,7 +353,7 @@ class MusicService
|
|||
timeline.getWindow(it, window)
|
||||
window.mediaItem.mediaId.toUUID()
|
||||
}.toSet()
|
||||
withContext(Dispatchers.Main) {
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
queueVersion = it.queueVersion + 1,
|
||||
|
|
@ -321,7 +370,7 @@ class MusicService
|
|||
suspend fun moveQueue(
|
||||
index: Int,
|
||||
direction: MoveDirection,
|
||||
) = withContext(Dispatchers.Main) {
|
||||
) = withContext(WholphinDispatchers.Main) {
|
||||
player.moveMediaItem(index, if (direction == MoveDirection.UP) index - 1 else index + 1)
|
||||
updateQueueSize()
|
||||
}
|
||||
|
|
@ -332,7 +381,7 @@ class MusicService
|
|||
suspend fun moveQueue(
|
||||
index: Int,
|
||||
newIndex: Int,
|
||||
) = withContext(Dispatchers.Main) {
|
||||
) = withContext(WholphinDispatchers.Main) {
|
||||
player.moveMediaItem(index, newIndex)
|
||||
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
|
||||
*/
|
||||
suspend fun playNext(song: BaseItem) {
|
||||
val mediaItem = convert(song)
|
||||
val mediaItem = convert(song, preferAc3Surround())
|
||||
onMain {
|
||||
player.addMediaItem(state.value.currentIndex + 1, mediaItem)
|
||||
if (player.mediaItemCount == 1) {
|
||||
|
|
@ -385,7 +434,7 @@ class MusicService
|
|||
.subscribe<PlaystateMessage>()
|
||||
.onEach { message ->
|
||||
message.data?.let {
|
||||
withContext(Dispatchers.Main) {
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
when (it.command) {
|
||||
PlaystateCommand.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.NavPinType
|
||||
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.main.settings.Library
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
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.userViewsApi
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
|
|
@ -52,35 +52,37 @@ class NavDrawerService
|
|||
private val seerrServerRepository: SeerrServerRepository,
|
||||
private val musicService: MusicService,
|
||||
) {
|
||||
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
|
||||
private val _state = MutableStateFlow(NavDrawerItemState())
|
||||
val state: StateFlow<NavDrawerItemState> = _state
|
||||
|
||||
init {
|
||||
// Handle updating the nav drawer when the user changes
|
||||
serverRepository.currentUserFlow
|
||||
.combine(serverRepository.currentUserDtoFlow) { user, userDto ->
|
||||
Pair(user, userDto)
|
||||
}.onEach { (user, userDto) ->
|
||||
Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id)
|
||||
_state.update {
|
||||
it.copy(
|
||||
items = emptyList(),
|
||||
moreItems = emptyList(),
|
||||
)
|
||||
}
|
||||
combine(
|
||||
serverRepository.currentUserFlow,
|
||||
serverRepository.currentUserDtoFlow,
|
||||
seerrServerRepository.active,
|
||||
) { user, userDto, discoverActive ->
|
||||
Triple(user, userDto, discoverActive)
|
||||
}.collectLatestIn(coroutineScope) { (user, userDto, discoverActive) ->
|
||||
Timber.d(
|
||||
"User updated: user=%s, userDto=%s, discoverActive=%s",
|
||||
user?.id,
|
||||
userDto?.id,
|
||||
discoverActive,
|
||||
)
|
||||
try {
|
||||
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")
|
||||
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
|
||||
coroutineScope.launchDefault {
|
||||
|
|
@ -132,13 +134,22 @@ class NavDrawerService
|
|||
.content.items
|
||||
val recordingFolders =
|
||||
if (tvAccess) {
|
||||
api.liveTvApi
|
||||
.getRecordingFolders(userId = userId)
|
||||
.content.items
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
try {
|
||||
api.liveTvApi
|
||||
.getRecordingFolders(userId = userId)
|
||||
.content.items
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
} catch (ex: InvalidStatusException) {
|
||||
if (ex.status == 401 || ex.status == 403) {
|
||||
Timber.w("Got HTTP %s querying for recording folders", ex.status)
|
||||
emptySet()
|
||||
} else {
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setOf()
|
||||
emptySet()
|
||||
}
|
||||
val libraries =
|
||||
userViews
|
||||
|
|
@ -178,8 +189,13 @@ class NavDrawerService
|
|||
suspend fun updateNavDrawer(
|
||||
user: JellyfinUser,
|
||||
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 libraries =
|
||||
allLibraries
|
||||
|
|
@ -204,7 +220,7 @@ class NavDrawerService
|
|||
val allItems = builtins + libraries
|
||||
|
||||
val navDrawerPins =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
|
||||
}
|
||||
|
||||
|
|
@ -229,21 +245,18 @@ class NavDrawerService
|
|||
it.copy(
|
||||
items = items,
|
||||
moreItems = moreItems,
|
||||
allLibraries = allLibraries,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class NavDrawerItemState(
|
||||
val items: List<NavDrawerItem>,
|
||||
val moreItems: List<NavDrawerItem>,
|
||||
val discoverEnabled: Boolean,
|
||||
val nowPlayingEnabled: Boolean,
|
||||
val nowPlayingTitle: String?,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false, false, null)
|
||||
}
|
||||
}
|
||||
val items: List<NavDrawerItem> = emptyList(),
|
||||
val moreItems: List<NavDrawerItem> = emptyList(),
|
||||
val nowPlayingEnabled: Boolean = false,
|
||||
val nowPlayingTitle: String? = null,
|
||||
val allLibraries: List<Library> = emptyList(),
|
||||
)
|
||||
|
||||
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.VideoRendererEventListener
|
||||
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.MediaExtensionStatus
|
||||
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
|
||||
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.util.mpv.MpvPlayer
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.github.peerless2012.ass.media.AssHandler
|
||||
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
|
||||
import io.github.peerless2012.ass.media.kt.withAssMkvSupport
|
||||
import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
|
||||
import io.github.peerless2012.ass.media.type.AssRenderType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
|
|
@ -60,9 +62,10 @@ class PlayerFactory
|
|||
|
||||
suspend fun createVideoPlayer(
|
||||
backend: PlayerBackend,
|
||||
prefs: PlaybackPreferences,
|
||||
appPreferences: AppPreferences,
|
||||
): PlayerCreation {
|
||||
withContext(Dispatchers.Main) {
|
||||
val prefs = appPreferences.playbackPreferences
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
if (currentPlayer?.isReleased == false) {
|
||||
Timber.w("Player was not released before trying to create a new one!")
|
||||
currentPlayer?.release()
|
||||
|
|
@ -104,6 +107,7 @@ class PlayerFactory
|
|||
WholphinRenderersFactory(context, decodeAv1)
|
||||
.setEnableDecoderFallback(true)
|
||||
.setExtensionRendererMode(rendererMode)
|
||||
|
||||
val mediaSourceFactory =
|
||||
if (useLibAss) {
|
||||
val renderType =
|
||||
|
|
@ -128,7 +132,15 @@ class PlayerFactory
|
|||
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
|
||||
.Builder(context)
|
||||
|
|
@ -138,7 +150,7 @@ class PlayerFactory
|
|||
.build()
|
||||
.apply {
|
||||
assHandler?.init(this)
|
||||
withContext(Dispatchers.Main) {
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
setAudioAttributes(
|
||||
AudioAttributes
|
||||
.Builder()
|
||||
|
|
@ -158,7 +170,10 @@ class PlayerFactory
|
|||
return PlayerCreation(newPlayer, assHandler)
|
||||
}
|
||||
|
||||
fun createAudioPlayer(extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK): ExoPlayer {
|
||||
fun createAudioPlayer(
|
||||
disableAudioOffload: Boolean,
|
||||
extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK,
|
||||
): ExoPlayer {
|
||||
val rendererMode =
|
||||
when (extensions) {
|
||||
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||
|
|
@ -176,7 +191,7 @@ class PlayerFactory
|
|||
OkHttpDataSource.Factory(authOkHttpClient),
|
||||
extractorsFactory,
|
||||
)
|
||||
val trackSelector = createTrackSelector()
|
||||
val trackSelector = createTrackSelector(disableAudioOffload = disableAudioOffload)
|
||||
return ExoPlayer
|
||||
.Builder(context)
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
|
|
@ -187,9 +202,10 @@ class PlayerFactory
|
|||
it.setAudioAttributes(
|
||||
AudioAttributes
|
||||
.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.build(),
|
||||
false,
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -199,18 +215,33 @@ class PlayerFactory
|
|||
.setConstantBitrateSeekingEnabled(true)
|
||||
.setConstantBitrateSeekingAlwaysEnabled(true)
|
||||
|
||||
private fun createTrackSelector() =
|
||||
DefaultTrackSelector(context).apply {
|
||||
setParameters(
|
||||
buildUponParameters()
|
||||
.setAudioOffloadPreferences(
|
||||
AudioOffloadPreferences
|
||||
.Builder()
|
||||
.setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)
|
||||
.build(),
|
||||
),
|
||||
)
|
||||
}
|
||||
private fun createTrackSelector(
|
||||
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(
|
||||
buildUponParameters()
|
||||
.apply {
|
||||
tunneling?.let { setTunnelingEnabled(tunneling) }
|
||||
}.setAudioOffloadPreferences(
|
||||
AudioOffloadPreferences
|
||||
.Builder()
|
||||
.setAudioOffloadMode(offloadMode)
|
||||
.build(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createMediaSession(player: Player) =
|
||||
MediaSession
|
||||
.Builder(context, player)
|
||||
.build()
|
||||
}
|
||||
|
||||
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.PlaylistInfo
|
||||
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.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.gt
|
||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||
import com.github.damontecres.wholphin.ui.playback.playable
|
||||
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.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.TransformList
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
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.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
|
|
@ -66,7 +65,7 @@ class PlaylistCreator
|
|||
GetEpisodesRequest(
|
||||
seriesId = seriesId,
|
||||
seasonId = seasonId,
|
||||
fields = DefaultItemFields,
|
||||
fields = DetailItemFields,
|
||||
startItemId = episodeId,
|
||||
sortBy = if (shuffled) ItemSortBy.RANDOM else null,
|
||||
limit = Playlist.MAX_SIZE,
|
||||
|
|
@ -78,7 +77,7 @@ class PlaylistCreator
|
|||
.convertAndAddParts(false)
|
||||
val startIndex =
|
||||
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(
|
||||
userId = serverRepository.currentUser?.id,
|
||||
parentId = playlistId,
|
||||
fields = DefaultItemFields,
|
||||
fields = DetailItemFields,
|
||||
startIndex = startIndex,
|
||||
limit = Playlist.MAX_SIZE,
|
||||
sortBy = listOf(sortAndDirection.sort),
|
||||
|
|
@ -104,7 +103,7 @@ class PlaylistCreator
|
|||
),
|
||||
)
|
||||
val items = GetItemsRequestHandler.execute(api, request).content.items
|
||||
return Playlist(items.convertAndAddParts(), 0)
|
||||
return Playlist(items.convertAndAddParts())
|
||||
}
|
||||
|
||||
private suspend fun createFromCollection(
|
||||
|
|
@ -136,7 +135,7 @@ class PlaylistCreator
|
|||
excludeItemIds = listOf(item.id),
|
||||
sortBy = sortAndDirection?.let { listOf(sortAndDirection.sort) },
|
||||
sortOrder = sortAndDirection?.let { listOf(sortAndDirection.direction) },
|
||||
fields = DefaultItemFields,
|
||||
fields = DetailItemFields,
|
||||
startIndex = startIndex,
|
||||
limit = Playlist.MAX_SIZE,
|
||||
excludeLocationTypes = listOf(LocationType.VIRTUAL),
|
||||
|
|
@ -147,7 +146,7 @@ class PlaylistCreator
|
|||
.execute(api, request)
|
||||
.content.items
|
||||
.convertAndAddParts()
|
||||
return Playlist(items, 0)
|
||||
return Playlist(items)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -202,28 +201,72 @@ class PlaylistCreator
|
|||
BaseItemKind.SEASON -> {
|
||||
val seriesId = item.seriesId
|
||||
if (seriesId != null) {
|
||||
PlaylistCreationResult.Success(
|
||||
createFromEpisode(
|
||||
seriesId = seriesId,
|
||||
seasonId = item.id,
|
||||
episodeId = null,
|
||||
shuffled = shuffled,
|
||||
),
|
||||
)
|
||||
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(
|
||||
createFromEpisode(
|
||||
seriesId = seriesId,
|
||||
seasonId = item.id,
|
||||
episodeId = null,
|
||||
shuffled = shuffled,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
PlaylistCreationResult.Error(null, "Episode has no seriesId")
|
||||
}
|
||||
}
|
||||
|
||||
BaseItemKind.SERIES -> {
|
||||
PlaylistCreationResult.Success(
|
||||
createFromEpisode(
|
||||
seriesId = item.id,
|
||||
seasonId = null,
|
||||
episodeId = null,
|
||||
shuffled = shuffled,
|
||||
),
|
||||
)
|
||||
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(
|
||||
createFromEpisode(
|
||||
seriesId = item.id,
|
||||
seasonId = null,
|
||||
episodeId = nextUp.id,
|
||||
shuffled = shuffled,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
PlaylistCreationResult.Error(
|
||||
null,
|
||||
"Could not determine next up episode for series: " + item.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BaseItemKind.PLAYLIST -> {
|
||||
|
|
@ -263,7 +306,7 @@ class PlaylistCreator
|
|||
}.let(::addAll)
|
||||
}
|
||||
}
|
||||
PlaylistCreationResult.Success(Playlist(list, 0))
|
||||
PlaylistCreationResult.Success(Playlist(list))
|
||||
}
|
||||
|
||||
// Not support yet
|
||||
|
|
@ -299,26 +342,49 @@ class PlaylistCreator
|
|||
* Get the playlists on the server for a given media type
|
||||
*/
|
||||
suspend fun getServerPlaylists(
|
||||
query: String,
|
||||
mediaType: MediaType?,
|
||||
scope: CoroutineScope,
|
||||
): List<PlaylistInfo?> {
|
||||
): List<PlaylistInfo> {
|
||||
val userId =
|
||||
serverRepository.currentUser?.id ?: throw IllegalStateException("No user found")
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
searchTerm = query.takeIf { it.isNotBlank() },
|
||||
includeItemTypes = listOf(BaseItemKind.PLAYLIST),
|
||||
mediaTypes = mediaType?.let { listOf(mediaType) },
|
||||
recursive = true,
|
||||
limit = 25,
|
||||
sortBy = listOf(ItemSortBy.DATE_LAST_CONTENT_ADDED),
|
||||
sortOrder = listOf(SortOrder.DESCENDING),
|
||||
)
|
||||
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
|
||||
return TransformList(pager) {
|
||||
it?.let {
|
||||
PlaylistInfo(
|
||||
id = it.id,
|
||||
name = it.name ?: context.getString(R.string.unknown),
|
||||
count = it.data.childCount ?: 0,
|
||||
mediaType = it.data.mediaType,
|
||||
)
|
||||
val playlists = GetItemsRequestHandler.execute(api, request).content.items
|
||||
return playlists
|
||||
.mapNotNull { playlist ->
|
||||
try {
|
||||
val response = api.playlistsApi.getPlaylistUser(playlist.id, userId).content
|
||||
if (response.canEdit) {
|
||||
PlaylistInfo(
|
||||
id = playlist.id,
|
||||
name = playlist.name ?: context.getString(R.string.unknown),
|
||||
count = playlist.childCount ?: 0,
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import android.os.Looper
|
|||
import android.view.Display
|
||||
import com.github.damontecres.wholphin.MainActivity
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
|
@ -38,7 +38,7 @@ class RefreshRateService
|
|||
stream: MediaStream,
|
||||
switchRefreshRate: Boolean,
|
||||
switchResolution: Boolean,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
) = withContext(WholphinDispatchers.IO) {
|
||||
if (!switchRefreshRate && !switchResolution) {
|
||||
Timber.v("Not switching either refresh rate nor resolution")
|
||||
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
|
||||
|
||||
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.request.ImageRequest
|
||||
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.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
|
@ -57,6 +60,7 @@ class ScreensaverService
|
|||
val keepScreenOn = MutableStateFlow(false)
|
||||
|
||||
private var waitJob: Job? = null
|
||||
private var dimJob: Job? = null
|
||||
|
||||
init {
|
||||
userPreferencesService.flow
|
||||
|
|
@ -65,7 +69,14 @@ class ScreensaverService
|
|||
val enabled =
|
||||
prefs.appPreferences.interfacePreferences.screensaverPreference.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)
|
||||
}
|
||||
|
|
@ -79,7 +90,10 @@ class ScreensaverService
|
|||
// Timber.v("pulse")
|
||||
_state.update {
|
||||
if (!it.active) {
|
||||
it.copy(active = false)
|
||||
it.copy(
|
||||
active = false,
|
||||
dimActive = false,
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
|
|
@ -94,7 +108,30 @@ class ScreensaverService
|
|||
.appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds
|
||||
delay(startDelay)
|
||||
_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(
|
||||
enabledTemp = true,
|
||||
active = true,
|
||||
dimActive = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -121,9 +159,13 @@ class ScreensaverService
|
|||
it.copy(
|
||||
enabledTemp = 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) {
|
||||
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)
|
||||
if (screensaverEnabled) {
|
||||
if (screensaverEnabled || dimEnabled) {
|
||||
// Page is requesting to keep screen on, so we don't wait to show the screensaver
|
||||
_state.update {
|
||||
it.copy(active = false, paused = keep)
|
||||
it.copy(
|
||||
active = false,
|
||||
dimActive = false,
|
||||
paused = keep,
|
||||
)
|
||||
}
|
||||
if (!keep) {
|
||||
pulse()
|
||||
}
|
||||
} else {
|
||||
}
|
||||
if (!screensaverEnabled) {
|
||||
// If in-app screensaver is not enabled, send keep screen on to the OS
|
||||
keepScreenOnInternal(keep)
|
||||
}
|
||||
}
|
||||
|
|
@ -224,7 +273,7 @@ class ScreensaverService
|
|||
if (index > pager.lastIndex) index = 0
|
||||
}
|
||||
}
|
||||
}.flowOn(Dispatchers.Default).cancellable()
|
||||
}.flowOn(WholphinDispatchers.Default).cancellable()
|
||||
|
||||
private suspend fun createPager(): ApiRequestPager<GetItemsRequest> {
|
||||
val prefs =
|
||||
|
|
@ -245,6 +294,11 @@ class ScreensaverService
|
|||
)
|
||||
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(
|
||||
|
|
@ -252,6 +306,10 @@ data class ScreensaverState(
|
|||
val enabledTemp: Boolean,
|
||||
val active: Boolean,
|
||||
val paused: Boolean,
|
||||
val dimEnabled: Boolean = false,
|
||||
val dimActive: Boolean = false,
|
||||
) {
|
||||
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.MovieDetails
|
||||
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.ServiceProfile
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvResult
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||
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.toLocalDate
|
||||
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.ImageType
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -115,21 +124,7 @@ class SeerrService
|
|||
}
|
||||
|
||||
BaseItemKind.PERSON -> {
|
||||
api.personApi
|
||||
.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
|
||||
}
|
||||
personCredits(it)
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
|
@ -141,6 +136,50 @@ class SeerrService
|
|||
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? =
|
||||
if (active.first()) {
|
||||
item.data.providerIds
|
||||
|
|
@ -153,20 +192,15 @@ class SeerrService
|
|||
null
|
||||
}
|
||||
|
||||
private suspend fun createImageUrl(
|
||||
suspend fun createImageUrl(
|
||||
imageType: ImageType,
|
||||
path: String?,
|
||||
mediaInfo: MediaInfo?,
|
||||
backdropHeight: Int = 1080,
|
||||
backdropWidth: Int = 1920,
|
||||
): String? {
|
||||
if (mediaInfo != null) {
|
||||
val itemId =
|
||||
if (mediaInfo.jellyfinMediaId.isNotNullOrBlank()) {
|
||||
mediaInfo.jellyfinMediaId.toUUIDOrNull()
|
||||
} else if (mediaInfo.jellyfinMediaId4k.isNotNullOrBlank()) {
|
||||
mediaInfo.jellyfinMediaId4k.toUUIDOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val itemId = mediaInfo.jellyfinId
|
||||
if (itemId != null) {
|
||||
return imageUrlService.getItemImageUrl(
|
||||
itemId = itemId,
|
||||
|
|
@ -174,21 +208,143 @@ class SeerrService
|
|||
)
|
||||
}
|
||||
}
|
||||
val current = seerrServerRepository.current.firstOrNull() ?: return null
|
||||
val cacheImages = current.serverConfig.cacheImages == true
|
||||
val base =
|
||||
if (cacheImages) {
|
||||
current.server.url.removeSuffix("/") + "/imageproxy/tmdb"
|
||||
return path?.takeIf { it.isNotNullOrBlank() }?.let {
|
||||
val current = seerrServerRepository.current.firstOrNull() ?: return null
|
||||
val cacheImages = current.serverConfig.cacheImages == true
|
||||
val base =
|
||||
if (cacheImages) {
|
||||
current.server.url.removeSuffix("/") + "/imageproxy/tmdb"
|
||||
} else {
|
||||
"https://image.tmdb.org"
|
||||
}
|
||||
val prefix =
|
||||
when (imageType) {
|
||||
ImageType.PRIMARY -> "/t/p/w500"
|
||||
ImageType.BACKDROP -> "/t/p/w${backdropWidth}_and_h${backdropHeight}_multi_faces"
|
||||
else -> throw IllegalArgumentException("Image type not supported: $imageType")
|
||||
}
|
||||
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 {
|
||||
"https://image.tmdb.org"
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val prefix =
|
||||
when (imageType) {
|
||||
ImageType.PRIMARY -> "/t/p/w500"
|
||||
ImageType.BACKDROP -> "/t/p/w1920_and_h1080_multi_faces"
|
||||
else -> throw IllegalArgumentException("Image type not supported: $imageType")
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
return "${base}${prefix}$path"
|
||||
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 =
|
||||
|
|
@ -216,7 +372,8 @@ class SeerrService
|
|||
releaseDate = toLocalDate(movie.releaseDate),
|
||||
posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, 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 =
|
||||
|
|
@ -232,7 +389,8 @@ class SeerrService
|
|||
releaseDate = toLocalDate(movie.releaseDate),
|
||||
posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, 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 =
|
||||
|
|
@ -248,7 +406,8 @@ class SeerrService
|
|||
releaseDate = toLocalDate(tv.firstAirDate),
|
||||
posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, 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 =
|
||||
|
|
@ -264,7 +423,8 @@ class SeerrService
|
|||
releaseDate = toLocalDate(tv.firstAirDate),
|
||||
posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, 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 =
|
||||
|
|
@ -280,7 +440,8 @@ class SeerrService
|
|||
releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate),
|
||||
posterUrl = createImageUrl(ImageType.PRIMARY, search.posterPath, 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 =
|
||||
|
|
@ -293,7 +454,7 @@ class SeerrService
|
|||
availability =
|
||||
SeerrAvailability.from(credit.mediaInfo?.status)
|
||||
?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(credit.firstAirDate),
|
||||
releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate),
|
||||
posterUrl =
|
||||
createImageUrl(
|
||||
ImageType.PRIMARY,
|
||||
|
|
@ -306,7 +467,8 @@ class SeerrService
|
|||
credit.backdropPath,
|
||||
credit.mediaInfo,
|
||||
),
|
||||
jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
logoUrl = createImageUrl(ImageType.LOGO, null, credit.mediaInfo),
|
||||
jellyfinItemId = credit.mediaInfo?.jellyfinId,
|
||||
)
|
||||
|
||||
suspend fun createDiscoverItem(credit: CreditCrew): DiscoverItem =
|
||||
|
|
@ -319,7 +481,7 @@ class SeerrService
|
|||
availability =
|
||||
SeerrAvailability.from(credit.mediaInfo?.status)
|
||||
?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(credit.firstAirDate),
|
||||
releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate),
|
||||
posterUrl =
|
||||
createImageUrl(
|
||||
ImageType.PRIMARY,
|
||||
|
|
@ -332,6 +494,13 @@ class SeerrService
|
|||
credit.backdropPath,
|
||||
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.scopes.ActivityScoped
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
|
|
@ -94,6 +95,8 @@ class ServerEventListener
|
|||
.joinToString("\n")
|
||||
showToast(context, toast, Toast.LENGTH_LONG)
|
||||
}
|
||||
}.catch { ex ->
|
||||
Timber.e(ex, "Error in websocket subscription")
|
||||
}.launchIn(activity.lifecycleScope)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ package com.github.damontecres.wholphin.services
|
|||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
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.showToast
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.google.protobuf.MessageLiteOrBuilder
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.serialization.json.Json
|
||||
|
|
@ -22,10 +25,10 @@ import javax.inject.Inject
|
|||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Send media info to the server
|
||||
* Send reports to the server such as media info or app logs
|
||||
*/
|
||||
@Singleton
|
||||
class MediaReportService
|
||||
class ServerReportService
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
|
|
@ -45,45 +48,65 @@ class MediaReportService
|
|||
/**
|
||||
* Fetch the media info and send it to the server
|
||||
*/
|
||||
fun sendReportFor(itemId: UUID) {
|
||||
fun sendMediaReportFor(itemId: UUID) {
|
||||
ioScope.launchIO(ExceptionHandler(autoToast = true)) {
|
||||
val item = api.userLibraryApi.getItem(itemId = itemId).content
|
||||
sendReportFor(item)
|
||||
sendMediaReportFor(item)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the media report for the given item
|
||||
*/
|
||||
suspend fun sendReportFor(item: BaseItemDto) {
|
||||
suspend fun sendMediaReportFor(item: BaseItemDto) {
|
||||
val sources =
|
||||
item.mediaSources ?: api.userLibraryApi
|
||||
.getItem(itemId = item.id)
|
||||
.content.mediaSources
|
||||
val sourcesJson = json.encodeToString(sources)
|
||||
val playbackPrefs = userPreferencesService.getCurrent().appPreferences.playbackPreferences
|
||||
val appPreferences = userPreferencesService.getCurrent().appPreferences
|
||||
val serverVersion = serverRepository.currentServer?.serverVersion
|
||||
val deviceProfile =
|
||||
deviceProfileService.getOrCreateDeviceProfile(playbackPrefs, serverVersion)
|
||||
deviceProfileService.getOrCreateDeviceProfile(appPreferences, serverVersion)
|
||||
val deviceProfileJson = json.encodeToString(deviceProfile)
|
||||
val body =
|
||||
"""
|
||||
Send media info
|
||||
buildLogHeader("Send media info") + "\n\n" +
|
||||
"""
|
||||
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
|
||||
clientInfo=$clientInfo
|
||||
flavor=${BuildConfig.FLAVOR}
|
||||
deviceInfo=$deviceInfo
|
||||
manufacturer=${Build.MANUFACTURER}
|
||||
model=${Build.MODEL}
|
||||
apiLevel=${Build.VERSION.SDK_INT}
|
||||
|
||||
playbackPrefs=${playbackPrefs.toString().replace("\n", ", ").replace("\t", " ")}
|
||||
|
||||
deviceProfile=$deviceProfileJson
|
||||
|
||||
mediaSources=$sourcesJson
|
||||
""".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}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun MessageLiteOrBuilder.toStringOneLine(): String = toString().replace("\n", ", ").replace("\t", " ")
|
||||
|
|
@ -27,9 +27,6 @@ class SetupNavigationManager
|
|||
fun navigateTo(destination: SetupDestination) {
|
||||
backStack[0] = destination
|
||||
log()
|
||||
if (destination !is SetupDestination.AppContent) {
|
||||
navigationManager.reloadHome()
|
||||
}
|
||||
}
|
||||
|
||||
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.PlaybackLanguageChoice
|
||||
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.UserProfileSettings
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
|
|
@ -135,8 +137,8 @@ class StreamChoiceService
|
|||
val seriesLang =
|
||||
playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() }
|
||||
// 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()) {
|
||||
val sorted =
|
||||
candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels })
|
||||
|
|
@ -226,23 +228,17 @@ class StreamChoiceService
|
|||
): MediaStream? {
|
||||
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
|
||||
return null
|
||||
} else if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
|
||||
}
|
||||
val seriesLang =
|
||||
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
|
||||
val subtitleLanguage =
|
||||
seriesLang ?: getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
|
||||
if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
|
||||
// Client-side manual override: User selected "Only Forced" in player menu
|
||||
val seriesLang =
|
||||
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
|
||||
val subtitleLanguage =
|
||||
(seriesLang ?: userConfig?.subtitleLanguagePreference)
|
||||
?.takeIf { it.isNotNullOrBlank() }
|
||||
return findForcedTrack(candidates, subtitleLanguage, audioStreamLang)
|
||||
} else if (itemPlayback?.subtitleIndexEnabled == true) {
|
||||
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
|
||||
} else {
|
||||
val seriesLang =
|
||||
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
|
||||
val subtitleLanguage =
|
||||
(seriesLang ?: userConfig?.subtitleLanguagePreference)
|
||||
?.takeIf { it.isNotNullOrBlank() }
|
||||
|
||||
val subtitleMode =
|
||||
when {
|
||||
playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> {
|
||||
|
|
@ -258,9 +254,17 @@ class StreamChoiceService
|
|||
|
||||
else -> {
|
||||
// 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 =
|
||||
candidates
|
||||
.sortedWith(
|
||||
|
|
@ -277,11 +281,13 @@ class StreamChoiceService
|
|||
SubtitlePlaybackMode.ALWAYS -> {
|
||||
if (subtitleLanguage.isNotNullOrBlank()) {
|
||||
candidates.firstOrNull {
|
||||
it.language.equals(subtitleLanguage, true) ||
|
||||
it.language.isUnknown
|
||||
// Prefer non-forced first
|
||||
!it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage)
|
||||
} ?: candidates.firstOrNull {
|
||||
it.language.equalsLangOrUnknown(subtitleLanguage)
|
||||
}
|
||||
} else {
|
||||
candidates.firstOrNull()
|
||||
candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -371,3 +377,38 @@ private val String?.isUnknown: Boolean
|
|||
this.equals("undetermined", true) ||
|
||||
this.equals("mul", 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 com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import com.mayakapps.kache.InMemoryKache
|
||||
import com.mayakapps.kache.ObjectKache
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
|
|
@ -43,7 +43,7 @@ class SuggestionsCache
|
|||
|
||||
private val memoryCache =
|
||||
InMemoryKache<String, CachedSuggestions>(maxSize = 8) {
|
||||
creationScope = CoroutineScope(Dispatchers.IO)
|
||||
creationScope = CoroutineScope(WholphinDispatchers.IO)
|
||||
}
|
||||
|
||||
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.AppPreferences
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
|
|
@ -81,7 +81,7 @@ class SuggestionsWorker
|
|||
.takeIf { it > 0 }
|
||||
?: AppPreference.HomePageItems.defaultValue.toInt()
|
||||
|
||||
val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions")
|
||||
val context = WholphinDispatchers.IO.limitedParallelism(2, "fetchSuggestions")
|
||||
if (requestedParentId != null && requestedItemKind != null) {
|
||||
Timber.d(
|
||||
"Fetching on-demand suggestions for parent=%s kind=%s",
|
||||
|
|
@ -128,7 +128,7 @@ class SuggestionsWorker
|
|||
supervisorScope {
|
||||
supportedViews
|
||||
.map { view ->
|
||||
async(Dispatchers.IO) {
|
||||
async(WholphinDispatchers.IO) {
|
||||
runCatching {
|
||||
Timber.v("Fetching suggestions for parent=%s kind=%s", view.id, view.itemKind)
|
||||
fetchAndCacheSuggestions(
|
||||
|
|
@ -293,7 +293,7 @@ class SuggestionsWorker
|
|||
limit = freshLimit,
|
||||
)
|
||||
}
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(WholphinDispatchers.Default) {
|
||||
val contextual = contextualDeferred.await()
|
||||
val random = randomDeferred.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.IoCoroutineScope
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import com.github.damontecres.wholphin.util.profile.Codec
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -102,7 +102,7 @@ class ThemeSongPlayer
|
|||
Codec.Audio.FLAC,
|
||||
),
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
player.apply {
|
||||
stop()
|
||||
volume = volumeLevel
|
||||
|
|
@ -127,7 +127,7 @@ class ThemeSongPlayer
|
|||
scope.launch {
|
||||
mutex.withLock {
|
||||
state.value.job?.cancelAndJoin()
|
||||
withContext(Dispatchers.Main) {
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
Timber.v("Stopping theme song")
|
||||
player.stop()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class TrailerService
|
|||
// TODO would be nice to clean up the trailer name
|
||||
// ?.replace(item.name ?: "", "")
|
||||
// ?.removePrefix(" - ")
|
||||
?: context.getString(R.string.trailer)
|
||||
?: context.resources.getQuantityString(R.plurals.trailers, 1)
|
||||
val subtitle =
|
||||
when (url.toUri().host) {
|
||||
"youtube.com", "www.youtube.com" -> "YouTube"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
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.showToast
|
||||
import com.github.damontecres.wholphin.util.Version
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
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 PERMISSION_REQUEST_CODE = 123456
|
||||
|
||||
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
|
||||
|
||||
val ACTIVE = BuildConfig.UPDATING_ENABLED
|
||||
|
|
@ -131,7 +130,7 @@ class UpdateChecker
|
|||
suspend fun getRelease(version: Version): Release? {
|
||||
val url =
|
||||
"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 =
|
||||
Request
|
||||
.Builder()
|
||||
|
|
@ -146,7 +145,7 @@ class UpdateChecker
|
|||
* Get the latest released version
|
||||
*/
|
||||
suspend fun getLatestRelease(updateUrl: String): Release? =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
|
|
@ -158,8 +157,8 @@ class UpdateChecker
|
|||
|
||||
private fun getRelease(request: Request): Release? {
|
||||
return okHttpClient.newCall(request).execute().use {
|
||||
if (it.isSuccessful && it.body != null) {
|
||||
val result = Json.parseToJsonElement(it.body!!.string())
|
||||
if (it.isSuccessful) {
|
||||
val result = Json.parseToJsonElement(it.body.string())
|
||||
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
|
||||
val version = Version.tryFromString(name)
|
||||
val publishedAt =
|
||||
|
|
@ -195,11 +194,12 @@ class UpdateChecker
|
|||
/**
|
||||
* Download and install an update
|
||||
*/
|
||||
@SuppressLint("RequestInstallPackagesPolicy")
|
||||
suspend fun installRelease(
|
||||
release: Release,
|
||||
callback: DownloadCallback,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
cleanup()
|
||||
val request =
|
||||
Request
|
||||
|
|
@ -208,9 +208,9 @@ class UpdateChecker
|
|||
.get()
|
||||
.build()
|
||||
okHttpClient.newCall(request).execute().use {
|
||||
if (it.isSuccessful && it.body != null) {
|
||||
if (it.isSuccessful) {
|
||||
Timber.v("Request successful for ${release.downloadUrl}")
|
||||
withContext(Dispatchers.Main) {
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
callback.contentLength(it.body.contentLength())
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
|
|
@ -230,7 +230,7 @@ class UpdateChecker
|
|||
contentValues,
|
||||
)
|
||||
if (uri != null) {
|
||||
it.body!!.byteStream().use { input ->
|
||||
it.body.byteStream().use { input ->
|
||||
resolver.openOutputStream(uri).use { output ->
|
||||
copyTo(input, output!!, callback = callback)
|
||||
}
|
||||
|
|
@ -290,7 +290,7 @@ class UpdateChecker
|
|||
downloadDir.mkdirs()
|
||||
val targetFile = File(downloadDir, APK_NAME)
|
||||
targetFile.outputStream().use { output ->
|
||||
response.body!!.byteStream().use { input ->
|
||||
response.body.byteStream().use { input ->
|
||||
copyTo(input, output, callback = callback)
|
||||
}
|
||||
}
|
||||
|
|
@ -374,7 +374,7 @@ data class Release(
|
|||
Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"),
|
||||
"#$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\\*\\*.*"), "")
|
||||
}
|
||||
|
||||
|
|
@ -387,29 +387,30 @@ interface DownloadCallback {
|
|||
suspend fun copyTo(
|
||||
input: InputStream,
|
||||
out: OutputStream,
|
||||
bufferSize: Int = 16 * 1024,
|
||||
bufferSize: Int = 64 * 1024,
|
||||
callback: DownloadCallback,
|
||||
): Long {
|
||||
var bytesCopied: Long = 0
|
||||
val buffer = ByteArray(bufferSize)
|
||||
var bytes = input.read(buffer)
|
||||
while (bytes >= 0) {
|
||||
out.write(buffer, 0, bytes)
|
||||
bytesCopied += bytes
|
||||
withContext(Dispatchers.Main) {
|
||||
callback.bytesDownloaded(bytesCopied)
|
||||
): Long =
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
var bytesCopied: Long = 0
|
||||
val buffer = ByteArray(bufferSize)
|
||||
var bytes = input.read(buffer)
|
||||
while (bytes >= 0) {
|
||||
out.write(buffer, 0, bytes)
|
||||
bytesCopied += bytes
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
callback.bytesDownloaded(bytesCopied)
|
||||
}
|
||||
bytes = input.read(buffer)
|
||||
}
|
||||
bytes = input.read(buffer)
|
||||
return@withContext bytesCopied
|
||||
}
|
||||
return bytesCopied
|
||||
}
|
||||
|
||||
fun getDownloadUrl(
|
||||
assets: JsonArray,
|
||||
debug: Boolean,
|
||||
supportedAbis: List<String> = Build.SUPPORTED_ABIS.toList(),
|
||||
supportedABIs: List<String> = Build.SUPPORTED_ABIS.toList(),
|
||||
): 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 preferredNames =
|
||||
buildList {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import androidx.datastore.core.DataStore
|
|||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -19,13 +20,10 @@ class UserPreferencesService
|
|||
private val serverRepository: ServerRepository,
|
||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||
) {
|
||||
val flow = preferencesDataStore.data.map { UserPreferences(it) }
|
||||
|
||||
suspend fun getCurrent(): UserPreferences =
|
||||
serverRepository.currentUserDto!!.configuration.let { userConfig ->
|
||||
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||
UserPreferences(
|
||||
appPrefs,
|
||||
)
|
||||
val flow: Flow<UserPreferences> =
|
||||
preferencesDataStore.data.combine(serverRepository.currentUserFlow) { appPrefs, user ->
|
||||
UserPreferences(appPrefs, user?.appPreferences)
|
||||
}
|
||||
|
||||
suspend fun getCurrent(): UserPreferences = flow.first()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
import dagger.hilt.android.scopes.ActivityScoped
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -56,7 +56,7 @@ class UserSwitchListener
|
|||
user.uiLanguage?.let { LocaleListCompat.forLanguageTags(it) }
|
||||
?: LocaleListCompat.getEmptyLocaleList()
|
||||
Timber.i("Switching locale to %s", localeList)
|
||||
withContext(Dispatchers.Main) {
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
AppCompatDelegate.setApplicationLocales(localeList)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,13 @@
|
|||
package com.github.damontecres.wholphin.services.hilt
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.work.WorkManager
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.R
|
||||
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.util.CoroutineContextApiClientFactory
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -20,9 +15,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.android.androidDevice
|
||||
|
|
@ -49,21 +42,21 @@ annotation class AuthOkHttpClient
|
|||
annotation class StandardOkHttpClient
|
||||
|
||||
/**
|
||||
* A [CoroutineScope] with [Dispatchers.IO]
|
||||
* A [CoroutineScope] with [WholphinDispatchers.IO]
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class IoCoroutineScope
|
||||
|
||||
/**
|
||||
* A [CoroutineScope] with [Dispatchers.Default]
|
||||
* A [CoroutineScope] with [WholphinDispatchers.Default]
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class DefaultCoroutineScope
|
||||
|
||||
/**
|
||||
* [Dispatchers.IO]
|
||||
* [WholphinDispatchers.IO]
|
||||
*
|
||||
* @see IoCoroutineScope
|
||||
*/
|
||||
|
|
@ -72,7 +65,7 @@ annotation class DefaultCoroutineScope
|
|||
annotation class IoDispatcher
|
||||
|
||||
/**
|
||||
* [Dispatchers.Default]
|
||||
* [WholphinDispatchers.Default]
|
||||
*
|
||||
* @see DefaultCoroutineScope
|
||||
*/
|
||||
|
|
@ -160,55 +153,10 @@ object AppModule {
|
|||
@Singleton
|
||||
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
|
||||
@Singleton
|
||||
@IoDispatcher
|
||||
fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
||||
fun ioDispatcher(): CoroutineDispatcher = WholphinDispatchers.IO
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
|
|
@ -220,7 +168,7 @@ object AppModule {
|
|||
@Provides
|
||||
@Singleton
|
||||
@DefaultDispatcher
|
||||
fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
|
||||
fun defaultDispatcher(): CoroutineDispatcher = WholphinDispatchers.Default
|
||||
|
||||
@Provides
|
||||
@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.PlaybackEffectDao
|
||||
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.ServerPreferencesDao
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
|
|
@ -43,7 +44,7 @@ object DatabaseModule {
|
|||
).addMigrations(Migrations.Migrate2to3)
|
||||
// .setQueryCallback({ sqlQuery, args ->
|
||||
// Timber.v("sqlQuery=$sqlQuery, args=$args")
|
||||
// }, Dispatchers.IO.asExecutor())
|
||||
// }, WholphinDispatchers.IO.asExecutor())
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
|
|
@ -74,6 +75,10 @@ object DatabaseModule {
|
|||
@Singleton
|
||||
fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
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.AppPreferences
|
||||
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.ui.SlimItemFields
|
||||
import dagger.assisted.Assisted
|
||||
|
|
@ -226,27 +227,27 @@ class TvProviderWorker
|
|||
|
||||
setIntent(
|
||||
Intent(context, MainActivity::class.java)
|
||||
.putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
|
||||
.putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
|
||||
.putExtra(IntentService.INTENT_ITEM_ID, item.id.toString())
|
||||
.putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName)
|
||||
.apply {
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
putExtra(
|
||||
MainActivity.INTENT_SERIES_ID,
|
||||
IntentService.INTENT_SERIES_ID,
|
||||
dto.seriesId?.toString(),
|
||||
)
|
||||
putExtra(
|
||||
MainActivity.INTENT_SEASON_ID,
|
||||
IntentService.INTENT_SEASON_ID,
|
||||
dto.seasonId?.toString(),
|
||||
)
|
||||
dto.parentIndexNumber?.let {
|
||||
putExtra(
|
||||
MainActivity.INTENT_SEASON_NUMBER,
|
||||
IntentService.INTENT_SEASON_NUMBER,
|
||||
it,
|
||||
)
|
||||
}
|
||||
dto.indexNumber?.let {
|
||||
putExtra(
|
||||
MainActivity.INTENT_EPISODE_NUMBER,
|
||||
IntentService.INTENT_EPISODE_NUMBER,
|
||||
it,
|
||||
)
|
||||
}
|
||||
|
|
@ -264,6 +265,7 @@ class TvProviderWorker
|
|||
.getLatestMedia(
|
||||
GetLatestMediaRequest(
|
||||
fields = SlimItemFields,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
|
||||
imageTypeLimit = 1,
|
||||
parentId = null,
|
||||
groupItems = true,
|
||||
|
|
@ -362,27 +364,27 @@ class TvProviderWorker
|
|||
|
||||
setIntent(
|
||||
Intent(context, MainActivity::class.java)
|
||||
.putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
|
||||
.putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
|
||||
.putExtra(IntentService.INTENT_ITEM_ID, item.id.toString())
|
||||
.putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName)
|
||||
.apply {
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
putExtra(
|
||||
MainActivity.INTENT_SERIES_ID,
|
||||
IntentService.INTENT_SERIES_ID,
|
||||
dto.seriesId?.toString(),
|
||||
)
|
||||
putExtra(
|
||||
MainActivity.INTENT_SEASON_ID,
|
||||
IntentService.INTENT_SEASON_ID,
|
||||
dto.seasonId?.toString(),
|
||||
)
|
||||
dto.parentIndexNumber?.let {
|
||||
putExtra(
|
||||
MainActivity.INTENT_SEASON_NUMBER,
|
||||
IntentService.INTENT_SEASON_NUMBER,
|
||||
it,
|
||||
)
|
||||
}
|
||||
dto.indexNumber?.let {
|
||||
putExtra(
|
||||
MainActivity.INTENT_EPISODE_NUMBER,
|
||||
IntentService.INTENT_EPISODE_NUMBER,
|
||||
it,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import coil3.disk.DiskCache
|
|||
import coil3.disk.directory
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.network.CacheStrategy
|
||||
import coil3.network.ConnectivityChecker
|
||||
import coil3.network.NetworkRequest
|
||||
import coil3.network.NetworkResponse
|
||||
import coil3.network.cachecontrol.CacheControlCacheStrategy
|
||||
|
|
@ -95,6 +96,8 @@ fun CoilConfig(
|
|||
OkHttpNetworkFetcherFactory(
|
||||
cacheStrategy = { WholphinCacheStrategy(CacheControlCacheStrategy()) },
|
||||
callFactory = { client },
|
||||
// Disable connectivity checker which may be unreliable for VPNs and/or some versions of Android
|
||||
connectivityChecker = { ConnectivityChecker { true } },
|
||||
),
|
||||
)
|
||||
}.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.RowColumnSaver
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
|
|
@ -242,7 +244,7 @@ val BaseItemDto.timeRemaining: Duration?
|
|||
get() =
|
||||
userData?.playbackPositionTicks?.let {
|
||||
if (it > 0) {
|
||||
runTimeTicks?.minus(it)?.ticks
|
||||
runTimeTicks?.minus(it)?.ticks?.takeIf { it > Duration.ZERO }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
@ -359,34 +361,34 @@ suspend fun showToast(
|
|||
context: Context,
|
||||
text: CharSequence,
|
||||
duration: Int,
|
||||
) = withContext(Dispatchers.Main) {
|
||||
) = withContext(WholphinDispatchers.Main) {
|
||||
Toast.makeText(context, text, duration).show()
|
||||
}
|
||||
|
||||
suspend fun showToast(
|
||||
context: Context,
|
||||
text: CharSequence,
|
||||
) = withContext(Dispatchers.Main) {
|
||||
) = withContext(WholphinDispatchers.Main) {
|
||||
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(
|
||||
context: CoroutineContext = ExceptionHandler(),
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
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(
|
||||
context: CoroutineContext = ExceptionHandler(),
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
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).
|
||||
|
|
@ -410,7 +412,7 @@ fun logTab(
|
|||
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) =
|
||||
this.ifElse(
|
||||
|
|
@ -425,6 +427,9 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
|
|||
useSeriesForPrimary: Boolean,
|
||||
) = 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
|
||||
*/
|
||||
|
|
@ -446,3 +451,15 @@ fun <T> Flow<T>.collectLatestIn(
|
|||
* 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) }
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.text.format.DateFormat
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
|
|
@ -12,19 +15,36 @@ import org.jellyfin.sdk.model.api.MediaSegmentType
|
|||
import timber.log.Timber
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeParseException
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Date
|
||||
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 {
|
||||
if (timeFormatter.locale != Locale.getDefault()) {
|
||||
timeFormatter = timeFormatter.withLocale(Locale.getDefault())
|
||||
}
|
||||
return timeFormatter
|
||||
fun formatTime(
|
||||
context: Context,
|
||||
time: LocalTime,
|
||||
): String {
|
||||
val instant =
|
||||
time
|
||||
.atDate(LocalDate.now())
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
return DateFormat.getTimeFormat(context).format(Date.from(instant))
|
||||
}
|
||||
|
||||
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?
|
||||
get() =
|
||||
if (parentIndexNumber != null && indexNumber != null && indexNumberEnd != null) {
|
||||
"S$parentIndexNumber E$indexNumber-E$indexNumberEnd"
|
||||
} else if (parentIndexNumber != null && indexNumber != null) {
|
||||
"S$parentIndexNumber E$indexNumber"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
formatSeasonEpisode(
|
||||
parentIndexNumber,
|
||||
indexNumber,
|
||||
indexNumberEnd,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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?
|
||||
get() =
|
||||
if (parentIndexNumber != null && indexNumber != null) {
|
||||
val season = parentIndexNumber?.toString()?.padStart(2, '0')
|
||||
val episode = indexNumber?.toString()?.padStart(2, '0')
|
||||
val endEpisode = indexNumberEnd?.toString()?.padStart(2, '0')
|
||||
if (endEpisode != null) {
|
||||
"S${season}E$episode-E$endEpisode"
|
||||
} else {
|
||||
"S${season}E$episode"
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
formatSeasonEpisode(
|
||||
parentIndexNumber,
|
||||
indexNumber,
|
||||
indexNumberEnd,
|
||||
padded = true,
|
||||
)
|
||||
|
||||
val BaseItemDto.seriesProductionYears: String?
|
||||
get() =
|
||||
|
|
@ -200,44 +258,64 @@ fun listToDotString(
|
|||
}
|
||||
}
|
||||
|
||||
@get:StringRes
|
||||
val BaseItemKind.titleStringRes: Int
|
||||
get() = formatTypeName(this)
|
||||
|
||||
@StringRes
|
||||
fun formatTypeName(type: BaseItemKind): Int =
|
||||
when (type) {
|
||||
BaseItemKind.MOVIE -> R.string.movies
|
||||
BaseItemKind.SERIES -> R.string.tv_shows
|
||||
BaseItemKind.MOVIE -> R.string.movies_title
|
||||
BaseItemKind.SERIES -> R.string.tv_shows_title
|
||||
BaseItemKind.EPISODE -> R.string.episodes
|
||||
BaseItemKind.VIDEO -> R.string.videos
|
||||
BaseItemKind.PLAYLIST -> R.string.playlists
|
||||
BaseItemKind.PERSON -> R.string.people
|
||||
BaseItemKind.PERSON -> R.string.people_title
|
||||
BaseItemKind.BOX_SET -> R.string.collections
|
||||
BaseItemKind.AUDIO -> TODO()
|
||||
BaseItemKind.AUDIO -> R.string.songs
|
||||
BaseItemKind.CHANNEL -> R.string.channels
|
||||
BaseItemKind.GENRE -> R.string.genres
|
||||
BaseItemKind.LIVE_TV_CHANNEL -> R.string.channels
|
||||
BaseItemKind.MUSIC_ALBUM -> TODO()
|
||||
BaseItemKind.MUSIC_ARTIST -> TODO()
|
||||
BaseItemKind.MUSIC_GENRE -> TODO()
|
||||
BaseItemKind.MUSIC_VIDEO -> TODO()
|
||||
BaseItemKind.MUSIC_ALBUM -> R.string.albums
|
||||
BaseItemKind.MUSIC_ARTIST -> R.string.artists
|
||||
BaseItemKind.MUSIC_GENRE -> R.string.genres
|
||||
BaseItemKind.MUSIC_VIDEO -> R.string.music_videos
|
||||
BaseItemKind.PHOTO -> R.string.photos
|
||||
BaseItemKind.PHOTO_ALBUM -> TODO()
|
||||
BaseItemKind.PROGRAM -> TODO()
|
||||
BaseItemKind.RECORDING -> TODO()
|
||||
BaseItemKind.PHOTO_ALBUM -> R.string.photo_albums
|
||||
BaseItemKind.PROGRAM -> R.string.programs
|
||||
BaseItemKind.RECORDING -> R.string.recordings
|
||||
BaseItemKind.SEASON -> R.string.tv_seasons
|
||||
BaseItemKind.STUDIO -> R.string.studios
|
||||
BaseItemKind.TRAILER -> R.string.trailers
|
||||
BaseItemKind.TRAILER -> R.string.trailers_title
|
||||
BaseItemKind.TV_CHANNEL -> R.string.channels
|
||||
BaseItemKind.TV_PROGRAM -> TODO()
|
||||
BaseItemKind.USER_ROOT_FOLDER -> TODO()
|
||||
BaseItemKind.USER_VIEW -> TODO()
|
||||
BaseItemKind.YEAR -> TODO()
|
||||
BaseItemKind.AGGREGATE_FOLDER -> TODO()
|
||||
BaseItemKind.AUDIO_BOOK -> TODO()
|
||||
BaseItemKind.BASE_PLUGIN_FOLDER -> TODO()
|
||||
BaseItemKind.BOOK -> TODO()
|
||||
BaseItemKind.CHANNEL_FOLDER_ITEM -> TODO()
|
||||
BaseItemKind.COLLECTION_FOLDER -> TODO()
|
||||
BaseItemKind.FOLDER -> TODO()
|
||||
BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> TODO()
|
||||
BaseItemKind.LIVE_TV_PROGRAM -> TODO()
|
||||
BaseItemKind.PLAYLISTS_FOLDER -> TODO()
|
||||
BaseItemKind.TV_PROGRAM -> R.string.programs
|
||||
BaseItemKind.USER_ROOT_FOLDER -> R.string.folders_title
|
||||
BaseItemKind.USER_VIEW -> R.string.user_views
|
||||
BaseItemKind.YEAR -> R.string.year
|
||||
BaseItemKind.AGGREGATE_FOLDER -> R.string.folders_title
|
||||
BaseItemKind.AUDIO_BOOK -> R.string.audio_books
|
||||
BaseItemKind.BASE_PLUGIN_FOLDER -> R.string.folders_title
|
||||
BaseItemKind.BOOK -> R.string.books
|
||||
BaseItemKind.CHANNEL_FOLDER_ITEM -> R.string.folders_title
|
||||
BaseItemKind.COLLECTION_FOLDER -> R.string.library
|
||||
BaseItemKind.FOLDER -> R.string.folders_title
|
||||
BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> R.string.playlists
|
||||
BaseItemKind.LIVE_TV_PROGRAM -> R.string.programs
|
||||
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 Green = Color(74, 222, 128)
|
||||
val Yellow = Color(234, 179, 8)
|
||||
val Red = Color(204, 38, 38)
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.SEASON_USER_DATA,
|
||||
ItemFields.CHILD_COUNT,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.TRICKPLAY,
|
||||
ItemFields.SORT_NAME,
|
||||
ItemFields.CHAPTERS,
|
||||
ItemFields.MEDIA_SOURCES,
|
||||
ItemFields.MEDIA_SOURCE_COUNT,
|
||||
ItemFields.PARENT_ID,
|
||||
ItemFields.CAN_DELETE,
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
@ -66,27 +64,37 @@ val DefaultItemFields =
|
|||
*/
|
||||
val SlimItemFields =
|
||||
listOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.SEASON_USER_DATA,
|
||||
ItemFields.CHILD_COUNT,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.SORT_NAME,
|
||||
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,
|
||||
)
|
||||
|
||||
val PhotoItemFields =
|
||||
DefaultItemFields +
|
||||
DetailItemFields +
|
||||
listOf(
|
||||
ItemFields.WIDTH,
|
||||
ItemFields.HEIGHT,
|
||||
)
|
||||
|
||||
val ProgramItemFields = DetailItemFields + listOf(ItemFields.CHANNEL_INFO)
|
||||
|
||||
object Cards {
|
||||
const val HEIGHT_2X3_DP = 172
|
||||
val height2x3 = HEIGHT_2X3_DP.dp
|
||||
const val HEIGHT_EPISODE = 128
|
||||
const val HEIGHT_LIVE_TV = 96
|
||||
val heightEpisode = HEIGHT_EPISODE.dp
|
||||
val playedPercentHeight = 6.dp
|
||||
val serverUserCircle = height2x3 * .75f
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
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
|
||||
|
|
@ -74,7 +73,7 @@ fun BannerCard(
|
|||
val imageUrlService = LocalImageUrlService.current
|
||||
val density = LocalDensity.current
|
||||
val fillHeight =
|
||||
remember(cardHeight) {
|
||||
remember(cardHeight, density) {
|
||||
if (cardHeight.isSpecified) {
|
||||
with(density) {
|
||||
cardHeight.roundToPx()
|
||||
|
|
@ -219,13 +218,11 @@ fun BannerCardWithTitle(
|
|||
useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true,
|
||||
) {
|
||||
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 aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||
val width = cardHeight * aspectRationToUse
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier.width(width),
|
||||
) {
|
||||
BannerCard(
|
||||
|
|
@ -245,13 +242,7 @@ fun BannerCardWithTitle(
|
|||
imageContentScale = imageContentScale,
|
||||
useSeriesForPrimary = useSeriesForPrimary,
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = spaceBelow)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
SlidingCardText(focused) {
|
||||
Text(
|
||||
text = title ?: "",
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.Modifier
|
||||
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.times
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
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.AspectRatios
|
||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.formatDuration
|
||||
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]
|
||||
*/
|
||||
@Composable
|
||||
fun ChapterCard(
|
||||
name: String?,
|
||||
position: Duration,
|
||||
imageUrl: String?,
|
||||
chapter: Chapter,
|
||||
onClick: () -> Unit,
|
||||
aspectRatio: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
cardHeight: Dp = 120.dp,
|
||||
onLongClick: (() -> Unit)? = 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(
|
||||
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
|
||||
modifier = modifier.height(cardHeight),
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = interactionSource,
|
||||
|
|
@ -48,22 +71,36 @@ fun ChapterCard(
|
|||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onSuccess = {
|
||||
width =
|
||||
with(density) {
|
||||
it.painter.intrinsicSize.width
|
||||
.toDp()
|
||||
}
|
||||
},
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(width)
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.background(AppColors.TransparentBlack50),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(4.dp)) {
|
||||
name?.let {
|
||||
chapter.name?.let {
|
||||
Text(
|
||||
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 = 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
|
||||
fun ChapterRow(
|
||||
chapters: List<Chapter>,
|
||||
aspectRatio: Float,
|
||||
onClick: (Chapter) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onLongClick: ((Chapter) -> Unit)? = null,
|
||||
|
|
@ -47,11 +46,8 @@ fun ChapterRow(
|
|||
) {
|
||||
itemsIndexed(chapters) { index, item ->
|
||||
ChapterCard(
|
||||
name = item.name,
|
||||
position = item.position,
|
||||
imageUrl = item.imageUrl,
|
||||
chapter = item,
|
||||
onClick = { onClick(item) },
|
||||
aspectRatio = aspectRatio,
|
||||
modifier = Modifier,
|
||||
onLongClick = onLongClick?.let { { it(item) } },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -59,8 +59,6 @@ fun DiscoverItemCard(
|
|||
width: Dp = Cards.height2x3 * AspectRatios.TALL,
|
||||
) {
|
||||
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
|
||||
|
|
@ -78,7 +76,7 @@ fun DiscoverItemCard(
|
|||
}
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier.size(width, Dp.Unspecified),
|
||||
) {
|
||||
Card(
|
||||
|
|
@ -130,7 +128,16 @@ fun DiscoverItemCard(
|
|||
AvailableIndicator(Modifier.align(Alignment.TopEnd))
|
||||
}
|
||||
|
||||
else -> {}
|
||||
SeerrAvailability.BLOCKLISTED -> {
|
||||
// TODO handle block listed
|
||||
// BlocklistedIndicator(Modifier.align(Alignment.TopEnd))
|
||||
}
|
||||
|
||||
SeerrAvailability.UNKNOWN,
|
||||
SeerrAvailability.DELETED,
|
||||
null,
|
||||
-> {
|
||||
}
|
||||
}
|
||||
if (showOverlay) {
|
||||
val color =
|
||||
|
|
@ -171,13 +178,7 @@ fun DiscoverItemCard(
|
|||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = spaceBelow)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
SlidingCardText(focused) {
|
||||
Text(
|
||||
text = item?.title ?: "",
|
||||
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
|
||||
@Composable
|
||||
private fun Preview() {
|
||||
|
|
@ -292,6 +318,7 @@ private fun Preview() {
|
|||
PendingIndicator()
|
||||
AvailableIndicator()
|
||||
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,
|
||||
) {
|
||||
ItemRow(
|
||||
title = stringResource(R.string.extras),
|
||||
title = stringResource(R.string.extras_title),
|
||||
items = extras,
|
||||
onClickItem = onClickItem,
|
||||
onLongClickItem = onLongClickItem,
|
||||
|
|
@ -38,7 +38,7 @@ fun ExtrasRow(
|
|||
unplayedItemCount = -1,
|
||||
playedPercentage = item?.playedPercentage ?: 0.0,
|
||||
numberOfVersions = -1,
|
||||
aspectRatio = AspectRatios.FOUR_THREE, // TODO
|
||||
aspectRatio = item?.aspectRatio ?: AspectRatios.WIDE,
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import coil3.request.crossfade
|
|||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
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.theme.WholphinTheme
|
||||
import java.util.UUID
|
||||
|
|
|
|||
|
|
@ -52,8 +52,6 @@ fun GridCard(
|
|||
) {
|
||||
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
|
||||
|
|
@ -71,7 +69,7 @@ fun GridCard(
|
|||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Card(
|
||||
|
|
@ -107,13 +105,7 @@ fun GridCard(
|
|||
)
|
||||
}
|
||||
AnimatedVisibility(showTitle) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = spaceBelow)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
SlidingCardText(focused) {
|
||||
Text(
|
||||
text = item?.title ?: "",
|
||||
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.FontAwesome
|
||||
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.logCoilError
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
|
@ -67,14 +68,16 @@ fun ItemCardImage(
|
|||
) {
|
||||
val imageUrlService = LocalImageUrlService.current
|
||||
val imageUrl =
|
||||
remember(item) {
|
||||
item?.let {
|
||||
remember(item, imageType, fillWidth, fillHeight) {
|
||||
if (item != null && (fillWidth.gt(0) || fillHeight.gt(0))) {
|
||||
imageUrlService.getItemImageUrl(
|
||||
item,
|
||||
imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
ItemCardImage(
|
||||
|
|
@ -262,7 +265,7 @@ fun ItemCardImageOverlay(
|
|||
}
|
||||
}
|
||||
|
||||
watchedPercent?.let { percent ->
|
||||
if (watchedPercent != null && watchedPercent > 0 && watchedPercent < 100) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
@ -271,7 +274,7 @@ fun ItemCardImageOverlay(
|
|||
MaterialTheme.colorScheme.tertiary,
|
||||
).clip(RectangleShape)
|
||||
.height(Cards.playedPercentHeight)
|
||||
.fillMaxWidth((percent / 100.0).toFloat()),
|
||||
.fillMaxWidth((watchedPercent / 100.0).toFloat()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package com.github.damontecres.wholphin.ui.cards
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Border
|
||||
|
|
@ -99,10 +95,6 @@ fun PersonCard(
|
|||
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(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing
|
||||
modifier = modifier,
|
||||
|
|
@ -170,21 +162,7 @@ fun PersonCard(
|
|||
.clip(CircleShape),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
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(),
|
||||
) {
|
||||
SlidingCardText(focused) {
|
||||
Text(
|
||||
text = name ?: "",
|
||||
maxLines = 1,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ fun PersonRow(
|
|||
people: List<Person>,
|
||||
onClick: (Person) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@StringRes title: Int = R.string.people,
|
||||
@StringRes title: Int = R.string.people_title,
|
||||
onLongClick: ((Int, Person) -> Unit)? = null,
|
||||
) {
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
|
|
@ -84,7 +84,7 @@ fun DiscoverPersonRow(
|
|||
people: List<DiscoverItem>,
|
||||
onClick: (DiscoverItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@StringRes title: Int = R.string.people,
|
||||
@StringRes title: Int = R.string.people_title,
|
||||
onLongClick: ((Int, DiscoverItem) -> Unit)? = null,
|
||||
) {
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isSpecified
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.CardDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
|
|
@ -38,51 +37,21 @@ import kotlinx.coroutines.delay
|
|||
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
|
||||
fun SeasonCard(
|
||||
item: BaseItem?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
imageHeight: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
imageHeight: Dp = Dp.Unspecified,
|
||||
imageWidth: Dp = Dp.Unspecified,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
showImageOverlay: Boolean = false,
|
||||
aspectRatio: Float = item?.aspectRatio ?: AspectRatios.TALL,
|
||||
) {
|
||||
val imageUrlService = LocalImageUrlService.current
|
||||
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
|
||||
}
|
||||
}
|
||||
val imageUrl = rememberImageUrl(item, imageHeight, imageWidth)
|
||||
SeasonCard(
|
||||
title = item?.title,
|
||||
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
|
||||
*/
|
||||
|
|
@ -129,8 +139,11 @@ fun SeasonCard(
|
|||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
// 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 spaceBelow = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow")
|
||||
val spaceBetween =
|
||||
animateDpAsState(
|
||||
if (focused) spaceBetweenFocused else spaceBetweenUnfocused,
|
||||
label = "spaceBetween",
|
||||
)
|
||||
|
||||
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
||||
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||
|
|
@ -179,14 +192,12 @@ fun SeasonCard(
|
|||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
// Optimization: move animation reads to layout/draw phase
|
||||
.offset {
|
||||
IntOffset(0, (spaceBetween.value - 4.dp).roundToPx())
|
||||
}.layout { measurable, constraints ->
|
||||
val paddingPx = spaceBelow.value.roundToPx()
|
||||
.layout { measurable, constraints ->
|
||||
val topPaddingPx = (spaceBetweenUnfocused - spaceBetween.value).roundToPx()
|
||||
val bottomPaddingPx = spaceBetween.value.roundToPx()
|
||||
val placeable = measurable.measure(constraints)
|
||||
layout(placeable.width, placeable.height + paddingPx) {
|
||||
placeable.placeRelative(0, 0)
|
||||
layout(placeable.width, placeable.height + bottomPaddingPx + topPaddingPx) {
|
||||
placeable.placeRelative(0, topPaddingPx)
|
||||
}
|
||||
}.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 com.github.damontecres.wholphin.ui.AspectRatios
|
||||
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.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.setup.rememberIdColor
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import java.util.UUID
|
||||
|
|
|
|||
|
|
@ -53,8 +53,6 @@ fun ViewMoreCard(
|
|||
showTitle: Boolean = true,
|
||||
) {
|
||||
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
|
||||
|
|
@ -79,7 +77,7 @@ fun ViewMoreCard(
|
|||
size.height.takeIf { it.isSpecified } ?: (size.height * (1f / aspectRatio.ratio))
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Card(
|
||||
|
|
@ -95,24 +93,20 @@ fun ViewMoreCard(
|
|||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
||||
),
|
||||
) {
|
||||
Box {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowForward,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
contentDescription = "View more",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier.fillMaxSize(.66f),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showTitle) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier =
|
||||
Modifier
|
||||
.width(width)
|
||||
.padding(bottom = spaceBelow),
|
||||
) {
|
||||
SlidingCardText(focused) {
|
||||
Text(
|
||||
text = stringResource(R.string.view_more),
|
||||
maxLines = 1,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ fun WatchedIconBackground(): Color =
|
|||
AppThemeColors.GREEN,
|
||||
AppThemeColors.ORANGE,
|
||||
AppThemeColors.BOLD_BLUE,
|
||||
AppThemeColors.RED,
|
||||
AppThemeColors.BROWN,
|
||||
-> MaterialTheme.colorScheme.border.copy(alpha = 1f)
|
||||
|
||||
AppThemeColors.OLED_BLACK -> MaterialTheme.colorScheme.secondaryContainer
|
||||
|
|
@ -60,6 +62,8 @@ fun WatchedIconColor(): Color =
|
|||
AppThemeColors.ORANGE,
|
||||
AppThemeColors.BOLD_BLUE,
|
||||
AppThemeColors.OLED_BLACK,
|
||||
AppThemeColors.RED,
|
||||
AppThemeColors.BROWN,
|
||||
-> Color.White // MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
|
|
@ -15,6 +18,9 @@ import androidx.compose.ui.focus.focusRequester
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
|
|
@ -21,6 +22,7 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
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.detail.CardGrid
|
||||
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.playback.scale
|
||||
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
|
||||
|
|
@ -112,7 +115,7 @@ fun CollectionFolderGrid(
|
|||
columns = viewOptions.columns,
|
||||
spacing = viewOptions.spacing.dp,
|
||||
bringIntoViewSpec =
|
||||
remember(viewOptions) {
|
||||
remember(viewOptions, density) {
|
||||
val spacingPx = with(density) { viewOptions.spacing.dp.toPx() }
|
||||
if (viewOptions.showDetails) {
|
||||
ScrollToTopBringIntoViewSpec(spacingPx)
|
||||
|
|
@ -204,7 +207,7 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
|
|||
}
|
||||
|
||||
CollectionType.TVSHOWS -> {
|
||||
listOf(BaseItemKind.SERIES)
|
||||
listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE)
|
||||
}
|
||||
|
||||
CollectionType.HOMEVIDEOS -> {
|
||||
|
|
@ -219,6 +222,10 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
|
|||
)
|
||||
}
|
||||
|
||||
CollectionType.MUSICVIDEOS -> {
|
||||
listOf(BaseItemKind.MUSIC_VIDEO)
|
||||
}
|
||||
|
||||
CollectionType.BOXSETS -> {
|
||||
listOf(BaseItemKind.BOX_SET)
|
||||
}
|
||||
|
|
@ -227,7 +234,33 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
|
|||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -256,6 +289,7 @@ data class GridClickActions(
|
|||
|
||||
@Composable
|
||||
fun CollectionFolderHeader(
|
||||
listIsNotEmpty: Boolean,
|
||||
showHeader: Boolean,
|
||||
showTitle: Boolean,
|
||||
playEnabled: Boolean,
|
||||
|
|
@ -266,10 +300,14 @@ fun CollectionFolderHeader(
|
|||
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
|
||||
onClickShowViewOptions: () -> Unit,
|
||||
onClickPlayAll: (Boolean) -> Unit,
|
||||
onClickRandom: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
currentFilter: GetItemsFilter = GetItemsFilter(),
|
||||
filterOptions: List<ItemFilterBy<*>> = listOf(),
|
||||
onFilterChange: (GetItemsFilter) -> Unit = {},
|
||||
onShowFilterDropdown: ((Boolean) -> Unit)? = null,
|
||||
filterButtonFocusRequester: FocusRequester = remember { FocusRequester() },
|
||||
randomButtonFocusRequester: FocusRequester = remember { FocusRequester() },
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader,
|
||||
|
|
@ -277,6 +315,7 @@ fun CollectionFolderHeader(
|
|||
exit = shrinkVertically(),
|
||||
modifier = modifier,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
@ -286,61 +325,84 @@ fun CollectionFolderHeader(
|
|||
}
|
||||
val endPadding =
|
||||
16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp, end = endPadding)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
if (sortOptions.isNotEmpty()) {
|
||||
SortByButton(
|
||||
sortOptions = sortOptions,
|
||||
current = sortAndDirection,
|
||||
onSortChange = onSortChange,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
if (filterOptions.isNotEmpty()) {
|
||||
FilterByButton(
|
||||
filterOptions = filterOptions,
|
||||
current = currentFilter,
|
||||
onFilterChange = onFilterChange,
|
||||
getPossibleValues = getPossibleFilterValues,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
ExpandableFaButton(
|
||||
title = R.string.view_options,
|
||||
iconStringRes = R.string.fa_sliders,
|
||||
onClick = onClickShowViewOptions,
|
||||
modifier = Modifier,
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
if (sortOptions.isNotEmpty()) {
|
||||
SortByButton(
|
||||
sortOptions = sortOptions,
|
||||
current = sortAndDirection,
|
||||
onSortChange = onSortChange,
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
if (filterOptions.isNotEmpty()) {
|
||||
FilterByButton(
|
||||
filterOptions = filterOptions,
|
||||
current = currentFilter,
|
||||
onFilterChange = onFilterChange,
|
||||
getPossibleValues = getPossibleFilterValues,
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(filterButtonFocusRequester)
|
||||
.ifElse(
|
||||
sortOptions.isEmpty(),
|
||||
Modifier.focusRequester(focusRequester),
|
||||
),
|
||||
onShow = onShowFilterDropdown,
|
||||
)
|
||||
}
|
||||
ExpandableFaButton(
|
||||
title = R.string.view_options,
|
||||
iconStringRes = R.string.fa_sliders,
|
||||
onClick = onClickShowViewOptions,
|
||||
modifier =
|
||||
Modifier.ifElse(
|
||||
sortOptions.isEmpty() && filterOptions.isEmpty(),
|
||||
Modifier.focusRequester(focusRequester),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (playEnabled) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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(
|
||||
title = R.string.play,
|
||||
resume = Duration.ZERO,
|
||||
icon = Icons.Default.PlayArrow,
|
||||
onClick = { onClickPlayAll.invoke(false) },
|
||||
enabled = listIsNotEmpty,
|
||||
)
|
||||
ExpandableFaButton(
|
||||
title = R.string.shuffle,
|
||||
iconStringRes = R.string.fa_shuffle,
|
||||
onClick = { onClickPlayAll.invoke(true) },
|
||||
enabled = listIsNotEmpty,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.detail.AlphabetButtons
|
||||
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.launchIO
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
|
|
@ -97,13 +100,15 @@ fun CollectionFolderList(
|
|||
val positionFocusRequester = remember { FocusRequester() }
|
||||
val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME
|
||||
|
||||
var showHeader by remember { mutableStateOf(true) }
|
||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
fun jumpTo(newPosition: Int) {
|
||||
scope.launch {
|
||||
position = newPosition
|
||||
listState.animateScrollToItem(newPosition)
|
||||
positionFocusRequester.tryRequestFocus()
|
||||
if (newPosition >= 0) {
|
||||
position = newPosition
|
||||
listState.animateScrollToItem(newPosition)
|
||||
positionFocusRequester.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -357,7 +362,7 @@ fun ListItemGeneric(
|
|||
?.runTimeTicks
|
||||
?.ticks
|
||||
?.roundMinutes
|
||||
?.toString() ?: "",
|
||||
?.let { LocalResources.current.formatDuration(it) } ?: "",
|
||||
)
|
||||
},
|
||||
scale = scale,
|
||||
|
|
@ -419,7 +424,7 @@ fun ListItemMovie(
|
|||
?.runTimeTicks
|
||||
?.ticks
|
||||
?.roundMinutes
|
||||
?.toString() ?: "",
|
||||
?.let { LocalResources.current.formatDuration(it) } ?: "",
|
||||
)
|
||||
if (item?.played == true) {
|
||||
WatchedIcon(padding = 0.dp)
|
||||
|
|
@ -546,7 +551,7 @@ fun ListItemAlbum(
|
|||
.runTimeTicks
|
||||
?.ticks
|
||||
?.roundMinutes
|
||||
?.toString() ?: "",
|
||||
?.let { LocalResources.current.formatDuration(it) } ?: "",
|
||||
)
|
||||
}
|
||||
} 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