Compare commits
447 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddc193086d | ||
|
|
d9e99cbb0b | ||
|
|
3d3e2e5333 | ||
|
|
c160feea59 | ||
|
|
1a8d821622 | ||
|
|
bf4fa8ea5d | ||
|
|
df561f88f5 | ||
|
|
20f62243aa | ||
|
|
027d1b1010 | ||
|
|
d565617e0b | ||
|
|
208dc4feb9 | ||
|
|
3c4613c63f | ||
|
|
b6605958b1 | ||
|
|
57730b75e2 | ||
|
|
74fe6658cc | ||
|
|
a8d45cecd7 | ||
|
|
6994234bd0 | ||
|
|
1ccfa7eae6 | ||
|
|
7749e401ad | ||
|
|
b2b60bf78a | ||
|
|
e2ede5b7be | ||
|
|
985e4ef9a7 | ||
|
|
7d5895134b | ||
|
|
dc5bf6b47d | ||
|
|
147fc4f772 | ||
|
|
ecead3fad8 | ||
|
|
834bebb03f | ||
|
|
a001846da7 | ||
|
|
5c7bf941ea | ||
|
|
53b92184a4 | ||
|
|
1e369480fd | ||
|
|
e00f6d3f40 | ||
|
|
5e85a876bd | ||
|
|
5249209f3a | ||
|
|
276e138d7a | ||
|
|
00cc8f14ec | ||
|
|
f14a8fa6b8 | ||
|
|
6191044057 | ||
|
|
d0365ced7e | ||
|
|
9c8143368f | ||
|
|
266d5738a6 | ||
|
|
e34442a432 | ||
|
|
12e4c96304 | ||
|
|
857ad84771 | ||
|
|
7dfd3b9179 | ||
|
|
63de8d16d2 | ||
|
|
b771bb4c27 | ||
|
|
2b01ac9ffc | ||
|
|
b857fb4f1c | ||
|
|
19707eb9c8 | ||
|
|
c60d210e87 | ||
|
|
cb046c26c5 | ||
|
|
3d97b5abb0 | ||
|
|
da8c7c2837 | ||
|
|
d9c11d86ad | ||
|
|
b9172845d8 | ||
|
|
3d3fa76771 | ||
|
|
521183d427 | ||
|
|
103f8babd5 | ||
|
|
4ec0bcba72 | ||
|
|
096a4362c6 | ||
|
|
3ad59c8a4d | ||
|
|
a07a2d5fbe | ||
|
|
ad1f12b289 | ||
|
|
06d98f64a8 | ||
|
|
9ad1ff0cde | ||
|
|
a3d7a69d5f | ||
|
|
d9b0835687 | ||
|
|
342b0724ed | ||
|
|
9045a65721 | ||
|
|
d08ea45cb2 | ||
|
|
c1c6cd1aea | ||
|
|
d62315ffd9 | ||
|
|
2bb5dde570 | ||
|
|
aca0c454cd | ||
|
|
f0b86e44d8 | ||
|
|
747b821ec7 | ||
|
|
f0178663d5 | ||
|
|
c7b7f2f718 | ||
|
|
965da62f28 | ||
|
|
59f16ca4cf | ||
|
|
8bf9bd1c57 | ||
|
|
fb259dfe46 | ||
|
|
6ab2c24e53 | ||
|
|
9d3c2d2db8 | ||
|
|
8cf9cc30ba | ||
|
|
d5b48a95f0 | ||
|
|
13a8990b81 | ||
|
|
fef0e76733 | ||
|
|
a6729a7ec7 | ||
|
|
4777840bb0 | ||
|
|
2bcd2b9482 | ||
|
|
0c8fe45b06 | ||
|
|
225b78d67a | ||
|
|
6020ed2e74 | ||
|
|
3439038f8c | ||
|
|
3264d22fb2 | ||
|
|
4d4b4515bb | ||
|
|
236314050d | ||
|
|
41cd7425df | ||
|
|
b0178cca8c | ||
|
|
675f285a72 | ||
|
|
5523309223 | ||
|
|
45a29f34bc | ||
|
|
ed06b5c255 | ||
|
|
6629864913 | ||
|
|
f7a89233bc | ||
|
|
95cf3751ae | ||
|
|
aeebbaf5c1 | ||
|
|
36009c5ab4 | ||
|
|
ab1d976458 | ||
|
|
6c91002725 | ||
|
|
0c966e1936 | ||
|
|
2057ec95c3 | ||
|
|
5968bb0965 | ||
|
|
f46e5fc807 | ||
|
|
a990538ffa | ||
|
|
1276956229 | ||
|
|
476e73d988 | ||
|
|
36e1a3c2c1 | ||
|
|
bdf0d1ca2c | ||
|
|
8e9d5ef24c | ||
|
|
04acbb9e30 | ||
|
|
9945c870bc | ||
|
|
2a6d236b1d | ||
|
|
986ad0c17e | ||
|
|
067bb13287 | ||
|
|
fdec4d8407 | ||
|
|
51df9e4c17 | ||
|
|
33df66172d | ||
|
|
8f74d49e87 | ||
|
|
2775f4d8de | ||
|
|
70f4b2c967 | ||
|
|
1d334f94cf | ||
|
|
2b31fd3f97 | ||
|
|
6486713146 | ||
|
|
ab6ae6cd16 | ||
|
|
23250c6888 | ||
|
|
0619cbdbcf | ||
|
|
f53dba8491 | ||
|
|
5657dfb22a | ||
|
|
4ce0fe81b2 | ||
|
|
1270123872 | ||
|
|
78b1de23c7 | ||
|
|
83e1fb9a59 | ||
|
|
f1e8e989ed | ||
|
|
ee9520d45b | ||
|
|
4eddfaf8f3 | ||
|
|
be51a9893b | ||
|
|
46a7b4a345 | ||
|
|
104b707b9b | ||
|
|
51fdd79999 | ||
|
|
00e1eb6e08 | ||
|
|
9d66eeef0d | ||
|
|
5acbae0c16 | ||
|
|
9a9d513de6 | ||
|
|
4345ea301d | ||
|
|
d6ca138cdf | ||
|
|
019c62756d | ||
|
|
52465bb3c8 | ||
|
|
c8355ae445 | ||
|
|
6429fb3ff5 | ||
|
|
099dd125bd | ||
|
|
1301cb9f52 | ||
|
|
2b48e39195 | ||
|
|
e336763964 | ||
|
|
2d4cd0ebd8 | ||
|
|
4a4efb2467 | ||
|
|
0cf79cfd82 | ||
|
|
196d3bc6bd | ||
|
|
b5a6e733c2 | ||
|
|
d5a9744123 | ||
|
|
cb1a84862d | ||
|
|
2fa53e7650 | ||
|
|
a73e04c216 | ||
|
|
0938a8a19a | ||
|
|
539c8f5937 | ||
|
|
4db9973d65 | ||
|
|
8845429e9f | ||
|
|
9aec957ca5 | ||
|
|
0408b5d842 | ||
|
|
d0c3c51ef2 | ||
|
|
b6459bde73 | ||
|
|
a71329a2f6 | ||
|
|
05f8b79cd4 | ||
|
|
8ac5606f06 | ||
|
|
12cf7d8e66 | ||
|
|
ecf556e416 | ||
|
|
bf8db9c600 | ||
|
|
e994238de1 | ||
|
|
47551a89e8 | ||
|
|
0ea00447b8 | ||
|
|
a0515002c1 | ||
|
|
4738b0c0ad | ||
|
|
d21b35830c | ||
|
|
62c1e816b6 | ||
|
|
7c769c2f41 | ||
|
|
7e386babde | ||
|
|
bc5441b157 | ||
|
|
f5acc050af | ||
|
|
6628b00851 | ||
|
|
7815f80da6 | ||
|
|
de7d8e6b8b | ||
|
|
b4c0ca341e | ||
|
|
b12b204c80 | ||
|
|
eb36d61217 | ||
|
|
c568ec6a7a | ||
|
|
3048fd602f | ||
|
|
37a586a91e | ||
|
|
962d56e95e | ||
|
|
111d527e27 | ||
|
|
48a46ca146 | ||
|
|
c7500ec862 | ||
|
|
e85a84f14d | ||
|
|
aee3e2c8ba | ||
|
|
bdb4acfdc7 | ||
|
|
f9c7cfb0e5 | ||
|
|
7959bd5c19 | ||
|
|
6960c4ec7b | ||
|
|
dad7d64f6c | ||
|
|
43b3d121bc | ||
|
|
31be62946b | ||
|
|
905ca61d38 | ||
|
|
db497c0ad9 | ||
|
|
6bdde2c2bc | ||
|
|
e497f3998c | ||
|
|
409301f526 | ||
|
|
a48c696a34 | ||
|
|
ee2ff96327 | ||
|
|
52e92b995a | ||
|
|
553e218d83 | ||
|
|
964a100f0b | ||
|
|
8c3f54899e | ||
|
|
84b0291a65 | ||
|
|
fd958874c0 | ||
|
|
4fa4cd48f5 | ||
|
|
d3c35725e8 | ||
|
|
04b6be7177 | ||
|
|
023b4a8e86 | ||
|
|
b0c642a0c2 | ||
|
|
ee9763973f | ||
|
|
a20b893066 | ||
|
|
0a372fe3f0 | ||
|
|
4317917759 | ||
|
|
e63314402e | ||
|
|
b7a8bbf0af | ||
|
|
b8c542ede1 | ||
|
|
a80ab9c7c1 | ||
|
|
b24f135efb | ||
|
|
532b6b92d2 | ||
|
|
c715dbd149 | ||
|
|
8bb4a61d6b | ||
|
|
8fc5d9c23d | ||
|
|
07ea277269 | ||
|
|
ed972667eb | ||
|
|
f98be9daee | ||
|
|
d808943695 | ||
|
|
245e977057 | ||
|
|
3f4a2c5863 | ||
|
|
fc52c6ba72 | ||
|
|
a31945b09c | ||
|
|
3a9881c52e | ||
|
|
5a8108a129 | ||
|
|
b2c993b058 | ||
|
|
26ec721c0f | ||
|
|
9830c12fac | ||
|
|
523067bde9 | ||
|
|
ab3710f0ed | ||
|
|
fe6fcbe539 | ||
|
|
4f781b9610 | ||
|
|
1d411d1e3f | ||
|
|
79bdc601c4 | ||
|
|
b3463a8975 | ||
|
|
4a0dba229c | ||
|
|
bf61dba6ed | ||
|
|
c06b7e7c67 | ||
|
|
0c79dabecc | ||
|
|
9cd4c433ea | ||
|
|
f8d7345ff3 | ||
|
|
21e40cae24 | ||
|
|
c650bdcc20 | ||
|
|
9dee1629da | ||
|
|
7941d72104 | ||
|
|
d0d5d29fab | ||
|
|
4225317172 | ||
|
|
1af9d9be9d | ||
|
|
d3b1faeb99 | ||
|
|
57e2c3acc6 | ||
|
|
338be54355 | ||
|
|
c33a567828 | ||
|
|
59e47b2f37 | ||
|
|
748051e261 | ||
|
|
01dcc65b1f | ||
|
|
a4fbb205fc | ||
|
|
5012a1211b | ||
|
|
fc8ab38a57 | ||
|
|
7fc9819b63 | ||
|
|
2277bb79a8 | ||
|
|
03856f7e2f | ||
|
|
2974dd3b6a | ||
|
|
b6eb83cd0e | ||
|
|
edd0aab718 | ||
|
|
e6ba8a89b6 | ||
|
|
fcfa242588 | ||
|
|
135645ff61 | ||
|
|
903d9fa22e | ||
|
|
af0a5cd317 | ||
|
|
e0e5316062 | ||
|
|
5a5db8609d | ||
|
|
db1c7289be | ||
|
|
9bf0995599 | ||
|
|
8d8cc41290 | ||
|
|
1cb1fc4713 | ||
|
|
00b62e8f9d | ||
|
|
570c2490d5 | ||
|
|
4dd0feb417 | ||
|
|
c4ea2e9b40 | ||
|
|
a5b4b66fe1 | ||
|
|
db773e0c51 | ||
|
|
47c7ba369d | ||
|
|
b1cdc91f47 | ||
|
|
e53bd5c838 | ||
|
|
fcc7e5fd5f | ||
|
|
e61159e3f2 | ||
|
|
3824eae7c1 | ||
|
|
c086d023b6 | ||
|
|
08e239a544 | ||
|
|
82177db96d | ||
|
|
ebe5f3fc94 | ||
|
|
a45ef35972 | ||
|
|
5d1bc0466d | ||
|
|
9a87d5c5c3 | ||
|
|
ba11bb9e88 | ||
|
|
a0da2075a8 | ||
|
|
1eef03706b | ||
|
|
dd60dbbd98 | ||
|
|
192f674b2b | ||
|
|
fe9ddf1816 | ||
|
|
ea7cfa5943 | ||
|
|
28a88c8173 | ||
|
|
551c895088 | ||
|
|
26060c6779 | ||
|
|
6fc96c34b0 | ||
|
|
bc169d68c6 | ||
|
|
1a13cb0f9e | ||
|
|
cc59a50479 | ||
|
|
d984792e2d | ||
|
|
ae12625366 | ||
|
|
163af9e756 | ||
|
|
02b9fb60bb | ||
|
|
b731b36656 | ||
|
|
ffe674912c | ||
|
|
1b8bb7b327 | ||
|
|
c500006b9d | ||
|
|
8713cd5317 | ||
|
|
e7ec5c5bd2 | ||
|
|
352f472769 | ||
|
|
46b66fa8ad | ||
|
|
c8f757f467 | ||
|
|
0e1c79e933 | ||
|
|
fed10724e7 | ||
|
|
3c8d5c5fab | ||
|
|
fdb18aca8c | ||
|
|
98de292fa3 | ||
|
|
1dea42a39a | ||
|
|
3b9094fe2b | ||
|
|
2c395ac7c4 | ||
|
|
6e94f99b52 | ||
|
|
c7e2789021 | ||
|
|
6d8b997d89 | ||
|
|
11193c749d | ||
|
|
80d34e507d | ||
|
|
70063e9546 | ||
|
|
0fcb678977 | ||
|
|
9e45e6e4e8 | ||
|
|
3ad6eddd0c | ||
|
|
84fe0fd6ca | ||
|
|
2208e2e7a7 | ||
|
|
593869deb3 | ||
|
|
d74581d350 | ||
|
|
947f36f28e | ||
|
|
5b56ebf614 | ||
|
|
35c1da9a7e | ||
|
|
d0475c5eb0 | ||
|
|
218f2e6a14 | ||
|
|
ca7246afcc | ||
|
|
850ec393f4 | ||
|
|
0a64cfd8d2 | ||
|
|
db81460102 | ||
|
|
e805003df9 | ||
|
|
38fe985bd8 | ||
|
|
5c3222490b | ||
|
|
7207b3d812 | ||
|
|
ee3008b5cd | ||
|
|
acc4533868 | ||
|
|
a58c6244f2 | ||
|
|
833c026bbe | ||
|
|
86b34ac7fb | ||
|
|
d68edaca4d | ||
|
|
e6b7ca865e | ||
|
|
66ea6988ff | ||
|
|
7e2bbbc61a | ||
|
|
2ae4c87595 | ||
|
|
95932bb8f5 | ||
|
|
186b963e3a | ||
|
|
9ee8c7fb50 | ||
|
|
95e31975ba | ||
|
|
57c3b7f2aa | ||
|
|
91c777ea3a | ||
|
|
70f250e875 | ||
|
|
f37db62dbe | ||
|
|
4669afb224 | ||
|
|
318c007aeb | ||
|
|
a6f7364c09 | ||
|
|
b2e8517e0f | ||
|
|
1cafe6c539 | ||
|
|
38d33536f9 | ||
|
|
acc77901ad | ||
|
|
47844799c8 | ||
|
|
040d46e5f5 | ||
|
|
0a621fa9a3 | ||
|
|
2971e0368f | ||
|
|
c60586c13e | ||
|
|
a682757b8b | ||
|
|
454c0d6fec | ||
|
|
14e7391bc2 | ||
|
|
5bb1ee7819 | ||
|
|
cd33bea725 | ||
|
|
a404b99ab1 | ||
|
|
6e54d2d412 | ||
|
|
8a3ac11643 | ||
|
|
e592017602 | ||
|
|
6960f808f2 | ||
|
|
888eafb4b3 | ||
|
|
7987e14a65 | ||
|
|
8f6109e93f | ||
|
|
0074062633 | ||
|
|
a46e88b970 | ||
|
|
fa94dba71a | ||
|
|
21769a926c | ||
|
|
2b2569772c | ||
|
|
eb8ce2d0a1 | ||
|
|
6542595a5f | ||
|
|
d7db8890d7 | ||
|
|
6ac9e7d207 | ||
|
|
f4902f5a70 | ||
|
|
6378920ac4 |
22
.github/workflows/app-build.yaml
vendored
22
.github/workflows/app-build.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: App Build
|
||||
name: App / Build
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -11,22 +11,26 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
name: Build
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@2c7a4878f5d120bd643426d54ae1209b29cc01a3 # tag=v3
|
||||
uses: actions/setup-java@de1bb2b0c5634f0fc4438d7aa9944e68f9bf86cc # tag=v3
|
||||
with:
|
||||
distribution: adopt
|
||||
java-version: 11
|
||||
cache: gradle
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef # tag=v2
|
||||
- name: Assemble debug APKs
|
||||
run: ./gradlew --build-cache --no-daemon --info assembleDebug
|
||||
run: ./gradlew assembleDebug
|
||||
- name: Create publish bundle
|
||||
run: mkdir -p build/gh-app-publish/; find app/build/ -iname "*.apk" -exec mv "{}" build/gh-app-publish/ \;
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # tag=v3
|
||||
uses: actions/upload-artifact@83fd05a356d7e2593de66fc9913b3002723633cb # tag=v3
|
||||
with:
|
||||
name: build-artifacts
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
path: app/build/outputs/apk/debug/*.apk
|
||||
path: build/gh-app-publish/
|
||||
|
||||
24
.github/workflows/app-lint.yaml
vendored
24
.github/workflows/app-lint.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: App Lint
|
||||
name: App / Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -9,26 +9,26 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
permissions:
|
||||
contents: read # for actions/checkout to fetch code
|
||||
security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
|
||||
runs-on: ubuntu-20.04
|
||||
name: Lint
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@2c7a4878f5d120bd643426d54ae1209b29cc01a3 # tag=v3
|
||||
uses: actions/setup-java@de1bb2b0c5634f0fc4438d7aa9944e68f9bf86cc # tag=v3
|
||||
with:
|
||||
distribution: adopt
|
||||
java-version: 11
|
||||
cache: gradle
|
||||
- name: Run detekt and lint task
|
||||
run: ./gradlew --build-cache --no-daemon --info detekt lint
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef # tag=v2
|
||||
- name: Run detekt and lint tasks
|
||||
run: ./gradlew detekt lint
|
||||
- name: Upload SARIF files
|
||||
uses: github/codeql-action/upload-sarif@2ca79b6fa8d3ec278944088b4aa5f46912db5d63 # tag=v2
|
||||
uses: github/codeql-action/upload-sarif@678fc3afe258fb2e0cdc165ccf77b85719de7b3c # v2
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
sarif_file: .
|
||||
|
||||
23
.github/workflows/app-publish.yaml
vendored
23
.github/workflows/app-publish.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: App Publish
|
||||
name: App / Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -6,21 +6,24 @@ on:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin-androidtv' }}
|
||||
publish:
|
||||
name: Publish
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ contains(github.repository_owner, 'jellyfin') }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@2c7a4878f5d120bd643426d54ae1209b29cc01a3 # tag=v3
|
||||
uses: actions/setup-java@de1bb2b0c5634f0fc4438d7aa9944e68f9bf86cc # tag=v3
|
||||
with:
|
||||
distribution: adopt
|
||||
java-version: 11
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef # tag=v2
|
||||
- name: Set JELLYFIN_VERSION
|
||||
run: echo "JELLYFIN_VERSION=$(echo ${GITHUB_REF#refs/tags/v} | tr / -)" >> $GITHUB_ENV
|
||||
- name: Assemble release files
|
||||
run: ./gradlew --no-daemon --info assemble bundleRelease versionTxt
|
||||
run: ./gradlew assemble bundleRelease versionTxt
|
||||
- name: Sign APK
|
||||
id: signApk
|
||||
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # tag=v1
|
||||
@@ -30,7 +33,7 @@ jobs:
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
- name: Sign AAB
|
||||
- name: Sign app bundle
|
||||
id: signAab
|
||||
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # tag=v1
|
||||
with:
|
||||
@@ -48,7 +51,7 @@ jobs:
|
||||
mv ${{ steps.signAab.outputs.signedReleaseFile }} build/jellyfin-publish/jellyfin-androidtv-v${{ env.JELLYFIN_VERSION }}-release.aab
|
||||
mv app/build/version.txt build/jellyfin-publish/
|
||||
- name: Upload release archive to GitHub release
|
||||
uses: alexellis/upload-assets@5586bc227f8525a5e3525e6edf64da5350bfb5b1 # tag=0.3.0
|
||||
uses: alexellis/upload-assets@259de5111cb56966d046ced998941e93f91d2c93 # tag=0.4.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
with:
|
||||
|
||||
16
.github/workflows/app-test.yaml
vendored
16
.github/workflows/app-test.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: App Test
|
||||
name: App / Test
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -12,15 +12,17 @@ permissions:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-20.04
|
||||
name: Test
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@2c7a4878f5d120bd643426d54ae1209b29cc01a3 # tag=v3
|
||||
uses: actions/setup-java@de1bb2b0c5634f0fc4438d7aa9944e68f9bf86cc # tag=v3
|
||||
with:
|
||||
distribution: adopt
|
||||
java-version: 11
|
||||
cache: gradle
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef # tag=v2
|
||||
- name: Run test task
|
||||
run: ./gradlew --build-cache --no-daemon --info test
|
||||
run: ./gradlew test
|
||||
|
||||
7
.github/workflows/gradlew-update.yaml
vendored
7
.github/workflows/gradlew-update.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Gradle update
|
||||
name: Gradle / Update wrapper
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -6,8 +6,9 @@ on:
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-20.04
|
||||
if: github.repository == 'jellyfin/jellyfin-androidtv'
|
||||
name: Update
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ contains(github.repository_owner, 'jellyfin') }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
|
||||
|
||||
7
.github/workflows/gradlew-validate.yaml
vendored
7
.github/workflows/gradlew-validate.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Gradle validate
|
||||
name: Gradle / Validate wrapper
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -13,9 +13,10 @@ permissions:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-20.04
|
||||
name: Validate
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/wrapper-validation-action@e6e38bacfdf1a337459f332974bb2327a31aaf4b # tag=v1
|
||||
uses: gradle/wrapper-validation-action@55e685c48d84285a5b0418cd094606e199cca3b6 # tag=v1
|
||||
|
||||
17
.github/workflows/merge-conflict-labeler.yaml
vendored
17
.github/workflows/merge-conflict-labeler.yaml
vendored
@@ -1,17 +0,0 @@
|
||||
name: Merge conflict labeler
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request_target:
|
||||
types:
|
||||
- synchronize
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-20.04
|
||||
if: github.repository == 'jellyfin/jellyfin-androidtv'
|
||||
steps:
|
||||
- uses: eps1lon/actions-label-merge-conflict@b8bf8341285ec9a4567d4318ba474fee998a6919 # tag=v2.0.1
|
||||
with:
|
||||
dirtyLabel: merge conflict
|
||||
repoToken: ${{ secrets.JF_BOT_TOKEN }}
|
||||
18
.github/workflows/repo-merge-conflict.yaml
vendored
Normal file
18
.github/workflows/repo-merge-conflict.yaml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: Repo / Label merge conflict
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request_target:
|
||||
types:
|
||||
- synchronize
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
name: Triage
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ contains(github.repository_owner, 'jellyfin') }}
|
||||
steps:
|
||||
- uses: eps1lon/actions-label-merge-conflict@fd1f295ee7443d13745804bc49fe158e240f6c6e # tag=v2.1.0
|
||||
with:
|
||||
dirtyLabel: merge conflict
|
||||
repoToken: ${{ secrets.JF_BOT_TOKEN }}
|
||||
15
.github/workflows/repo-stale.yaml
vendored
15
.github/workflows/repo-stale.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Issue stale check
|
||||
name: Repo / Reply stale issue
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -10,11 +10,12 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-20.04
|
||||
if: github.repository == 'jellyfin/jellyfin-androidtv'
|
||||
triage:
|
||||
name: Triage
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ contains(github.repository_owner, 'jellyfin') }}
|
||||
steps:
|
||||
- uses: actions/stale@9c1b1c6e115ca2af09755448e0dbba24e5061cc8 # tag=v5
|
||||
- uses: actions/stale@5ebf00ea0e4c1561e9b43a292ed34424fb1d4578 # tag=v6
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
days-before-stale: 120
|
||||
@@ -25,7 +26,7 @@ jobs:
|
||||
stale-issue-label: stale
|
||||
stale-issue-message: |-
|
||||
This issue has gone 120 days without comment. To avoid abandoned issues, it will be closed in 21 days if there are no new comments.
|
||||
|
||||
|
||||
If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or master branch, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label.
|
||||
|
||||
|
||||
This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://docs.jellyfin.org/general/getting-help.html).
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
- [joern-h](https://github.com/joern-h)
|
||||
- [Niels van Velzen](https://github.com/nielsvanvelzen)
|
||||
- [GodTamIt](https://github.com/GodTamIt)
|
||||
- [sparky3387](https://github.com/sparky3387)
|
||||
|
||||
# Emby Contributors
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
<a href="https://www.amazon.com/gp/aw/d/B07TX7Z725">
|
||||
<img width="153" alt="Jellyfin on Amazon Appstore" src="https://jellyfin.org/images/store-icons/amazon.png"/>
|
||||
</a>
|
||||
<a href="https://f-droid.org/en/packages/org.jellyfin.androidtv/">
|
||||
<img width="153" alt="Jellyfin on F-Droid" src="https://jellyfin.org/images/store-icons/fdroid.png"/>
|
||||
</a>
|
||||
<br/>
|
||||
<a href="https://repo.jellyfin.org/releases/client/androidtv/">Download archive</a>
|
||||
</p>
|
||||
|
||||
39
app/baselineWorkaround.gradle
Normal file
39
app/baselineWorkaround.gradle
Normal file
@@ -0,0 +1,39 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath libs.android.gradle
|
||||
}
|
||||
}
|
||||
|
||||
import com.android.tools.profgen.ArtProfileKt
|
||||
import com.android.tools.profgen.ArtProfileSerializer
|
||||
import com.android.tools.profgen.DexFile
|
||||
|
||||
/**
|
||||
* This is a temporary workaround for https://issuetracker.google.com/issues/231837768
|
||||
* which will be fixed in AGP 8.1.0, at which point the workaround may be deleted.
|
||||
*/
|
||||
project.afterEvaluate {
|
||||
tasks.compileReleaseArtProfile.doLast {
|
||||
outputs.files.each { file ->
|
||||
if (file.toString().endsWith(".profm")) {
|
||||
println("Sorting ${file}")
|
||||
def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
|
||||
def profile = ArtProfileKt.ArtProfile(file)
|
||||
def keys = new ArrayList(profile.profileData.keySet())
|
||||
def sortedData = new LinkedHashMap()
|
||||
Collections.sort keys, new DexFile.Companion()
|
||||
keys.each { key -> sortedData[key] = profile.profileData[key] }
|
||||
new FileOutputStream(file).with {
|
||||
write(version.magicBytes$profgen)
|
||||
write(version.versionBytes$profgen)
|
||||
version.write$profgen(it, sortedData, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
kotlin("android")
|
||||
kotlin("kapt")
|
||||
alias(libs.plugins.kotlin.ksp)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.aboutlibraries)
|
||||
}
|
||||
|
||||
// Apply workaround
|
||||
apply("baselineWorkaround.gradle")
|
||||
|
||||
android {
|
||||
namespace = "org.jellyfin.androidtv"
|
||||
compileSdk = 31
|
||||
compileSdk = 33
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 21
|
||||
targetSdk = 31
|
||||
targetSdk = 33
|
||||
|
||||
// Release version
|
||||
applicationId = namespace
|
||||
@@ -21,17 +24,19 @@ android {
|
||||
setProperty("archivesBaseName", "jellyfin-androidtv-v$versionName")
|
||||
}
|
||||
|
||||
sourceSets["main"].java.srcDirs("src/main/kotlin")
|
||||
sourceSets["test"].java.srcDirs("src/test/kotlin")
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
compose = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get()
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
val release by getting {
|
||||
isMinifyEnabled = false
|
||||
@@ -92,7 +97,10 @@ val versionTxt by tasks.registering {
|
||||
|
||||
dependencies {
|
||||
// Jellyfin
|
||||
implementation(projects.playback)
|
||||
implementation(projects.playback.core)
|
||||
implementation(projects.playback.exoplayer)
|
||||
implementation(projects.playback.jellyfin)
|
||||
implementation(projects.playback.ui)
|
||||
implementation(projects.preference)
|
||||
implementation(libs.jellyfin.apiclient)
|
||||
implementation(libs.jellyfin.sdk) {
|
||||
@@ -125,13 +133,11 @@ dependencies {
|
||||
implementation(libs.androidx.window)
|
||||
implementation(libs.androidx.cardview)
|
||||
implementation(libs.androidx.startup)
|
||||
implementation(libs.bundles.androidx.compose)
|
||||
|
||||
// Dependency Injection
|
||||
implementation(libs.bundles.koin)
|
||||
|
||||
// GSON
|
||||
implementation(libs.gson)
|
||||
|
||||
// Media players
|
||||
implementation(libs.exoplayer)
|
||||
implementation(libs.jellyfin.exoplayer.ffmpegextension)
|
||||
@@ -141,8 +147,9 @@ dependencies {
|
||||
implementation(libs.bundles.markwon)
|
||||
|
||||
// Image utility
|
||||
implementation(libs.blurhash)
|
||||
implementation(libs.glide.core)
|
||||
kapt(libs.glide.compiler)
|
||||
ksp(libs.glide.ksp)
|
||||
implementation(libs.kenburnsview)
|
||||
|
||||
// Crash Reporting
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="org.jellyfin.androidtv">
|
||||
android:installLocation="auto">
|
||||
|
||||
<!-- Android TV Integration -->
|
||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
|
||||
@@ -23,13 +23,14 @@
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_WIFI_STATE"
|
||||
tools:ignore="LeanbackUsesWifi" />
|
||||
|
||||
<!-- Search permissions (voice to text) -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<!-- Device feature requirements -->
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
@@ -48,9 +49,9 @@
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
android:roundIcon="@drawable/app_icon_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Jellyfin"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<service
|
||||
android:name=".auth.service.AuthenticatorService"
|
||||
android:exported="false">
|
||||
@@ -62,6 +63,7 @@
|
||||
android:resource="@xml/authenticator" />
|
||||
</service>
|
||||
|
||||
<!-- Screensaver -->
|
||||
<service
|
||||
android:name=".integration.dream.LibraryDreamService"
|
||||
android:exported="true"
|
||||
@@ -88,6 +90,9 @@
|
||||
<meta-data
|
||||
android:name="org.jellyfin.androidtv.SessionInitializer"
|
||||
android:value="androidx.startup" />
|
||||
<meta-data
|
||||
android:name="org.jellyfin.androidtv.ProcessLifecycleInitializer"
|
||||
android:value="androidx.startup" />
|
||||
</provider>
|
||||
|
||||
<provider
|
||||
@@ -96,10 +101,13 @@
|
||||
android:exported="true"
|
||||
android:initOrder="10" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.browsing.MainActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
<provider
|
||||
android:name=".integration.provider.ImageProvider"
|
||||
android:authorities="${applicationId}.integration.provider.ImageProvider"
|
||||
android:exported="true"
|
||||
android:initOrder="10" />
|
||||
|
||||
<!-- Authentication -->
|
||||
<activity
|
||||
android:name=".ui.startup.StartupActivity"
|
||||
android:exported="true"
|
||||
@@ -118,73 +126,24 @@
|
||||
android:name="android.app.searchable"
|
||||
android:resource="@xml/searchable" />
|
||||
</activity>
|
||||
|
||||
<activity-alias
|
||||
android:name=".startup.StartupActivity"
|
||||
android:targetActivity=".ui.startup.StartupActivity" />
|
||||
|
||||
<!-- Main application -->
|
||||
<activity
|
||||
android:name=".ui.playback.PlaybackOverlayActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.browsing.GenericFolderActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.browsing.UserViewActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.browsing.CollectionActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.browsing.GroupedItemsActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
android:name=".ui.browsing.MainActivity"
|
||||
android:screenOrientation="landscape"
|
||||
android:windowSoftInputMode="adjustNothing" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.preference.PreferencesActivity"
|
||||
android:screenOrientation="behind"
|
||||
android:theme="@style/Theme.Jellyfin.Preferences" />
|
||||
|
||||
<!-- Playback related activities -->
|
||||
<activity
|
||||
android:name=".ui.browsing.SuggestedMoviesActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.search.SearchActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.itemdetail.FullDetailsActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.livetv.LiveTvGuideActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.browsing.GenericGridActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.browsing.BrowseRecordingsActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.browsing.BrowseScheduleActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.itemdetail.PhotoPlayerActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.itemdetail.ItemListActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.playback.AudioNowPlayingActivity"
|
||||
android:name=".ui.playback.PlaybackOverlayActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity android:name=".ui.playback.ExternalPlayerActivity" />
|
||||
|
||||
@@ -2,8 +2,6 @@ package org.jellyfin.androidtv
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.work.BackoffPolicy
|
||||
@@ -11,20 +9,18 @@ import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.await
|
||||
import com.bumptech.glide.Glide
|
||||
import com.vanniktech.blurhash.BlurHash
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.acra.ACRA
|
||||
import org.jellyfin.androidtv.auth.repository.SessionRepository
|
||||
import org.jellyfin.androidtv.data.eventhandling.SocketHandler
|
||||
import org.jellyfin.androidtv.data.repository.NotificationsRepository
|
||||
import org.jellyfin.androidtv.integration.LeanbackChannelWorker
|
||||
import org.jellyfin.androidtv.telemetry.TelemetryService
|
||||
import org.jellyfin.androidtv.util.AutoBitrate
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.android.getKoin
|
||||
import org.koin.android.ext.android.inject
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Suppress("unused")
|
||||
@@ -37,28 +33,6 @@ class JellyfinApplication : Application() {
|
||||
|
||||
val notificationsRepository by inject<NotificationsRepository>()
|
||||
notificationsRepository.addDefaultNotifications()
|
||||
|
||||
// Register application lifecycle events
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
/**
|
||||
* Called by the Process Lifecycle when the app is created. It is called after [onCreate].
|
||||
*/
|
||||
override fun onCreate(owner: LifecycleOwner) {
|
||||
// Register activity lifecycle callbacks
|
||||
getKoin().getAll<ActivityLifecycleCallbacks>().forEach(::registerActivityLifecycleCallbacks)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the Process Lifecycle when the app is activated in the foreground (activity opened).
|
||||
*/
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
Timber.i("Process lifecycle started")
|
||||
|
||||
owner.lifecycleScope.launch {
|
||||
get<SessionRepository>().restoreSession()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,6 +66,19 @@ class JellyfinApplication : Application() {
|
||||
ProcessLifecycleOwner.get().lifecycleScope.launch { autoBitrate.detect() }
|
||||
}
|
||||
|
||||
override fun onLowMemory() {
|
||||
super.onLowMemory()
|
||||
|
||||
BlurHash.clearCache()
|
||||
Glide.with(this).onLowMemory()
|
||||
}
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
|
||||
Glide.with(this).onTrimMemory(level)
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.jellyfin.androidtv
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.startup.AppInitializer
|
||||
import androidx.startup.Initializer
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.auth.repository.SessionRepository
|
||||
import org.jellyfin.androidtv.di.KoinInitializer
|
||||
import timber.log.Timber
|
||||
|
||||
@Suppress("unused")
|
||||
class ProcessLifecycleInitializer : Initializer<Unit> {
|
||||
override fun create(context: Context) {
|
||||
val koin = AppInitializer.getInstance(context)
|
||||
.initializeComponent(KoinInitializer::class.java)
|
||||
.koin
|
||||
|
||||
// Register application lifecycle events
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
/**
|
||||
* Called by the Process Lifecycle when the app is created. It is called after [onCreate].
|
||||
*/
|
||||
override fun onCreate(owner: LifecycleOwner) {
|
||||
// Register activity lifecycle callbacks
|
||||
val callbacks = koin.getAll<Application.ActivityLifecycleCallbacks>()
|
||||
Timber.i("Registering ${callbacks.size} ActivityLifecycleCallbacks")
|
||||
val app = context.applicationContext as Application
|
||||
callbacks.forEach { callback -> app.registerActivityLifecycleCallbacks(callback) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the Process Lifecycle when the app is activated in the foreground (activity opened).
|
||||
*/
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
Timber.i("Process lifecycle started")
|
||||
|
||||
owner.lifecycleScope.launch {
|
||||
koin.get<SessionRepository>().restoreSession()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun dependencies() = listOf(KoinInitializer::class.java)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import java.util.UUID
|
||||
|
||||
data class AccountManagerAccount(
|
||||
val id: UUID,
|
||||
val address: String,
|
||||
val server: UUID,
|
||||
val name: String,
|
||||
val accessToken: String? = null,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package org.jellyfin.androidtv.auth.model
|
||||
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
|
||||
sealed class LoginState
|
||||
object AuthenticatingState : LoginState()
|
||||
object RequireSignInState : LoginState()
|
||||
object ServerUnavailableState : LoginState()
|
||||
data class ServerVersionNotSupported(val server: Server) : LoginState()
|
||||
data class ApiClientErrorLoginState(val error: ApiClientException) : LoginState()
|
||||
object AuthenticatedState : LoginState()
|
||||
|
||||
@@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.jellyfin.androidtv.auth.model.AccountManagerAccount
|
||||
import org.jellyfin.androidtv.auth.model.ApiClientErrorLoginState
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticateMethod
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticatedState
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticatingState
|
||||
@@ -69,12 +70,12 @@ class AuthenticationRepositoryImpl(
|
||||
}
|
||||
|
||||
private fun authenticateAutomatic(server: Server, user: User): Flow<LoginState> {
|
||||
Timber.d("Authenticating user %s", user)
|
||||
Timber.i("Authenticating user %s", user)
|
||||
|
||||
// Automatic logic is disabled when the always authenticate preference is enabled
|
||||
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return flowOf(RequireSignInState)
|
||||
|
||||
val account = accountManagerStore.getAccount(user.id)
|
||||
val account = accountManagerStore.getAccount(server.id, user.id)
|
||||
// Try login with access token
|
||||
return if (account?.accessToken != null) authenticateToken(server, user.withToken(account.accessToken))
|
||||
// Try login without password
|
||||
@@ -94,7 +95,7 @@ class AuthenticationRepositoryImpl(
|
||||
return@flow
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to sign in as $username")
|
||||
emit(RequireSignInState)
|
||||
emit(ApiClientErrorLoginState(err))
|
||||
return@flow
|
||||
}
|
||||
|
||||
@@ -106,9 +107,13 @@ class AuthenticationRepositoryImpl(
|
||||
val result = try {
|
||||
val response = api.userApi.authenticateWithQuickConnect(secret)
|
||||
response.content
|
||||
} catch (err: TimeoutException) {
|
||||
Timber.e(err, "Failed to connect to server")
|
||||
emit(ServerUnavailableState)
|
||||
return@flow
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to sign in with Quick Connect secret")
|
||||
emit(RequireSignInState)
|
||||
emit(ApiClientErrorLoginState(err))
|
||||
return@flow
|
||||
}
|
||||
|
||||
@@ -116,7 +121,7 @@ class AuthenticationRepositoryImpl(
|
||||
}
|
||||
|
||||
private fun authenticateAuthenticationResult(server: Server, result: AuthenticationResult) = flow {
|
||||
val accessToken = result.accessToken ?:return@flow emit(RequireSignInState)
|
||||
val accessToken = result.accessToken ?: return@flow emit(RequireSignInState)
|
||||
val userInfo = result.user ?: return@flow emit(RequireSignInState)
|
||||
val user = PrivateUser(
|
||||
id = userInfo.id,
|
||||
@@ -130,8 +135,12 @@ class AuthenticationRepositoryImpl(
|
||||
|
||||
authenticateFinish(server, userInfo, accessToken)
|
||||
val success = setActiveSession(user, server)
|
||||
if (success) emit(AuthenticatedState)
|
||||
else emit(RequireSignInState)
|
||||
if (success) {
|
||||
emit(AuthenticatedState)
|
||||
} else {
|
||||
Timber.w("Failed to set active session after authenticating")
|
||||
emit(RequireSignInState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun authenticateToken(server: Server, user: User) = flow {
|
||||
@@ -145,9 +154,13 @@ class AuthenticationRepositoryImpl(
|
||||
val userInfo by userApiClient.userApi.getCurrentUser()
|
||||
authenticateFinish(server, userInfo, user.accessToken.orEmpty())
|
||||
emit(AuthenticatedState)
|
||||
} catch (err: TimeoutException) {
|
||||
Timber.e(err, "Failed to connect to server")
|
||||
emit(ServerUnavailableState)
|
||||
return@flow
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to get current user data")
|
||||
emit(RequireSignInState)
|
||||
emit(ApiClientErrorLoginState(err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,12 +179,18 @@ class AuthenticationRepositoryImpl(
|
||||
)
|
||||
authenticationStore.putUser(server.id, userInfo.id, updatedUser)
|
||||
|
||||
val accountManagerAccount = AccountManagerAccount(userInfo.id, server.id, updatedUser.name, accessToken)
|
||||
val accountManagerAccount = AccountManagerAccount(
|
||||
id = userInfo.id,
|
||||
address = "${updatedUser.name}@${server.address}",
|
||||
server = server.id,
|
||||
name = updatedUser.name,
|
||||
accessToken = accessToken,
|
||||
)
|
||||
accountManagerStore.putAccount(accountManagerAccount)
|
||||
}
|
||||
|
||||
private suspend fun setActiveSession(user: User, server: Server): Boolean {
|
||||
val authenticated = sessionRepository.switchCurrentSession(user.id)
|
||||
val authenticated = sessionRepository.switchCurrentSession(server.id, user.id)
|
||||
|
||||
if (authenticated) {
|
||||
// Update last use in store
|
||||
@@ -188,7 +207,7 @@ class AuthenticationRepositoryImpl(
|
||||
}
|
||||
|
||||
override fun logout(user: User): Boolean {
|
||||
val authInfo = accountManagerStore.getAccount(user.id) ?: return false
|
||||
val authInfo = accountManagerStore.getAccount(user.serverId, user.id) ?: return false
|
||||
return accountManagerStore.removeAccount(authInfo)
|
||||
}
|
||||
|
||||
|
||||
@@ -83,12 +83,12 @@ class ServerRepositoryImpl(
|
||||
|
||||
// Mutating data
|
||||
override fun addServer(address: String): Flow<ServerAdditionState> = flow {
|
||||
Timber.d("Adding server %s", address)
|
||||
Timber.i("Adding server %s", address)
|
||||
|
||||
emit(ConnectingState(address))
|
||||
|
||||
val addressCandidates = jellyfin.discovery.getAddressCandidates(address)
|
||||
Timber.d("Found ${addressCandidates.size} candidates")
|
||||
Timber.i("Found ${addressCandidates.size} candidates")
|
||||
|
||||
val goodRecommendations = mutableListOf<RecommendedServerInfo>()
|
||||
val badRecommendations = mutableListOf<RecommendedServerInfo>()
|
||||
|
||||
@@ -31,7 +31,7 @@ class ServerUserRepositoryImpl(
|
||||
) : ServerUserRepository {
|
||||
override fun getStoredServerUsers(server: Server) = authenticationStore.getUsers(server.id)
|
||||
?.mapNotNull { (userId, userInfo) ->
|
||||
val authInfo = accountManagerStore.getAccount(userId)
|
||||
val authInfo = accountManagerStore.getAccount(server.id, userId)
|
||||
PrivateUser(
|
||||
id = authInfo?.id ?: userId,
|
||||
serverId = authInfo?.server ?: server.id,
|
||||
@@ -64,7 +64,7 @@ class ServerUserRepositoryImpl(
|
||||
authenticationStore.removeUser(user.serverId, user.id)
|
||||
|
||||
// Remove authentication info from system account manager
|
||||
accountManagerStore.getAccount(user.id)?.let { accountManagerAccount ->
|
||||
accountManagerStore.getAccount(user.serverId, user.id)?.let { accountManagerAccount ->
|
||||
accountManagerStore.removeAccount(accountManagerAccount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ interface SessionRepository {
|
||||
val state: StateFlow<SessionRepositoryState>
|
||||
|
||||
suspend fun restoreSession()
|
||||
suspend fun switchCurrentSession(userId: UUID): Boolean
|
||||
suspend fun switchCurrentSession(serverId: UUID, userId: UUID): Boolean
|
||||
fun destroyCurrentSession()
|
||||
}
|
||||
|
||||
@@ -64,33 +64,37 @@ class SessionRepositoryImpl(
|
||||
override val state = _state.asStateFlow()
|
||||
|
||||
override suspend fun restoreSession(): Unit = currentSessionMutex.withLock {
|
||||
Timber.d("Restoring session")
|
||||
Timber.i("Restoring session")
|
||||
_state.value = SessionRepositoryState.RESTORING_SESSION
|
||||
|
||||
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return destroyCurrentSession()
|
||||
|
||||
val behavior = authenticationPreferences[AuthenticationPreferences.autoLoginUserBehavior]
|
||||
val userId = authenticationPreferences[AuthenticationPreferences.autoLoginUserId].toUUIDOrNull()
|
||||
|
||||
when (behavior) {
|
||||
when (authenticationPreferences[AuthenticationPreferences.autoLoginUserBehavior]) {
|
||||
DISABLED -> destroyCurrentSession()
|
||||
LAST_USER -> setCurrentSession(createLastUserSession())
|
||||
SPECIFIC_USER -> setCurrentSession(createUserSession(userId))
|
||||
SPECIFIC_USER -> {
|
||||
val serverId = authenticationPreferences[AuthenticationPreferences.autoLoginServerId].toUUIDOrNull()
|
||||
val userId = authenticationPreferences[AuthenticationPreferences.autoLoginUserId].toUUIDOrNull()
|
||||
if (serverId != null && userId != null) setCurrentSession(createUserSession(serverId, userId))
|
||||
}
|
||||
}
|
||||
|
||||
_state.value = SessionRepositoryState.READY
|
||||
}
|
||||
|
||||
override suspend fun switchCurrentSession(userId: UUID): Boolean {
|
||||
override suspend fun switchCurrentSession(serverId: UUID, userId: UUID): Boolean {
|
||||
// No change in user - don't switch
|
||||
if (currentSession.value?.userId == userId) return false
|
||||
if (currentSession.value?.userId == userId) {
|
||||
Timber.d("Current session user is the same as the requested user")
|
||||
return false
|
||||
}
|
||||
|
||||
_state.value = SessionRepositoryState.SWITCHING_SESSION
|
||||
Timber.d("Switching current session to user $userId")
|
||||
Timber.i("Switching current session to user $userId")
|
||||
|
||||
val session = createUserSession(userId)
|
||||
val session = createUserSession(serverId, userId)
|
||||
if (session == null) {
|
||||
Timber.d("Could not switch to non-existing session for user $userId")
|
||||
Timber.w("Could not switch to non-existing session for user $userId")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -100,7 +104,7 @@ class SessionRepositoryImpl(
|
||||
}
|
||||
|
||||
override fun destroyCurrentSession() {
|
||||
Timber.d("Destroying current session")
|
||||
Timber.i("Destroying current session")
|
||||
|
||||
userRepository.updateCurrentUser(null)
|
||||
_currentSession.value = null
|
||||
@@ -115,6 +119,7 @@ class SessionRepositoryImpl(
|
||||
if (currentSession.value?.userId == session.userId) return true
|
||||
|
||||
// Update last active user
|
||||
authenticationPreferences[AuthenticationPreferences.lastServerId] = session.serverId.toString()
|
||||
authenticationPreferences[AuthenticationPreferences.lastUserId] = session.userId.toString()
|
||||
|
||||
// Check if server version is supported
|
||||
@@ -125,7 +130,7 @@ class SessionRepositoryImpl(
|
||||
// Update session after binding the apiclient settings
|
||||
val deviceInfo = session?.let { defaultDeviceInfo.forUser(it.userId) } ?: defaultDeviceInfo
|
||||
val success = apiBinder.updateSession(session, deviceInfo)
|
||||
Timber.d("Updating current session. userId=${session?.userId} apiBindingSuccess=${success}")
|
||||
Timber.i("Updating current session. userId=${session?.userId} apiBindingSuccess=${success}")
|
||||
|
||||
if (success) {
|
||||
userApiClient.applySession(session, deviceInfo)
|
||||
@@ -156,13 +161,14 @@ class SessionRepositoryImpl(
|
||||
|
||||
private fun createLastUserSession(): Session? {
|
||||
val lastUserId = authenticationPreferences[AuthenticationPreferences.lastUserId].toUUIDOrNull()
|
||||
return createUserSession(lastUserId)
|
||||
val lastServerId = authenticationPreferences[AuthenticationPreferences.lastServerId].toUUIDOrNull()
|
||||
|
||||
return if (lastUserId != null && lastServerId != null) createUserSession(lastServerId, lastUserId)
|
||||
else null
|
||||
}
|
||||
|
||||
private fun createUserSession(userId: UUID?): Session? {
|
||||
if (userId == null) return null
|
||||
|
||||
val account = accountManagerStore.getAccount(userId)
|
||||
private fun createUserSession(serverId: UUID, userId: UUID): Session? {
|
||||
val account = accountManagerStore.getAccount(serverId, userId)
|
||||
if (account?.accessToken == null) return null
|
||||
|
||||
return Session(
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.jellyfin.androidtv.auth.store
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.auth.model.AccountManagerAccount
|
||||
@@ -22,8 +23,16 @@ class AccountManagerStore(
|
||||
const val ACCOUNT_ACCESS_TOKEN_TYPE = "$ACCOUNT_TYPE.access_token"
|
||||
}
|
||||
|
||||
private fun Array<Account>.filterServerAccount(server: UUID, account: UUID? = null) = filter {
|
||||
val validServerId = accountManager.getUserData(it, ACCOUNT_DATA_SERVER)?.toUUIDOrNull() == server
|
||||
val validUserId = account == null || accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == account
|
||||
|
||||
validServerId && validUserId
|
||||
}
|
||||
|
||||
private fun getAccountData(account: Account): AccountManagerAccount = AccountManagerAccount(
|
||||
id = accountManager.getUserData(account, ACCOUNT_DATA_ID).toUUID(),
|
||||
address = account.name,
|
||||
server = accountManager.getUserData(account, ACCOUNT_DATA_SERVER).toUUID(),
|
||||
name = accountManager.getUserData(account, ACCOUNT_DATA_NAME),
|
||||
accessToken = accountManager.peekAuthToken(account, ACCOUNT_ACCESS_TOKEN_TYPE)
|
||||
@@ -31,11 +40,12 @@ class AccountManagerStore(
|
||||
|
||||
suspend fun putAccount(accountManagerAccount: AccountManagerAccount) {
|
||||
var androidAccount = accountManager.getAccountsByType(ACCOUNT_TYPE)
|
||||
.firstOrNull { accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == accountManagerAccount.id }
|
||||
.filterServerAccount(accountManagerAccount.server, accountManagerAccount.id)
|
||||
.firstOrNull()
|
||||
|
||||
// Update credentials
|
||||
if (androidAccount == null) {
|
||||
androidAccount = Account(accountManagerAccount.name, ACCOUNT_TYPE)
|
||||
androidAccount = Account(accountManagerAccount.address, ACCOUNT_TYPE)
|
||||
|
||||
accountManager.addAccountExplicitly(
|
||||
androidAccount,
|
||||
@@ -46,9 +56,14 @@ class AccountManagerStore(
|
||||
}
|
||||
|
||||
// Update name
|
||||
if (androidAccount.name != accountManagerAccount.name) {
|
||||
if (androidAccount.name != accountManagerAccount.address) {
|
||||
androidAccount = suspendCoroutine { continuation ->
|
||||
accountManager.renameAccount(androidAccount, accountManagerAccount.name, { continuation.resume(it.result) }, null)
|
||||
accountManager.renameAccount(
|
||||
androidAccount,
|
||||
accountManagerAccount.address,
|
||||
{ continuation.resume(it.result) },
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,12 +74,13 @@ class AccountManagerStore(
|
||||
|
||||
fun removeAccount(accountManagerAccount: AccountManagerAccount): Boolean {
|
||||
val androidAccount = accountManager.getAccountsByType(ACCOUNT_TYPE)
|
||||
.firstOrNull { accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == accountManagerAccount.id }
|
||||
.filterServerAccount(accountManagerAccount.server, accountManagerAccount.id)
|
||||
.firstOrNull()
|
||||
?: return false
|
||||
|
||||
// Remove current account info
|
||||
@Suppress("DEPRECATION")
|
||||
return if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
accountManager.removeAccount(androidAccount, null, null)
|
||||
true
|
||||
} else accountManager.removeAccountExplicitly(androidAccount)
|
||||
@@ -72,11 +88,12 @@ class AccountManagerStore(
|
||||
|
||||
fun getAccounts() = accountManager.getAccountsByType(ACCOUNT_TYPE).map(::getAccountData)
|
||||
|
||||
fun getAccountsByServer(server: UUID) = accountManager.getAccountsByType(ACCOUNT_TYPE).filter { account ->
|
||||
accountManager.getUserData(account, ACCOUNT_DATA_SERVER)?.toUUIDOrNull() == server
|
||||
}.map(::getAccountData)
|
||||
fun getAccountsByServer(server: UUID) = accountManager.getAccountsByType(ACCOUNT_TYPE)
|
||||
.filterServerAccount(server)
|
||||
.map(::getAccountData)
|
||||
|
||||
fun getAccount(id: UUID) = accountManager.getAccountsByType(ACCOUNT_TYPE).firstOrNull { account ->
|
||||
accountManager.getUserData(account, ACCOUNT_DATA_ID)?.toUUIDOrNull() == id
|
||||
}?.let(::getAccountData)
|
||||
fun getAccount(server: UUID, account: UUID) = accountManager.getAccountsByType(ACCOUNT_TYPE)
|
||||
.filterServerAccount(server, account)
|
||||
.firstOrNull()
|
||||
?.let(::getAccountData)
|
||||
}
|
||||
|
||||
@@ -14,12 +14,30 @@ class AuthenticationPreferences(context: Context) : SharedPreferenceStore(
|
||||
companion object {
|
||||
// Preferences
|
||||
val autoLoginUserBehavior = enumPreference("auto_login_user_behavior", UserSelectBehavior.LAST_USER)
|
||||
val autoLoginServerId = stringPreference("auto_login_server_id", "")
|
||||
val autoLoginUserId = stringPreference("auto_login_user_id", "")
|
||||
|
||||
val sortBy = enumPreference("sort_by", AuthenticationSortBy.LAST_USE)
|
||||
val alwaysAuthenticate = booleanPreference("always_authenticate", false)
|
||||
|
||||
// Persistent state
|
||||
val lastServerId = stringPreference("last_server_id", "")
|
||||
val lastUserId = stringPreference("last_user_id", "")
|
||||
}
|
||||
|
||||
init {
|
||||
runMigrations {
|
||||
// v0.15.4 to v0.15.5
|
||||
migration(toVersion = 2) {
|
||||
// Unfortunately we cannot migrate the "specific user" login option
|
||||
// so we'll reset the preference to disabled if it was used
|
||||
if (it.getString("auto_login_user_behavior", null) === UserSelectBehavior.SPECIFIC_USER.name) {
|
||||
putString("auto_login_user_id", "")
|
||||
putString("auto_login_user_behavior", UserSelectBehavior.DISABLED.name)
|
||||
}
|
||||
|
||||
putString("last_user_id", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.jellyfin.androidtv.auth.store
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -47,8 +48,15 @@ class AuthenticationStore(
|
||||
// No store found
|
||||
if (!storePath.exists()) return emptyMap()
|
||||
|
||||
// Parse JSON document
|
||||
val root = try {
|
||||
json.parseToJsonElement(storePath.readText()).jsonObject
|
||||
} catch (e: SerializationException) {
|
||||
Timber.e(e, "Unable to read JSON")
|
||||
JsonObject(emptyMap())
|
||||
}
|
||||
|
||||
// Check for version
|
||||
val root = json.parseToJsonElement(storePath.readText()).jsonObject
|
||||
return when (root["version"]?.jsonPrimitive?.intOrNull) {
|
||||
1 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
|
||||
null -> {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.jellyfin.androidtv.auth.ui
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import org.jellyfin.androidtv.auth.repository.SessionRepository
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
import org.jellyfin.androidtv.ui.startup.StartupActivity
|
||||
import org.koin.android.ext.android.inject
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Extension function to check authentication. Should be called in [FragmentActivity.onCreate] and
|
||||
* [FragmentActivity.onResume]. It validates the current session and opens the authentication screen
|
||||
* when no session is found.
|
||||
*
|
||||
* @return whether to proceed creating the activity or not. When `false` is returned the
|
||||
* [FragmentActivity.finish] function is automatically called.
|
||||
*/
|
||||
fun FragmentActivity.validateAuthentication(): Boolean {
|
||||
val sessionRepository by inject<SessionRepository>()
|
||||
val userRepository by inject<UserRepository>()
|
||||
|
||||
if (sessionRepository.currentSession.value == null || userRepository.currentUser.value == null) {
|
||||
Timber.w("Activity ${this::class.qualifiedName} started without a session, bouncing to StartupActivity")
|
||||
startActivity(Intent(this, StartupActivity::class.java))
|
||||
finish()
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -66,6 +66,7 @@ object Codec {
|
||||
|
||||
object Subtitle {
|
||||
const val ASS = "ass"
|
||||
const val DVBSUB = "dvbsub"
|
||||
const val DVDSUB = "dvdsub"
|
||||
const val IDX = "idx"
|
||||
const val PGS = "pgs"
|
||||
@@ -76,5 +77,8 @@ object Codec {
|
||||
const val SUB = "sub"
|
||||
const val SUBRIP = "subrip"
|
||||
const val VTT = "vtt"
|
||||
const val SMIL = "smil"
|
||||
const val TTML = "ttml"
|
||||
const val WEBVTT = "webvtt"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package org.jellyfin.androidtv.constant
|
||||
|
||||
enum class CustomMessage {
|
||||
RefreshCurrentItem,
|
||||
ActionComplete,
|
||||
sealed interface CustomMessage {
|
||||
object RefreshCurrentItem : CustomMessage
|
||||
object ActionComplete : CustomMessage
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.jellyfin.androidtv.constant
|
||||
object LiveTvOption {
|
||||
const val LIVE_TV_GUIDE_OPTION_ID = 1000
|
||||
const val LIVE_TV_RECORDINGS_OPTION_ID = 2000
|
||||
const val VIDEO_QUEUE_OPTION_ID = 3000
|
||||
const val LIVE_TV_SCHEDULE_OPTION_ID = 4000
|
||||
const val LIVE_TV_SERIES_OPTION_ID = 5000
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.jellyfin.androidtv.constant
|
||||
|
||||
import android.content.Context
|
||||
import org.jellyfin.androidtv.R
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private val qualityOptions = setOf(
|
||||
0.0, // auto
|
||||
120.0, 110.0, 100.0, // 100 >=
|
||||
90.0, 80.0, 70.0, 60.0, 50.0, 40.0, 30.0, 20.0, 15.0, 10.0, // 10 >=
|
||||
5.0, 3.0, 2.0, 1.0, // 1 >=
|
||||
0.72, 0.42 // 0 >=
|
||||
)
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
fun getQualityProfiles(
|
||||
context: Context
|
||||
): Map<String, String> = qualityOptions.associate {
|
||||
val value = when {
|
||||
it == 0.0 -> context.getString(R.string.bitrate_auto)
|
||||
it >= 1.0 -> context.getString(R.string.bitrate_mbit, it)
|
||||
else -> context.getString(R.string.bitrate_kbit, it * 1000.0)
|
||||
}
|
||||
|
||||
it.toString().removeSuffix(".0") to value
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ enum class QueryType {
|
||||
LiveTvChannel,
|
||||
LiveTvProgram,
|
||||
LiveTvRecording,
|
||||
LiveTvRecordingGroup,
|
||||
StaticItems,
|
||||
Persons,
|
||||
StaticAudioQueueItems,
|
||||
@@ -26,4 +25,5 @@ enum class QueryType {
|
||||
LatestItems,
|
||||
SeriesTimer,
|
||||
Premieres,
|
||||
Resume,
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
import org.jellyfin.apiclient.model.dlna.DeviceProfile;
|
||||
import org.jellyfin.apiclient.model.dlna.EncodingContext;
|
||||
import org.jellyfin.apiclient.model.dto.MediaSourceInfo;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
/**
|
||||
* Class AudioOptions.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
@Deprecated
|
||||
public class AudioOptions {
|
||||
public AudioOptions() {
|
||||
setContext(EncodingContext.Streaming);
|
||||
|
||||
setEnableDirectPlay(true);
|
||||
setEnableDirectStream(true);
|
||||
}
|
||||
|
||||
private boolean EnableDirectPlay;
|
||||
|
||||
public final boolean getEnableDirectPlay() {
|
||||
return EnableDirectPlay;
|
||||
}
|
||||
|
||||
public final void setEnableDirectPlay(boolean value) {
|
||||
EnableDirectPlay = value;
|
||||
}
|
||||
|
||||
private boolean EnableDirectStream;
|
||||
|
||||
public final boolean getEnableDirectStream() {
|
||||
return EnableDirectStream;
|
||||
}
|
||||
|
||||
public final void setEnableDirectStream(boolean value) {
|
||||
EnableDirectStream = value;
|
||||
}
|
||||
|
||||
private String ItemId;
|
||||
|
||||
public final String getItemId() {
|
||||
return ItemId;
|
||||
}
|
||||
|
||||
public final void setItemId(String value) {
|
||||
ItemId = value;
|
||||
}
|
||||
|
||||
private ArrayList<MediaSourceInfo> MediaSources;
|
||||
|
||||
public final ArrayList<MediaSourceInfo> getMediaSources() {
|
||||
return MediaSources;
|
||||
}
|
||||
|
||||
public final void setMediaSources(ArrayList<MediaSourceInfo> value) {
|
||||
MediaSources = value;
|
||||
}
|
||||
|
||||
private DeviceProfile Profile;
|
||||
|
||||
public final DeviceProfile getProfile() {
|
||||
return Profile;
|
||||
}
|
||||
|
||||
public final void setProfile(DeviceProfile value) {
|
||||
Profile = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional. Only needed if a specific AudioStreamIndex or SubtitleStreamIndex are requested.
|
||||
*/
|
||||
private String MediaSourceId;
|
||||
|
||||
public final String getMediaSourceId() {
|
||||
return MediaSourceId;
|
||||
}
|
||||
|
||||
public final void setMediaSourceId(String value) {
|
||||
MediaSourceId = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows an override of supported number of audio channels
|
||||
* Example: DeviceProfile supports five channel, but user only has stereo speakers
|
||||
*/
|
||||
private Integer MaxAudioChannels = null;
|
||||
|
||||
public final Integer getMaxAudioChannels() {
|
||||
return MaxAudioChannels;
|
||||
}
|
||||
|
||||
public final void setMaxAudioChannels(Integer value) {
|
||||
MaxAudioChannels = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The application's configured quality setting
|
||||
*/
|
||||
private Integer MaxBitrate = null;
|
||||
|
||||
public final Integer getMaxBitrate() {
|
||||
return MaxBitrate;
|
||||
}
|
||||
|
||||
public final void setMaxBitrate(Integer value) {
|
||||
MaxBitrate = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the context.
|
||||
*
|
||||
* <value>The context.</value>
|
||||
*/
|
||||
private EncodingContext Context = EncodingContext.values()[0];
|
||||
|
||||
public final EncodingContext getContext() {
|
||||
return Context;
|
||||
}
|
||||
|
||||
public final void setContext(EncodingContext value) {
|
||||
Context = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.jellyfin.androidtv.data.compat
|
||||
|
||||
import org.jellyfin.apiclient.model.dlna.DeviceProfile
|
||||
import org.jellyfin.apiclient.model.dlna.EncodingContext
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
|
||||
open class AudioOptions {
|
||||
var enableDirectPlay = true
|
||||
var enableDirectStream = true
|
||||
var itemId: String? = null
|
||||
var mediaSources: List<MediaSourceInfo>? = null
|
||||
var profile: DeviceProfile? = null
|
||||
|
||||
/**
|
||||
* Optional. Only needed if a specific AudioStreamIndex or SubtitleStreamIndex are requested.
|
||||
*/
|
||||
var mediaSourceId: String? = null
|
||||
|
||||
/**
|
||||
* Allows an override of supported number of audio channels
|
||||
* Example: DeviceProfile supports five channel, but user only has stereo speakers
|
||||
*/
|
||||
var maxAudioChannels: Int? = null
|
||||
|
||||
/**
|
||||
* The application's configured quality setting
|
||||
*/
|
||||
var maxBitrate: Int? = null
|
||||
|
||||
var context = EncodingContext.Streaming
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
import org.jellyfin.apiclient.model.dlna.PlaybackErrorCode;
|
||||
|
||||
@Deprecated
|
||||
public class PlaybackException extends RuntimeException {
|
||||
private PlaybackErrorCode ErrorCode = PlaybackErrorCode.values()[0];
|
||||
|
||||
public final PlaybackErrorCode getErrorCode() {
|
||||
return ErrorCode;
|
||||
}
|
||||
|
||||
public final void setErrorCode(PlaybackErrorCode value) {
|
||||
ErrorCode = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.jellyfin.androidtv.data.compat
|
||||
|
||||
import org.jellyfin.apiclient.model.dlna.PlaybackErrorCode
|
||||
|
||||
class PlaybackException : RuntimeException() {
|
||||
var errorCode = PlaybackErrorCode.NotAllowed
|
||||
}
|
||||
@@ -6,12 +6,11 @@ import org.jellyfin.apiclient.model.dlna.SubtitleProfile;
|
||||
import org.jellyfin.apiclient.model.entities.MediaStream;
|
||||
import org.jellyfin.apiclient.model.session.PlayMethod;
|
||||
|
||||
@Deprecated
|
||||
public class StreamBuilder
|
||||
{
|
||||
public static SubtitleProfile GetSubtitleProfile(MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod)
|
||||
public static SubtitleProfile getSubtitleProfile(org.jellyfin.sdk.model.api.MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod)
|
||||
{
|
||||
if (playMethod != PlayMethod.Transcode && !subtitleStream.getIsExternal())
|
||||
if (playMethod != PlayMethod.Transcode && !subtitleStream.isExternal())
|
||||
{
|
||||
// Look for supported embedded subs
|
||||
for (SubtitleProfile profile : subtitleProfiles)
|
||||
@@ -26,7 +25,7 @@ public class StreamBuilder
|
||||
continue;
|
||||
}
|
||||
|
||||
if (subtitleStream.getIsTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat()) && Utils.equalsIgnoreCase(profile.getFormat(), subtitleStream.getCodec()))
|
||||
if (subtitleStream.isTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat()) && Utils.equalsIgnoreCase(profile.getFormat(), subtitleStream.getCodec()))
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
@@ -37,12 +36,12 @@ public class StreamBuilder
|
||||
SubtitleProfile tempVar = new SubtitleProfile();
|
||||
tempVar.setMethod(SubtitleDeliveryMethod.Encode);
|
||||
tempVar.setFormat(subtitleStream.getCodec());
|
||||
SubtitleProfile tempVar2 = GetExternalSubtitleProfile(subtitleStream, subtitleProfiles, playMethod, false);
|
||||
SubtitleProfile tempVar3 = GetExternalSubtitleProfile(subtitleStream, subtitleProfiles, playMethod, true);
|
||||
SubtitleProfile tempVar2 = getExternalSubtitleProfile(subtitleStream, subtitleProfiles, playMethod, false);
|
||||
SubtitleProfile tempVar3 = getExternalSubtitleProfile(subtitleStream, subtitleProfiles, playMethod, true);
|
||||
return (tempVar2 != null) ? tempVar2 : (tempVar3 != null) ? tempVar3 : tempVar;
|
||||
}
|
||||
|
||||
private static SubtitleProfile GetExternalSubtitleProfile(MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod, boolean allowConversion)
|
||||
private static SubtitleProfile getExternalSubtitleProfile(org.jellyfin.sdk.model.api.MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod, boolean allowConversion)
|
||||
{
|
||||
for (SubtitleProfile profile : subtitleProfiles)
|
||||
{
|
||||
@@ -61,7 +60,7 @@ public class StreamBuilder
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((profile.getMethod() == SubtitleDeliveryMethod.External && subtitleStream.getIsTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat())) || (profile.getMethod() == SubtitleDeliveryMethod.Hls && subtitleStream.getIsTextSubtitleStream()))
|
||||
if ((profile.getMethod() == SubtitleDeliveryMethod.External && subtitleStream.isTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat())) || (profile.getMethod() == SubtitleDeliveryMethod.Hls && subtitleStream.isTextSubtitleStream()))
|
||||
{
|
||||
boolean requiresConversion = !Utils.equalsIgnoreCase(subtitleStream.getCodec(), profile.getFormat());
|
||||
|
||||
@@ -75,7 +74,7 @@ public class StreamBuilder
|
||||
continue;
|
||||
}
|
||||
|
||||
if (subtitleStream.getIsTextSubtitleStream() && subtitleStream.getSupportsExternalStream() && subtitleStream.SupportsSubtitleConversionTo(profile.getFormat()))
|
||||
if (subtitleStream.isTextSubtitleStream() && subtitleStream.getSupportsExternalStream() && supportsSubtitleConversionTo(subtitleStream, profile.getFormat()))
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
@@ -84,4 +83,15 @@ public class StreamBuilder
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static boolean supportsSubtitleConversionTo(org.jellyfin.sdk.model.api.MediaStream mediaStream, String codec)
|
||||
{
|
||||
if (!mediaStream.isTextSubtitleStream())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Can't convert from this
|
||||
return !("ass".equalsIgnoreCase(mediaStream.getCodec()) || "ssa".equalsIgnoreCase(mediaStream.getCodec()) || "ass".equalsIgnoreCase(codec) || "ssa".equalsIgnoreCase(codec));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,21 +7,15 @@ import org.jellyfin.apiclient.model.dlna.EncodingContext;
|
||||
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod;
|
||||
import org.jellyfin.apiclient.model.dlna.SubtitleProfile;
|
||||
import org.jellyfin.apiclient.model.dlna.TranscodeSeekInfo;
|
||||
import org.jellyfin.apiclient.model.dto.MediaSourceInfo;
|
||||
import org.jellyfin.apiclient.model.dto.NameValuePair;
|
||||
import org.jellyfin.apiclient.model.entities.MediaStream;
|
||||
import org.jellyfin.apiclient.model.entities.MediaStreamType;
|
||||
import org.jellyfin.apiclient.model.mediainfo.MediaProtocol;
|
||||
import org.jellyfin.apiclient.model.session.PlayMethod;
|
||||
import org.jellyfin.sdk.model.api.MediaProtocol;
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo;
|
||||
import org.jellyfin.sdk.model.api.MediaStream;
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
/**
|
||||
* Class StreamInfo.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
@Deprecated
|
||||
public class StreamInfo {
|
||||
private static final String START_TIME_TICKS = "StartTimeTicks";
|
||||
private static final String SUBTITLE_STREAM_INDEX = "SubtitleStreamIndex";
|
||||
@@ -181,7 +175,7 @@ public class StreamInfo {
|
||||
return getPlayMethod() == PlayMethod.DirectStream || getPlayMethod() == PlayMethod.DirectPlay;
|
||||
}
|
||||
|
||||
public final String ToUrl(String baseUrl, String accessToken) {
|
||||
public final String toUrl(String baseUrl, String accessToken) {
|
||||
if (!Utils.isEmpty(getMediaUrl())) {
|
||||
return getMediaUrl();
|
||||
}
|
||||
@@ -195,7 +189,7 @@ public class StreamInfo {
|
||||
}
|
||||
|
||||
ArrayList<String> list = new ArrayList<String>();
|
||||
for (NameValuePair pair : BuildParams(this, accessToken, false)) {
|
||||
for (NameValuePair pair : buildParams(this, accessToken, false)) {
|
||||
if (Utils.isEmpty(pair.getValue())) {
|
||||
continue;
|
||||
}
|
||||
@@ -216,10 +210,10 @@ public class StreamInfo {
|
||||
|
||||
String queryString = Utils.join("&", list);
|
||||
|
||||
return GetUrl(baseUrl, queryString);
|
||||
return getUrl(baseUrl, queryString);
|
||||
}
|
||||
|
||||
private String GetUrl(String baseUrl, String queryString) {
|
||||
private String getUrl(String baseUrl, String queryString) {
|
||||
if (Utils.isEmpty(baseUrl)) {
|
||||
throw new IllegalArgumentException(baseUrl);
|
||||
}
|
||||
@@ -236,7 +230,7 @@ public class StreamInfo {
|
||||
return String.format("%1$s/videos/%2$s/stream%3$s?%4$s", baseUrl, getItemId(), extension, queryString);
|
||||
}
|
||||
|
||||
private static ArrayList<NameValuePair> BuildParams(StreamInfo item, String accessToken, boolean isDlna) {
|
||||
private static ArrayList<NameValuePair> buildParams(StreamInfo item, String accessToken, boolean isDlna) {
|
||||
ArrayList<NameValuePair> list = new ArrayList<NameValuePair>();
|
||||
|
||||
String audioCodecs = item.getAudioCodecs().length == 0 ? "" : Utils.join(",", item.getAudioCodecs());
|
||||
@@ -293,20 +287,22 @@ public class StreamInfo {
|
||||
return list;
|
||||
}
|
||||
|
||||
public final ArrayList<SubtitleStreamInfo> GetSubtitleProfiles(boolean includeSelectedTrackOnly, String baseUrl, String accessToken) {
|
||||
return GetSubtitleProfiles(includeSelectedTrackOnly, false, baseUrl, accessToken);
|
||||
public final ArrayList<SubtitleStreamInfo> getSubtitleProfiles(boolean includeSelectedTrackOnly, String baseUrl, String accessToken) {
|
||||
return getSubtitleProfiles(includeSelectedTrackOnly, false, baseUrl, accessToken);
|
||||
}
|
||||
|
||||
public final ArrayList<SubtitleStreamInfo> GetSubtitleProfiles(boolean includeSelectedTrackOnly, boolean enableAllProfiles, String baseUrl, String accessToken) {
|
||||
public final ArrayList<SubtitleStreamInfo> getSubtitleProfiles(boolean includeSelectedTrackOnly, boolean enableAllProfiles, String baseUrl, String accessToken) {
|
||||
ArrayList<SubtitleStreamInfo> list = new ArrayList<SubtitleStreamInfo>();
|
||||
|
||||
// HLS will preserve timestamps so we can just grab the full subtitle stream
|
||||
long startPositionTicks = getPlayMethod() == PlayMethod.Transcode ? getStartPositionTicks() : 0;
|
||||
|
||||
if (!includeSelectedTrackOnly) {
|
||||
for (MediaStream stream : getMediaSource().getMediaStreams()) {
|
||||
if (stream.getType() == MediaStreamType.Subtitle) {
|
||||
AddSubtitleProfiles(list, stream, enableAllProfiles, baseUrl, accessToken, startPositionTicks);
|
||||
if (getMediaSource() == null) return list;
|
||||
|
||||
for (org.jellyfin.sdk.model.api.MediaStream stream : getMediaSource().getMediaStreams()) {
|
||||
if (stream.getType() == org.jellyfin.sdk.model.api.MediaStreamType.SUBTITLE) {
|
||||
addSubtitleProfiles(list, stream, enableAllProfiles, baseUrl, accessToken, startPositionTicks);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,22 +310,22 @@ public class StreamInfo {
|
||||
return list;
|
||||
}
|
||||
|
||||
private void AddSubtitleProfiles(ArrayList<SubtitleStreamInfo> list, MediaStream stream, boolean enableAllProfiles, String baseUrl, String accessToken, long startPositionTicks) {
|
||||
private void addSubtitleProfiles(ArrayList<SubtitleStreamInfo> list, org.jellyfin.sdk.model.api.MediaStream stream, boolean enableAllProfiles, String baseUrl, String accessToken, long startPositionTicks) {
|
||||
if (enableAllProfiles) {
|
||||
for (SubtitleProfile profile : getDeviceProfile().getSubtitleProfiles()) {
|
||||
SubtitleStreamInfo info = GetSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, new SubtitleProfile[]{profile});
|
||||
SubtitleStreamInfo info = getSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, new SubtitleProfile[]{profile});
|
||||
|
||||
list.add(info);
|
||||
}
|
||||
} else {
|
||||
SubtitleStreamInfo info = GetSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, getDeviceProfile().getSubtitleProfiles());
|
||||
SubtitleStreamInfo info = getSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, getDeviceProfile().getSubtitleProfiles());
|
||||
|
||||
list.add(info);
|
||||
}
|
||||
}
|
||||
|
||||
private SubtitleStreamInfo GetSubtitleStreamInfo(MediaStream stream, String baseUrl, String accessToken, long startPositionTicks, SubtitleProfile[] subtitleProfiles) {
|
||||
SubtitleProfile subtitleProfile = StreamBuilder.GetSubtitleProfile(stream, subtitleProfiles, getPlayMethod());
|
||||
private SubtitleStreamInfo getSubtitleStreamInfo(org.jellyfin.sdk.model.api.MediaStream stream, String baseUrl, String accessToken, long startPositionTicks, SubtitleProfile[] subtitleProfiles) {
|
||||
SubtitleProfile subtitleProfile = StreamBuilder.getSubtitleProfile(stream, subtitleProfiles, getPlayMethod());
|
||||
SubtitleStreamInfo tempVar = new SubtitleStreamInfo();
|
||||
String tempVar2 = stream.getLanguage();
|
||||
tempVar.setName((tempVar2 != null) ? tempVar2 : "Unknown");
|
||||
@@ -340,7 +336,7 @@ public class StreamInfo {
|
||||
SubtitleStreamInfo info = tempVar;
|
||||
|
||||
if (info.getDeliveryMethod() == SubtitleDeliveryMethod.External) {
|
||||
if (getMediaSource().getProtocol() == MediaProtocol.File || !stream.getCodec().equalsIgnoreCase(subtitleProfile.getFormat())) {
|
||||
if (getMediaSource().getProtocol() == MediaProtocol.FILE || !stream.getCodec().equalsIgnoreCase(subtitleProfile.getFormat())) {
|
||||
info.setUrl(String.format("%1$s/Videos/%2$s/%3$s/Subtitles/%4$s/%5$s/Stream.%6$s", baseUrl, getItemId(), getMediaSourceId(), String.valueOf(stream.getIndex()), String.valueOf(startPositionTicks), subtitleProfile.getFormat()));
|
||||
|
||||
if (!Utils.isEmpty(accessToken)) {
|
||||
@@ -354,14 +350,14 @@ public class StreamInfo {
|
||||
return info;
|
||||
}
|
||||
|
||||
public final ArrayList<MediaStream> GetSelectableAudioStreams() {
|
||||
return GetSelectableStreams(MediaStreamType.Audio);
|
||||
public final ArrayList<MediaStream> getSelectableAudioStreams() {
|
||||
return getSelectableStreams(MediaStreamType.AUDIO);
|
||||
}
|
||||
|
||||
public final ArrayList<MediaStream> GetSelectableStreams(MediaStreamType type) {
|
||||
ArrayList<MediaStream> list = new ArrayList<MediaStream>();
|
||||
public final ArrayList<org.jellyfin.sdk.model.api.MediaStream> getSelectableStreams(MediaStreamType type) {
|
||||
ArrayList<org.jellyfin.sdk.model.api.MediaStream> list = new ArrayList<org.jellyfin.sdk.model.api.MediaStream>();
|
||||
|
||||
for (MediaStream stream : getMediaSource().getMediaStreams()) {
|
||||
for (org.jellyfin.sdk.model.api.MediaStream stream : getMediaSource().getMediaStreams()) {
|
||||
if (type == stream.getType()) {
|
||||
list.add(stream);
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod;
|
||||
|
||||
@Deprecated
|
||||
public class SubtitleStreamInfo {
|
||||
private String Url;
|
||||
|
||||
public final String getUrl() {
|
||||
return Url;
|
||||
}
|
||||
|
||||
public final void setUrl(String value) {
|
||||
Url = value;
|
||||
}
|
||||
|
||||
private String Name;
|
||||
|
||||
public final String getName() {
|
||||
return Name;
|
||||
}
|
||||
|
||||
public final void setName(String value) {
|
||||
Name = value;
|
||||
}
|
||||
|
||||
private String Format;
|
||||
|
||||
public final String getFormat() {
|
||||
return Format;
|
||||
}
|
||||
|
||||
public final void setFormat(String value) {
|
||||
Format = value;
|
||||
}
|
||||
|
||||
private String DisplayTitle;
|
||||
|
||||
public final String getDisplayTitle() {
|
||||
return DisplayTitle;
|
||||
}
|
||||
|
||||
public final void setDisplayTitle(String value) {
|
||||
DisplayTitle = value;
|
||||
}
|
||||
|
||||
private int Index;
|
||||
|
||||
public final int getIndex() {
|
||||
return Index;
|
||||
}
|
||||
|
||||
public final void setIndex(int value) {
|
||||
Index = value;
|
||||
}
|
||||
|
||||
private SubtitleDeliveryMethod DeliveryMethod = SubtitleDeliveryMethod.values()[0];
|
||||
|
||||
public final SubtitleDeliveryMethod getDeliveryMethod() {
|
||||
return DeliveryMethod;
|
||||
}
|
||||
|
||||
public final void setDeliveryMethod(SubtitleDeliveryMethod value) {
|
||||
DeliveryMethod = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.jellyfin.androidtv.data.compat
|
||||
|
||||
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod
|
||||
|
||||
class SubtitleStreamInfo {
|
||||
var url: String? = null
|
||||
var name: String? = null
|
||||
var format: String? = null
|
||||
var displayTitle: String? = null
|
||||
var index = 0
|
||||
var deliveryMethod = SubtitleDeliveryMethod.Encode
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
/**
|
||||
* Class VideoOptions.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
@Deprecated
|
||||
public class VideoOptions extends AudioOptions {
|
||||
private Integer AudioStreamIndex;
|
||||
private Integer SubtitleStreamIndex;
|
||||
|
||||
public final Integer getAudioStreamIndex() {
|
||||
return AudioStreamIndex;
|
||||
}
|
||||
|
||||
public final void setAudioStreamIndex(Integer value) {
|
||||
AudioStreamIndex = value;
|
||||
}
|
||||
|
||||
public final Integer getSubtitleStreamIndex() {
|
||||
return SubtitleStreamIndex;
|
||||
}
|
||||
|
||||
public final void setSubtitleStreamIndex(Integer value) {
|
||||
SubtitleStreamIndex = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.jellyfin.androidtv.data.compat
|
||||
|
||||
class VideoOptions : AudioOptions() {
|
||||
var audioStreamIndex: Int? = null
|
||||
var subtitleStreamIndex: Int? = null
|
||||
}
|
||||
@@ -7,10 +7,12 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.jellyfin.androidtv.data.model.DataRefreshService
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
|
||||
import org.jellyfin.androidtv.ui.navigation.Destinations
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
|
||||
import org.jellyfin.androidtv.ui.playback.MediaManager
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
|
||||
import org.jellyfin.androidtv.util.apiclient.PlaybackHelper
|
||||
import org.jellyfin.apiclient.model.entities.MediaType
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.sessionApi
|
||||
@@ -19,9 +21,11 @@ import org.jellyfin.sdk.api.sockets.SocketInstance
|
||||
import org.jellyfin.sdk.api.sockets.SocketInstanceState
|
||||
import org.jellyfin.sdk.api.sockets.addGeneralCommandsListener
|
||||
import org.jellyfin.sdk.api.sockets.addListener
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.GeneralCommandType
|
||||
import org.jellyfin.sdk.model.api.LibraryUpdateInfo
|
||||
import org.jellyfin.sdk.model.api.PlaystateCommand
|
||||
import org.jellyfin.sdk.model.constant.MediaType
|
||||
import org.jellyfin.sdk.model.extensions.getValue
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import org.jellyfin.sdk.model.socket.LibraryChangedMessage
|
||||
@@ -33,12 +37,13 @@ import java.util.UUID
|
||||
class SocketHandler(
|
||||
private val context: Context,
|
||||
private val api: ApiClient,
|
||||
private val socketInstance: SocketInstance,
|
||||
private val dataRefreshService: DataRefreshService,
|
||||
private val mediaManager: MediaManager,
|
||||
private val playbackControllerContainer: PlaybackControllerContainer,
|
||||
private val navigationRepository: NavigationRepository,
|
||||
) {
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.IO)
|
||||
private var socketInstance: SocketInstance = createInstance()
|
||||
val state = socketInstance.state
|
||||
|
||||
suspend fun updateSession() {
|
||||
@@ -49,6 +54,7 @@ class SocketHandler(
|
||||
supportedCommands = listOf(
|
||||
GeneralCommandType.DISPLAY_MESSAGE,
|
||||
GeneralCommandType.SEND_STRING,
|
||||
GeneralCommandType.DISPLAY_CONTENT,
|
||||
),
|
||||
)
|
||||
} catch (err: ApiClientException) {
|
||||
@@ -61,27 +67,32 @@ class SocketHandler(
|
||||
else socketInstance.updateCredentials()
|
||||
}
|
||||
|
||||
private fun createInstance() = api.ws().apply {
|
||||
// Library
|
||||
addListener<LibraryChangedMessage> { message -> onLibraryChanged(message.info) }
|
||||
init {
|
||||
socketInstance.apply {
|
||||
// Library
|
||||
addListener<LibraryChangedMessage> { message -> onLibraryChanged(message.info) }
|
||||
|
||||
// Media playback
|
||||
addListener<PlayMessage> { message -> onPlayMessage(message) }
|
||||
addListener<PlayStateMessage> { message -> onPlayStateMessage(message) }
|
||||
// Media playback
|
||||
addListener<PlayMessage> { message -> onPlayMessage(message) }
|
||||
addListener<PlayStateMessage> { message -> onPlayStateMessage(message) }
|
||||
|
||||
// General commands
|
||||
addGeneralCommandsListener(setOf(GeneralCommandType.DISPLAY_CONTENT)) { message ->
|
||||
val itemId by message
|
||||
val itemUuid = itemId?.toUUIDOrNull()
|
||||
// General commands
|
||||
addGeneralCommandsListener(setOf(GeneralCommandType.DISPLAY_CONTENT)) { message ->
|
||||
val itemId by message
|
||||
val itemType by message
|
||||
|
||||
if (itemUuid != null) onDisplayContent(itemUuid)
|
||||
}
|
||||
addGeneralCommandsListener(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING)) { message ->
|
||||
val header by message
|
||||
val text by message
|
||||
val string by message
|
||||
val itemUuid = itemId?.toUUIDOrNull()
|
||||
val itemKind = itemType?.let { type -> BaseItemKind.values().find { value -> value.serialName.equals(type, true) } }
|
||||
|
||||
onDisplayMessage(header, text ?: string)
|
||||
if (itemUuid != null && itemKind != null) onDisplayContent(itemUuid, itemKind)
|
||||
}
|
||||
addGeneralCommandsListener(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING)) { message ->
|
||||
val header by message
|
||||
val text by message
|
||||
val string by message
|
||||
|
||||
onDisplayMessage(header, text ?: string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,16 +141,12 @@ class SocketHandler(
|
||||
PlaystateCommand.SEEK -> playbackController?.seek(
|
||||
(message.request.seekPositionTicks ?: 0) / TICKS_TO_MS
|
||||
)
|
||||
// FIXME get rewind/forward amount from displayprefs
|
||||
PlaystateCommand.REWIND -> playbackController?.skip(REWIND_MS)
|
||||
PlaystateCommand.FAST_FORWARD -> playbackController?.skip(FORWARD_MS)
|
||||
PlaystateCommand.REWIND -> playbackController?.rewind()
|
||||
PlaystateCommand.FAST_FORWARD -> playbackController?.fastForward()
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: Add an ItemLauncher function that accepts SDK BaseItemDto
|
||||
// Add "GeneralCommandType.DISPLAY_CONTENT" to supportedCommands in capabilities to receive
|
||||
// these messages. Otherwise this function is never called.
|
||||
private fun onDisplayContent(itemId: UUID) {
|
||||
private fun onDisplayContent(itemId: UUID, itemKind: BaseItemKind) {
|
||||
val playbackController = playbackControllerContainer.playbackController
|
||||
|
||||
if (playbackController?.isPlaying == true || playbackController?.isPaused == true) {
|
||||
@@ -149,9 +156,13 @@ class SocketHandler(
|
||||
|
||||
Timber.i("Launching $itemId")
|
||||
|
||||
coroutineScope.launch {
|
||||
val item by api.userLibraryApi.getItem(itemId = itemId)
|
||||
// ItemLauncher.launch(item)
|
||||
when (itemKind) {
|
||||
BaseItemKind.USER_VIEW,
|
||||
BaseItemKind.COLLECTION_FOLDER -> coroutineScope.launch {
|
||||
val item by api.userLibraryApi.getItem(itemId = itemId)
|
||||
ItemLauncher.launchUserView(item)
|
||||
}
|
||||
else -> navigationRepository.navigate(Destinations.itemDetails(itemId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +179,5 @@ class SocketHandler(
|
||||
|
||||
companion object {
|
||||
const val TICKS_TO_MS = 10000L
|
||||
const val REWIND_MS = -11000
|
||||
const val FORWARD_MS = 30000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,6 @@ package org.jellyfin.androidtv.data.model
|
||||
|
||||
data class AppNotification(
|
||||
val message: String,
|
||||
val dismiss: () -> Unit,
|
||||
val public: Boolean,
|
||||
)
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.model;
|
||||
|
||||
import org.jellyfin.apiclient.model.entities.ChapterInfo;
|
||||
|
||||
public class ChapterItemInfo extends ChapterInfo {
|
||||
private String itemId;
|
||||
|
||||
public String getItemId() {
|
||||
return itemId;
|
||||
}
|
||||
|
||||
public void setItemId(String itemId) {
|
||||
this.itemId = itemId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.jellyfin.androidtv.data.model
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class ChapterItemInfo(
|
||||
val itemId: UUID,
|
||||
val name: String?,
|
||||
val startPositionTicks: Long,
|
||||
val imagePath: String?,
|
||||
)
|
||||
@@ -1,37 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.model;
|
||||
|
||||
import org.jellyfin.apiclient.model.querying.ItemFilter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class FilterOptions {
|
||||
private boolean favoriteOnly;
|
||||
private boolean unwatchedOnly;
|
||||
|
||||
public void setFavoriteOnly(boolean value) {
|
||||
favoriteOnly = value;
|
||||
}
|
||||
|
||||
public void setUnwatchedOnly(boolean value) {
|
||||
unwatchedOnly = value;
|
||||
}
|
||||
|
||||
public boolean isUnwatchedOnly() {
|
||||
return unwatchedOnly;
|
||||
}
|
||||
|
||||
public boolean isFavoriteOnly() {
|
||||
return favoriteOnly;
|
||||
}
|
||||
|
||||
public ItemFilter[] getFilters() {
|
||||
if (!unwatchedOnly && !favoriteOnly) return null;
|
||||
|
||||
List<ItemFilter> filters = new ArrayList<>();
|
||||
if (favoriteOnly) filters.add(ItemFilter.IsFavorite);
|
||||
if (unwatchedOnly) filters.add(ItemFilter.IsUnplayed);
|
||||
|
||||
return filters.toArray(new ItemFilter[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.jellyfin.androidtv.data.model
|
||||
|
||||
import org.jellyfin.apiclient.model.querying.ItemFilter
|
||||
|
||||
class FilterOptions {
|
||||
var isFavoriteOnly = false
|
||||
var isUnwatchedOnly = false
|
||||
|
||||
val filters: Array<ItemFilter>?
|
||||
get() {
|
||||
if (!isUnwatchedOnly && !isFavoriteOnly) return null
|
||||
|
||||
return buildList {
|
||||
if (isFavoriteOnly) add(ItemFilter.IsFavorite)
|
||||
if (isUnwatchedOnly) add(ItemFilter.IsUnplayed)
|
||||
}.toTypedArray()
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.model;
|
||||
|
||||
public class InfoItem {
|
||||
private String label;
|
||||
private String value;
|
||||
|
||||
public InfoItem() {
|
||||
this("", "");
|
||||
}
|
||||
|
||||
public InfoItem(String label, String value) {
|
||||
this.label = label;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.jellyfin.androidtv.data.model
|
||||
|
||||
data class InfoItem @JvmOverloads constructor(
|
||||
val label: String = "",
|
||||
val value: String = "",
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.querying;
|
||||
|
||||
public class SpecialsQuery {
|
||||
private String ItemId;
|
||||
|
||||
public SpecialsQuery(String itemId) {
|
||||
ItemId = itemId;
|
||||
}
|
||||
|
||||
public String getItemId() {
|
||||
return ItemId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.jellyfin.androidtv.data.querying
|
||||
|
||||
data class SpecialsQuery(
|
||||
val itemId: String,
|
||||
)
|
||||
@@ -1,26 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.querying;
|
||||
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository;
|
||||
import org.jellyfin.apiclient.model.querying.ItemFields;
|
||||
import org.jellyfin.apiclient.model.querying.ItemQuery;
|
||||
import org.koin.java.KoinJavaComponent;
|
||||
|
||||
public class StdItemQuery extends ItemQuery {
|
||||
public StdItemQuery(ItemFields[] fields) {
|
||||
if (fields == null) {
|
||||
fields = new ItemFields[]{
|
||||
ItemFields.PrimaryImageAspectRatio,
|
||||
ItemFields.Overview,
|
||||
ItemFields.ItemCounts,
|
||||
ItemFields.DisplayPreferencesId,
|
||||
ItemFields.ChildCount
|
||||
};
|
||||
}
|
||||
setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
|
||||
setFields(fields);
|
||||
}
|
||||
|
||||
public StdItemQuery() {
|
||||
this(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.jellyfin.androidtv.data.querying
|
||||
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
import org.jellyfin.apiclient.model.querying.ItemFields
|
||||
import org.jellyfin.apiclient.model.querying.ItemQuery
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
|
||||
class StdItemQuery @JvmOverloads constructor(
|
||||
fields: Array<ItemFields> = defaultFields
|
||||
) : ItemQuery(), KoinComponent {
|
||||
companion object {
|
||||
val defaultFields = arrayOf(
|
||||
ItemFields.PrimaryImageAspectRatio,
|
||||
ItemFields.Overview,
|
||||
ItemFields.ItemCounts,
|
||||
ItemFields.DisplayPreferencesId,
|
||||
ItemFields.ChildCount
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
userId = get<UserRepository>().currentUser.value!!.id.toString()
|
||||
setFields(fields)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.querying;
|
||||
|
||||
public class TrailersQuery {
|
||||
private String ItemId;
|
||||
|
||||
public TrailersQuery(String itemId) {
|
||||
ItemId = itemId;
|
||||
}
|
||||
|
||||
public String getItemId() {
|
||||
return ItemId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.jellyfin.androidtv.data.querying
|
||||
|
||||
data class TrailersQuery(
|
||||
val itemId: String,
|
||||
)
|
||||
@@ -1,5 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.querying;
|
||||
|
||||
public class ViewQuery {
|
||||
//Dummy indicator class
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package org.jellyfin.androidtv.data.querying
|
||||
|
||||
object ViewQuery
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.jellyfin.androidtv.data.repository
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.jellyfin.androidtv.constant.CustomMessage
|
||||
|
||||
interface CustomMessageRepository {
|
||||
val message: StateFlow<CustomMessage?>
|
||||
fun pushMessage(message: CustomMessage)
|
||||
}
|
||||
|
||||
class CustomMessageRepositoryImpl : CustomMessageRepository {
|
||||
private val _message = MutableStateFlow<CustomMessage?>(null)
|
||||
override val message get() = _message.asStateFlow()
|
||||
|
||||
override fun pushMessage(message: CustomMessage) {
|
||||
// Make sure to re-emit the same message if requested
|
||||
if (_message.value == message) _message.value = null
|
||||
|
||||
_message.value = message
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.jellyfin.androidtv.data.repository
|
||||
|
||||
import org.jellyfin.androidtv.data.model.DataRefreshService
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.UserItemDataDto
|
||||
|
||||
interface ItemMutationRepository {
|
||||
suspend fun setFavorite(item: UUID, favorite: Boolean): UserItemDataDto
|
||||
suspend fun setPlayed(item: UUID, played: Boolean): UserItemDataDto
|
||||
}
|
||||
|
||||
class ItemMutationRepositoryImpl(
|
||||
private val api: ApiClient,
|
||||
private val dataRefreshService: DataRefreshService,
|
||||
) : ItemMutationRepository {
|
||||
override suspend fun setFavorite(item: UUID, favorite: Boolean): UserItemDataDto {
|
||||
val response by when {
|
||||
favorite -> api.userLibraryApi.markFavoriteItem(itemId = item)
|
||||
else -> api.userLibraryApi.unmarkFavoriteItem(itemId = item)
|
||||
}
|
||||
|
||||
dataRefreshService.lastFavoriteUpdate = System.currentTimeMillis()
|
||||
return response
|
||||
}
|
||||
|
||||
override suspend fun setPlayed(item: UUID, played: Boolean): UserItemDataDto {
|
||||
val response by when {
|
||||
played -> api.playStateApi.markPlayedItem(itemId = item)
|
||||
else -> api.playStateApi.markUnplayedItem(itemId = item)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.data.model.AppNotification
|
||||
import org.jellyfin.androidtv.preference.SystemPreferences
|
||||
|
||||
interface NotificationsRepository {
|
||||
val notifications: StateFlow<List<AppNotification>>
|
||||
@@ -18,19 +20,22 @@ interface NotificationsRepository {
|
||||
class NotificationsRepositoryImpl(
|
||||
private val context: Context,
|
||||
private val uiModeManager: UiModeManager,
|
||||
private val systemPreferences: SystemPreferences,
|
||||
) : NotificationsRepository {
|
||||
override val notifications = MutableStateFlow(emptyList<AppNotification>())
|
||||
|
||||
override fun dismissNotification(item: AppNotification) {
|
||||
notifications.value = notifications.value.filter { it != item }
|
||||
item.dismiss()
|
||||
}
|
||||
|
||||
override fun addDefaultNotifications() {
|
||||
addUiModeNotification()
|
||||
addBetaNotification()
|
||||
}
|
||||
|
||||
private fun addNotification(message: String) {
|
||||
notifications.value = notifications.value + AppNotification(message)
|
||||
private fun addNotification(message: String, public: Boolean = false, dismiss: () -> Unit = {}) {
|
||||
notifications.value = notifications.value + AppNotification(message, dismiss, public)
|
||||
}
|
||||
|
||||
private fun addUiModeNotification() {
|
||||
@@ -40,7 +45,19 @@ class NotificationsRepositoryImpl(
|
||||
val hasHdmiCec = context.packageManager.hasSystemFeature("android.hardware.hdmi.cec")
|
||||
|
||||
if (invalidUiMode && isTouch && !hasHdmiCec) {
|
||||
addNotification(context.getString(R.string.app_notification_uimode_invalid))
|
||||
addNotification(context.getString(R.string.app_notification_uimode_invalid), public = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addBetaNotification() {
|
||||
val dismissedVersion = systemPreferences[SystemPreferences.dismissedBetaNotificationVersion]
|
||||
val currentVersion = BuildConfig.VERSION_NAME
|
||||
val isBeta = currentVersion.lowercase().contains("beta")
|
||||
|
||||
if (isBeta && currentVersion != dismissedVersion) {
|
||||
addNotification(context.getString(R.string.app_notification_beta, currentVersion)) {
|
||||
systemPreferences[SystemPreferences.dismissedBetaNotificationVersion] = currentVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,17 @@ package org.jellyfin.androidtv.data.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import org.jellyfin.apiclient.model.entities.CollectionType
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.constant.CollectionType
|
||||
|
||||
interface UserViewsRepository {
|
||||
val views: Flow<Collection<BaseItemDto>>
|
||||
|
||||
fun isSupported(collectionType: String?): Boolean
|
||||
fun allowViewSelection(collectionType: String?): Boolean
|
||||
fun allowGridView(collectionType: String?): Boolean
|
||||
}
|
||||
|
||||
class UserViewsRepositoryImpl(
|
||||
@@ -26,13 +27,24 @@ class UserViewsRepositoryImpl(
|
||||
}
|
||||
|
||||
override fun isSupported(collectionType: String?) = collectionType !in unsupportedCollectionTypes
|
||||
override fun allowViewSelection(collectionType: String?) = collectionType != CollectionType.Music
|
||||
override fun allowViewSelection(collectionType: String?) = collectionType !in disallowViewSelectionCollectionTypes
|
||||
override fun allowGridView(collectionType: String?) = collectionType !in disallowGridViewCollectionTypes
|
||||
|
||||
private companion object {
|
||||
private val unsupportedCollectionTypes = arrayOf(
|
||||
CollectionType.Books,
|
||||
CollectionType.Games,
|
||||
CollectionType.Folders
|
||||
)
|
||||
|
||||
private val disallowViewSelectionCollectionTypes = arrayOf(
|
||||
CollectionType.LiveTv,
|
||||
CollectionType.Music,
|
||||
CollectionType.Photos,
|
||||
)
|
||||
|
||||
private val disallowGridViewCollectionTypes = arrayOf(
|
||||
CollectionType.LiveTv,
|
||||
CollectionType.Music
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,18 +30,16 @@ import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.model.Server
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.util.sdk.compat.asSdk
|
||||
import org.jellyfin.apiclient.model.search.SearchHint
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.SearchHint
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ExecutionException
|
||||
import org.jellyfin.apiclient.model.dto.BaseItemDto as LegacyBaseItemDto
|
||||
|
||||
class BackgroundService(
|
||||
private val context: Context,
|
||||
@@ -145,15 +143,6 @@ class BackgroundService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use all available backdrops from [baseItem] as background.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the SDK instead of the legacy apiclient",
|
||||
ReplaceWith("setBackground(baseItem?.asSdk())", "org.jellyfin.androidtv.util.sdk.compat.asSdk")
|
||||
)
|
||||
fun setBackground(baseItem: LegacyBaseItemDto?) = setBackground(baseItem?.asSdk())
|
||||
|
||||
/**
|
||||
* Use all available backdrops from [baseItem] as background.
|
||||
*/
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package org.jellyfin.androidtv.di
|
||||
|
||||
import android.app.Application
|
||||
import org.jellyfin.androidtv.ui.shared.AppThemeCallbacks
|
||||
import org.jellyfin.androidtv.ui.shared.AuthenticatedUserCallbacks
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
|
||||
val activityLifecycleCallbacksModule = module {
|
||||
single { AuthenticatedUserCallbacks(get()) } bind Application.ActivityLifecycleCallbacks::class
|
||||
single { AppThemeCallbacks(get()) } bind Application.ActivityLifecycleCallbacks::class
|
||||
}
|
||||
@@ -6,11 +6,19 @@ import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepositoryImpl
|
||||
import org.jellyfin.androidtv.data.eventhandling.SocketHandler
|
||||
import org.jellyfin.androidtv.data.model.DataRefreshService
|
||||
import org.jellyfin.androidtv.data.repository.CustomMessageRepository
|
||||
import org.jellyfin.androidtv.data.repository.CustomMessageRepositoryImpl
|
||||
import org.jellyfin.androidtv.data.repository.ItemMutationRepository
|
||||
import org.jellyfin.androidtv.data.repository.ItemMutationRepositoryImpl
|
||||
import org.jellyfin.androidtv.data.repository.NotificationsRepository
|
||||
import org.jellyfin.androidtv.data.repository.NotificationsRepositoryImpl
|
||||
import org.jellyfin.androidtv.data.repository.UserViewsRepository
|
||||
import org.jellyfin.androidtv.data.repository.UserViewsRepositoryImpl
|
||||
import org.jellyfin.androidtv.data.service.BackgroundService
|
||||
import org.jellyfin.androidtv.ui.navigation.Destinations
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepositoryImpl
|
||||
import org.jellyfin.androidtv.ui.picture.PictureViewerViewModel
|
||||
import org.jellyfin.androidtv.ui.playback.MediaManager
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
|
||||
import org.jellyfin.androidtv.ui.playback.nextup.NextUpViewModel
|
||||
@@ -21,10 +29,9 @@ import org.jellyfin.androidtv.util.MarkdownRenderer
|
||||
import org.jellyfin.androidtv.util.sdk.legacy
|
||||
import org.jellyfin.apiclient.AppInfo
|
||||
import org.jellyfin.apiclient.android
|
||||
import org.jellyfin.apiclient.interaction.ApiEventListener
|
||||
import org.jellyfin.apiclient.logging.AndroidLogger
|
||||
import org.jellyfin.apiclient.serialization.GsonJsonSerializer
|
||||
import org.jellyfin.sdk.android.androidDevice
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.createJellyfin
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
@@ -59,11 +66,11 @@ val appModule = module {
|
||||
get<JellyfinSdk>().createApi()
|
||||
}
|
||||
|
||||
single { SocketHandler(get(), get(), get(), get(), get()) }
|
||||
single { get<ApiClient>().ws() }
|
||||
|
||||
single { SocketHandler(get(), get(), get(), get(), get(), get(), get()) }
|
||||
|
||||
// Old apiclient
|
||||
single { GsonJsonSerializer() }
|
||||
|
||||
single {
|
||||
JellyfinApiClient {
|
||||
appInfo = AppInfo("Android TV", BuildConfig.VERSION_NAME)
|
||||
@@ -74,8 +81,7 @@ val appModule = module {
|
||||
|
||||
single {
|
||||
get<JellyfinApiClient>().createApi(
|
||||
device = get<DeviceInfo>(defaultDeviceInfo).legacy(),
|
||||
eventListener = ApiEventListener()
|
||||
device = get<DeviceInfo>(defaultDeviceInfo).legacy()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,12 +93,16 @@ val appModule = module {
|
||||
|
||||
single<UserRepository> { UserRepositoryImpl() }
|
||||
single<UserViewsRepository> { UserViewsRepositoryImpl(get()) }
|
||||
single<NotificationsRepository> { NotificationsRepositoryImpl(get(), get()) }
|
||||
single<NotificationsRepository> { NotificationsRepositoryImpl(get(), get(), get()) }
|
||||
single<ItemMutationRepository> { ItemMutationRepositoryImpl(get(), get()) }
|
||||
single<CustomMessageRepository> { CustomMessageRepositoryImpl() }
|
||||
single<NavigationRepository> { NavigationRepositoryImpl(Destinations.home) }
|
||||
|
||||
viewModel { StartupViewModel(get(), get(), get(), get()) }
|
||||
viewModel { UserLoginViewModel(get(), get(), get()) }
|
||||
viewModel { UserLoginViewModel(get(), get(), get(), get(defaultDeviceInfo)) }
|
||||
viewModel { ServerAddViewModel(get()) }
|
||||
viewModel { NextUpViewModel(get(), get(), get(), get()) }
|
||||
viewModel { PictureViewerViewModel(get()) }
|
||||
|
||||
single { BackgroundService(get(), get(), get(), get()) }
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ class KoinInitializer : Initializer<KoinApplication> {
|
||||
androidContext(context)
|
||||
|
||||
modules(
|
||||
activityLifecycleCallbacksModule,
|
||||
androidModule,
|
||||
appModule,
|
||||
authModule,
|
||||
|
||||
@@ -21,12 +21,12 @@ import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.data.repository.UserViewsRepository
|
||||
import org.jellyfin.androidtv.integration.provider.ImageProvider
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.ui.startup.StartupActivity
|
||||
import org.jellyfin.androidtv.util.ImageUtils
|
||||
import org.jellyfin.androidtv.util.dp
|
||||
import org.jellyfin.androidtv.util.sdk.isUsable
|
||||
import org.jellyfin.apiclient.model.entities.MediaType
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.exception.TimeoutException
|
||||
@@ -39,6 +39,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageFormat
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.constant.MediaType
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import timber.log.Timber
|
||||
@@ -160,7 +161,7 @@ class LeanbackChannelWorker(
|
||||
.filter { userViewsRepository.isSupported(it.collectionType) }
|
||||
.map { item ->
|
||||
val imageUri = if (item.imageTags?.contains(ImageType.PRIMARY) == true)
|
||||
api.imageApi.getItemImageUrl(item.id, ImageType.PRIMARY).toUri()
|
||||
ImageProvider.getImageUri(api.imageApi.getItemImageUrl(item.id, ImageType.PRIMARY))
|
||||
else
|
||||
ImageUtils.getResourceUrl(context, R.drawable.tile_land_tv).toUri()
|
||||
|
||||
@@ -184,35 +185,31 @@ class LeanbackChannelWorker(
|
||||
* Gets the poster art for an item. Uses the [preferParentThumb] parameter to fetch the series
|
||||
* image when preferred.
|
||||
*/
|
||||
private fun BaseItemDto.getPosterArtImageUrl(preferParentThumb: Boolean): Uri {
|
||||
if (type == BaseItemKind.MOVIE) {
|
||||
return api.imageApi.getItemImageUrl(
|
||||
itemId = id,
|
||||
imageType = ImageType.PRIMARY,
|
||||
format = ImageFormat.WEBP,
|
||||
width = 106.dp(context),
|
||||
height = 153.dp(context),
|
||||
).let(Uri::parse)
|
||||
}
|
||||
private fun BaseItemDto.getPosterArtImageUrl(preferParentThumb: Boolean): Uri = when {
|
||||
type == BaseItemKind.MOVIE -> api.imageApi.getItemImageUrl(
|
||||
itemId = id,
|
||||
imageType = ImageType.PRIMARY,
|
||||
format = ImageFormat.WEBP,
|
||||
width = 106.dp(context),
|
||||
height = 153.dp(context),
|
||||
)
|
||||
|
||||
return when {
|
||||
(preferParentThumb || imageTags?.contains(ImageType.PRIMARY) != true)
|
||||
&& parentThumbItemId != null -> api.imageApi.getItemImageUrl(
|
||||
itemId = parentThumbItemId!!,
|
||||
imageType = ImageType.THUMB,
|
||||
format = ImageFormat.WEBP,
|
||||
width = 272.dp(context),
|
||||
height = 153.dp(context),
|
||||
)
|
||||
else -> api.imageApi.getItemImageUrl(
|
||||
itemId = id,
|
||||
imageType = ImageType.PRIMARY,
|
||||
format = ImageFormat.WEBP,
|
||||
width = 272.dp(context),
|
||||
height = 153.dp(context),
|
||||
)
|
||||
}.let(Uri::parse)
|
||||
}
|
||||
(preferParentThumb || imageTags?.contains(ImageType.PRIMARY) != true) && parentThumbItemId != null -> api.imageApi.getItemImageUrl(
|
||||
itemId = parentThumbItemId!!,
|
||||
imageType = ImageType.THUMB,
|
||||
format = ImageFormat.WEBP,
|
||||
width = 272.dp(context),
|
||||
height = 153.dp(context),
|
||||
)
|
||||
|
||||
else -> api.imageApi.getItemImageUrl(
|
||||
itemId = id,
|
||||
imageType = ImageType.PRIMARY,
|
||||
format = ImageFormat.WEBP,
|
||||
width = 272.dp(context),
|
||||
height = 153.dp(context),
|
||||
)
|
||||
}.let(ImageProvider::getImageUri)
|
||||
|
||||
/**
|
||||
* Gets the resume and next up episodes. The returned pair contains two lists:
|
||||
|
||||
@@ -12,6 +12,7 @@ import android.provider.BaseColumns
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.integration.provider.ImageProvider
|
||||
import org.jellyfin.androidtv.util.ImageUtils
|
||||
import org.jellyfin.androidtv.util.sdk.isUsable
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
@@ -102,9 +103,8 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
|
||||
|
||||
MatrixCursor(columns).also { cursor ->
|
||||
searchResult?.items?.forEach { item ->
|
||||
// Note: Image loading might fail if the image is served over HTTP
|
||||
val imageUri = if (item.imageTags?.contains(ImageType.PRIMARY) == true)
|
||||
api.imageApi.getItemImageUrl(item.id, ImageType.PRIMARY)
|
||||
ImageProvider.getImageUri(api.imageApi.getItemImageUrl(item.id, ImageType.PRIMARY))
|
||||
else
|
||||
ImageUtils.getResourceUrl(context, R.drawable.tile_land_tv)
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.databinding.DreamLibraryBinding
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.preference.constant.ClockBehavior
|
||||
import org.jellyfin.apiclient.model.querying.ItemSortBy
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
@@ -25,6 +24,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageFormat
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.constant.ItemSortBy
|
||||
import org.koin.android.ext.android.inject
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ExecutionException
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.jellyfin.androidtv.integration.provider
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.target.CustomTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.R
|
||||
|
||||
class ImageProvider : ContentProvider() {
|
||||
override fun onCreate(): Boolean = true
|
||||
|
||||
override fun getType(uri: Uri) = null
|
||||
override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?) = null
|
||||
override fun insert(uri: Uri, values: ContentValues?) = null
|
||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?) = 0
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?) = 0
|
||||
|
||||
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
|
||||
val src = requireNotNull(uri.getQueryParameter("src")).toUri()
|
||||
|
||||
val (read, write) = ParcelFileDescriptor.createPipe()
|
||||
val outputStream = ParcelFileDescriptor.AutoCloseOutputStream(write)
|
||||
|
||||
ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.IO) {
|
||||
Glide.with(context!!)
|
||||
.asBitmap()
|
||||
.error(R.drawable.placeholder_icon)
|
||||
.load(src)
|
||||
.into(object : CustomTarget<Bitmap>() {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
@Suppress("DEPRECATION")
|
||||
val format = when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Bitmap.CompressFormat.WEBP_LOSSY
|
||||
else -> Bitmap.CompressFormat.WEBP
|
||||
}
|
||||
resource.compress(format, 95, outputStream)
|
||||
outputStream.close()
|
||||
}
|
||||
|
||||
override fun onLoadCleared(placeholder: Drawable?) = outputStream.close()
|
||||
})
|
||||
}
|
||||
|
||||
return read
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Get a [Uri] that uses the [ImageProvider] to load an image. The input should be a valid
|
||||
* Jellyfin image URL created using the SDK.
|
||||
*/
|
||||
fun getImageUri(src: String): Uri = Uri.Builder()
|
||||
.scheme("content")
|
||||
.authority("${BuildConfig.APPLICATION_ID}.integration.provider.ImageProvider")
|
||||
.appendQueryParameter("src", src)
|
||||
.appendQueryParameter("v", BuildConfig.VERSION_NAME)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import org.jellyfin.androidtv.constant.GridDirection
|
||||
import org.jellyfin.androidtv.constant.ImageType
|
||||
import org.jellyfin.androidtv.constant.PosterSize
|
||||
import org.jellyfin.androidtv.preference.store.DisplayPreferencesStore
|
||||
import org.jellyfin.apiclient.model.entities.SortOrder
|
||||
import org.jellyfin.apiclient.model.querying.ItemSortBy
|
||||
import org.jellyfin.preference.booleanPreference
|
||||
import org.jellyfin.preference.enumPreference
|
||||
import org.jellyfin.preference.stringPreference
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.constant.ItemSortBy
|
||||
|
||||
class LibraryPreferences(
|
||||
displayPreferencesId: String,
|
||||
@@ -30,6 +30,6 @@ class LibraryPreferences(
|
||||
|
||||
// Item sorting
|
||||
val sortBy = stringPreference("SortBy", ItemSortBy.SortName)
|
||||
val sortOrder = enumPreference("SortOrder", SortOrder.Ascending)
|
||||
val sortOrder = enumPreference("SortOrder", SortOrder.ASCENDING)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package org.jellyfin.androidtv.preference
|
||||
|
||||
import org.jellyfin.androidtv.preference.store.DisplayPreferencesStore
|
||||
import org.jellyfin.apiclient.model.querying.ItemSortBy
|
||||
import org.jellyfin.preference.booleanPreference
|
||||
import org.jellyfin.preference.stringPreference
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.constant.ItemSortBy
|
||||
|
||||
class LiveTvPreferences(
|
||||
api: ApiClient,
|
||||
|
||||
@@ -64,5 +64,10 @@ class SystemPreferences(context: Context) : SharedPreferenceStore(
|
||||
* Chosen player for play with button. Changes every time user chooses a player with "play with" button.
|
||||
*/
|
||||
var chosenPlayer = enumPreference("chosen_player", PreferredVideoPlayer.VLC)
|
||||
|
||||
/**
|
||||
* The version name for the latest dismissed beta notification or empty if none.
|
||||
*/
|
||||
val dismissedBetaNotificationVersion = stringPreference("dismissed_beta_notification_version", "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.jellyfin.androidtv.util.DeviceUtils
|
||||
import org.jellyfin.preference.booleanPreference
|
||||
import org.jellyfin.preference.enumPreference
|
||||
import org.jellyfin.preference.intPreference
|
||||
import org.jellyfin.preference.longPreference
|
||||
import org.jellyfin.preference.migration.putEnum
|
||||
import org.jellyfin.preference.store.SharedPreferenceStore
|
||||
import org.jellyfin.preference.stringPreference
|
||||
@@ -95,17 +96,17 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
*/
|
||||
var refreshRateSwitchingBehavior = enumPreference("refresh_rate_switching_behavior", RefreshRateSwitchingBehavior.DISABLED)
|
||||
|
||||
/**
|
||||
* Send a path instead to the external player
|
||||
*/
|
||||
var externalVideoPlayerSendPath = booleanPreference("pref_send_path_external", false)
|
||||
|
||||
/* Playback - Audio related */
|
||||
/**
|
||||
* Preferred behavior for audio streaming.
|
||||
*/
|
||||
var audioBehaviour = enumPreference("audio_behavior", defaultAudioBehavior)
|
||||
|
||||
/**
|
||||
* Preferred behavior for audio streaming.
|
||||
*/
|
||||
var audioNightMode = enumPreference("audio_night_mode", false)
|
||||
|
||||
/**
|
||||
* Enable DTS
|
||||
*/
|
||||
@@ -114,7 +115,7 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
/**
|
||||
* Enable AC3
|
||||
*/
|
||||
var ac3Enabled = booleanPreference("pref_bitstream_ac3", !DeviceUtils.isFireTvStickGen1())
|
||||
var ac3Enabled = booleanPreference("pref_bitstream_ac3", !DeviceUtils.isFireTvStickGen1)
|
||||
|
||||
/**
|
||||
* Default audio delay in milliseconds for libVLC
|
||||
@@ -179,9 +180,24 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
var subtitlesBackgroundEnabled = booleanPreference("subtitles_background_enabled", true)
|
||||
|
||||
/**
|
||||
* Set default subtitles font size
|
||||
* Subtitles font size
|
||||
*/
|
||||
var defaultSubtitlesSize = intPreference("subtitles_size", 28)
|
||||
var subtitlesSize = intPreference("subtitles_size", 28)
|
||||
|
||||
/**
|
||||
* Subtitles stroke size
|
||||
*/
|
||||
var subtitleStrokeSize = intPreference("subtitles_stroke_size", 0)
|
||||
|
||||
/**
|
||||
* Subtitles position
|
||||
*/
|
||||
var subtitlePosition = intPreference("subtitles_position", 40)
|
||||
|
||||
/**
|
||||
* Subtitles foreground color
|
||||
*/
|
||||
var subtitlesTextColor = longPreference("subtitles_text_color", 0xFFFFFFFF)
|
||||
}
|
||||
|
||||
init {
|
||||
@@ -213,7 +229,7 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
putInt("libvlc_audio_delay", it.getLong("libvlc_audio_delay", 0).toInt())
|
||||
|
||||
// Disable AC3 (Dolby Digital) on Fire Stick Gen 1 devices
|
||||
if (DeviceUtils.isFireTvStickGen1()) putBoolean("pref_bitstream_ac3", false)
|
||||
if (DeviceUtils.isFireTvStickGen1) putBoolean("pref_bitstream_ac3", false)
|
||||
}
|
||||
|
||||
// v0.13.3 to v0.13.4
|
||||
|
||||
@@ -18,5 +18,5 @@ enum class AudioBehavior {
|
||||
DOWNMIX_TO_STEREO
|
||||
}
|
||||
|
||||
val defaultAudioBehavior = if (DeviceUtils.isChromecastWithGoogleTV()) AudioBehavior.DOWNMIX_TO_STEREO
|
||||
val defaultAudioBehavior = if (DeviceUtils.isChromecastWithGoogleTV) AudioBehavior.DOWNMIX_TO_STEREO
|
||||
else AudioBehavior.DIRECT_STREAM
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.jellyfin.androidtv.preference.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
|
||||
import org.jellyfin.apiclient.model.querying.ItemSortBy
|
||||
import org.jellyfin.sdk.model.constant.ItemSortBy
|
||||
|
||||
enum class LiveTvChannelOrder(
|
||||
val stringValue: String
|
||||
|
||||
@@ -32,6 +32,7 @@ object TelemetryService {
|
||||
buildConfigClass = BuildConfig::class.java
|
||||
sharedPreferencesName = TelemetryPreferences.SHARED_PREFERENCES_NAME
|
||||
pluginLoader = AcraPluginLoader(AcraReportSenderFactory::class.java)
|
||||
applicationLogFileLines = 250
|
||||
|
||||
toast {
|
||||
text = context.getString(R.string.crash_report_toast)
|
||||
|
||||
@@ -10,10 +10,14 @@ import androidx.core.view.doOnAttach
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
import com.bumptech.glide.load.model.LazyHeaders
|
||||
import com.vanniktech.blurhash.BlurHash
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.util.BlurHashDecoder
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import kotlin.math.round
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -28,12 +32,19 @@ class AsyncImageView @JvmOverloads constructor(
|
||||
defStyleAttr: Int = 0,
|
||||
) : AppCompatImageView(context, attrs, defStyleAttr) {
|
||||
private val lifeCycleOwner get() = findViewTreeLifecycleOwner()
|
||||
private val styledAttributes = context.obtainStyledAttributes(attrs, R.styleable.AsyncImageView, defStyleAttr, 0)
|
||||
|
||||
/**
|
||||
* The duration of the crossfade when changing switching the images of the url, blurhash and
|
||||
* placeholder.
|
||||
*/
|
||||
var crossFadeDuration = 100.milliseconds
|
||||
@Suppress("MagicNumber")
|
||||
var crossFadeDuration = styledAttributes.getInt(R.styleable.AsyncImageView_crossfadeDuration, 100).milliseconds
|
||||
|
||||
/**
|
||||
* Shape the image to a circle and remove all corners.
|
||||
*/
|
||||
var circleCrop = styledAttributes.getBoolean(R.styleable.AsyncImageView_circleCrop, false)
|
||||
|
||||
/**
|
||||
* Load an image from the network using [url]. When the [url] is null or returns a bad response
|
||||
@@ -52,7 +63,7 @@ class AsyncImageView @JvmOverloads constructor(
|
||||
|
||||
// Only show blurhash if an image is going to be loaded from the network
|
||||
if (url != null && blurHash != null) withContext(Dispatchers.IO) {
|
||||
val blurHashBitmap = BlurHashDecoder.decode(
|
||||
val blurHashBitmap = BlurHash.decode(
|
||||
blurHash,
|
||||
if (aspectRatio > 1) round(blurHashResolution * aspectRatio).toInt() else blurHashResolution,
|
||||
if (aspectRatio >= 1) blurHashResolution else round(blurHashResolution / aspectRatio).toInt(),
|
||||
@@ -61,13 +72,23 @@ class AsyncImageView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
// Start loading image or placeholder
|
||||
Glide.with(this@AsyncImageView)
|
||||
.load(url ?: placeholder)
|
||||
.placeholder(placeholderOrBlurHash)
|
||||
.error(placeholder)
|
||||
// FIXME: Glide is unable to scale the image when transitions are enabled
|
||||
//.transition(DrawableTransitionOptions.withCrossFade(crossFadeDuration.inWholeMilliseconds.toInt()))
|
||||
.into(this@AsyncImageView)
|
||||
if (url == null) {
|
||||
Glide.with(this@AsyncImageView).load(placeholder).apply {
|
||||
if (circleCrop) circleCrop()
|
||||
}.into(this@AsyncImageView)
|
||||
} else {
|
||||
val glideUrl = GlideUrl(url, LazyHeaders.Builder().apply {
|
||||
setHeader("Accept", ApiClient.HEADER_ACCEPT)
|
||||
}.build())
|
||||
|
||||
Glide.with(this@AsyncImageView).load(glideUrl).apply {
|
||||
placeholder(placeholderOrBlurHash)
|
||||
error(placeholder)
|
||||
if (circleCrop) circleCrop()
|
||||
// FIXME: Glide is unable to scale the image when transitions are enabled
|
||||
//transition(DrawableTransitionOptions.withCrossFade(crossFadeDuration.inWholeMilliseconds.toInt()))
|
||||
}.into(this@AsyncImageView)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.RelativeLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import com.bumptech.glide.Glide
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
import org.jellyfin.androidtv.databinding.ClockUserBugBinding
|
||||
@@ -39,20 +39,11 @@ class ClockUserView @JvmOverloads constructor(
|
||||
|
||||
val currentUser = userRepository.currentUser.value
|
||||
|
||||
if (currentUser != null) {
|
||||
if (currentUser.primaryImageTag != null) {
|
||||
Glide.with(context)
|
||||
.load(ImageUtils.getPrimaryImageUrl(currentUser))
|
||||
.placeholder(R.drawable.ic_user)
|
||||
.centerInside()
|
||||
.circleCrop()
|
||||
.into(binding.clockUserImage)
|
||||
} else {
|
||||
binding.clockUserImage.setImageResource(R.drawable.ic_user)
|
||||
}
|
||||
binding.clockUserImage.isVisible = true
|
||||
} else {
|
||||
binding.clockUserImage.isVisible = false
|
||||
}
|
||||
binding.clockUserImage.load(
|
||||
url = currentUser?.let(ImageUtils::getPrimaryImageUrl),
|
||||
placeholder = ContextCompat.getDrawable(context, R.drawable.ic_user)
|
||||
)
|
||||
|
||||
binding.clockUserImage.isVisible = currentUser != null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ package org.jellyfin.androidtv.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.FrameLayout
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.View.OnFocusChangeListener
|
||||
import android.widget.FrameLayout
|
||||
import org.jellyfin.androidtv.databinding.ViewRowDetailsBinding
|
||||
|
||||
class DetailRowView @JvmOverloads constructor(
|
||||
@@ -13,4 +15,38 @@ class DetailRowView @JvmOverloads constructor(
|
||||
defStyleRes: Int = 0,
|
||||
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
|
||||
val binding = ViewRowDetailsBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
|
||||
/**
|
||||
* Keeps track of the last selected button and reselect it when navigating back to the buttons row.
|
||||
*/
|
||||
private val buttonsHierarchyChangeListener = object : OnHierarchyChangeListener {
|
||||
|
||||
private var lastFocusedButton: View? = null
|
||||
|
||||
private val focusChangeListener = OnFocusChangeListener { view, hasFocus ->
|
||||
// Restore last focused button when navigating back to button row
|
||||
if (hasFocus && lastFocusedButton != null) {
|
||||
lastFocusedButton?.requestFocus()
|
||||
lastFocusedButton = null
|
||||
}
|
||||
view.post {
|
||||
// Store last focused button when navigating away from button row
|
||||
if (binding.fdButtonRow.focusedChild == null) {
|
||||
lastFocusedButton = view
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChildViewAdded(parent: View?, child: View?) {
|
||||
child?.onFocusChangeListener = focusChangeListener
|
||||
}
|
||||
|
||||
override fun onChildViewRemoved(parent: View?, child: View?) {
|
||||
child?.onFocusChangeListener = null
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
binding.fdButtonRow.setOnHierarchyChangeListener(buttonsHierarchyChangeListener)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.view.View
|
||||
import android.view.animation.AlphaAnimation
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.IntRange
|
||||
import org.jellyfin.androidtv.integration.dream.LibraryDreamService
|
||||
|
||||
class FadeViewSwitcherLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
@@ -50,13 +49,13 @@ class FadeViewSwitcherLayout @JvmOverloads constructor(
|
||||
private fun View.fadeIn() {
|
||||
alpha = 1f
|
||||
startAnimation(AlphaAnimation(0f, 1f).apply {
|
||||
duration = LibraryDreamService.TRANSITION_DURATION
|
||||
duration = 1_000
|
||||
fillAfter = true
|
||||
})
|
||||
}
|
||||
|
||||
private fun View.fadeOut() = startAnimation(AlphaAnimation(1f, 0f).apply {
|
||||
duration = LibraryDreamService.TRANSITION_DURATION
|
||||
duration = 1_000
|
||||
fillAfter = true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,7 +43,5 @@ public class FriendlyDateButton extends FrameLayout {
|
||||
} else {
|
||||
setBackgroundColor(0);
|
||||
}
|
||||
|
||||
//TvApp.getApplication().getLogger().Debug("Focus on "+mProgram.getName()+ " was " +(gainFocus ? "gained" : "lost"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
package org.jellyfin.androidtv.ui;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.view.Gravity;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.leanback.widget.BaseGridView;
|
||||
import androidx.leanback.widget.ObjectAdapter;
|
||||
import androidx.leanback.widget.OnItemViewClickedListener;
|
||||
import androidx.leanback.widget.OnItemViewSelectedListener;
|
||||
import androidx.leanback.widget.Presenter;
|
||||
import androidx.leanback.widget.Row;
|
||||
import androidx.leanback.widget.RowPresenter;
|
||||
import androidx.leanback.widget.VerticalGridPresenter;
|
||||
|
||||
import org.jellyfin.androidtv.R;
|
||||
import org.jellyfin.androidtv.data.model.FilterOptions;
|
||||
import org.jellyfin.androidtv.databinding.HorizontalGridBrowseBinding;
|
||||
import org.jellyfin.androidtv.ui.itemhandling.BaseRowItem;
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter;
|
||||
import org.jellyfin.androidtv.ui.presentation.HorizontalGridPresenter;
|
||||
import org.jellyfin.androidtv.util.InfoLayoutHelper;
|
||||
import org.jellyfin.apiclient.model.entities.SortOrder;
|
||||
import org.jellyfin.apiclient.model.querying.ItemSortBy;
|
||||
|
||||
import java.text.MessageFormat;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import timber.log.Timber;
|
||||
|
||||
public class GridFragment extends Fragment {
|
||||
protected TextView mTitleView;
|
||||
private TextView mStatusText;
|
||||
private TextView mCounter;
|
||||
protected ViewGroup mGridDock;
|
||||
protected LinearLayout mInfoRow;
|
||||
protected LinearLayout mToolBar;
|
||||
private ItemRowAdapter mAdapter;
|
||||
private Presenter mGridPresenter;
|
||||
private Presenter.ViewHolder mGridViewHolder;
|
||||
private BaseGridView mGridView;
|
||||
private OnItemViewSelectedListener mOnItemViewSelectedListener;
|
||||
private OnItemViewClickedListener mOnItemViewClickedListener;
|
||||
private int mSelectedPosition = -1;
|
||||
private int mGridHeight = -1;
|
||||
private int mGridWidth = -1;
|
||||
private int mGridItemSpacingHorizontal = 0;
|
||||
private int mGridItemSpacingVertical = 0;
|
||||
private int mGridPaddingLeft = 0;
|
||||
private int mGridPaddingTop = 0;
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
// init with some working defaults
|
||||
DisplayMetrics display = requireContext().getResources().getDisplayMetrics();
|
||||
mGridHeight = display.heightPixels - (int) Math.round(display.density * 130.6); // top + bottom in dp, elements scale with density so adjust accordingly
|
||||
mGridWidth = display.widthPixels;
|
||||
|
||||
sortOptions = new HashMap<>();
|
||||
{
|
||||
sortOptions.put(0, new SortOption(getString(R.string.lbl_name), ItemSortBy.SortName, SortOrder.Ascending));
|
||||
sortOptions.put(1, new SortOption(getString(R.string.lbl_date_added), ItemSortBy.DateCreated + "," + ItemSortBy.SortName, SortOrder.Descending));
|
||||
sortOptions.put(2, new SortOption(getString(R.string.lbl_premier_date), ItemSortBy.PremiereDate + "," + ItemSortBy.SortName, SortOrder.Descending));
|
||||
sortOptions.put(3, new SortOption(getString(R.string.lbl_rating), ItemSortBy.OfficialRating + "," + ItemSortBy.SortName, SortOrder.Ascending));
|
||||
sortOptions.put(4, new SortOption(getString(R.string.lbl_community_rating), ItemSortBy.CommunityRating + "," + ItemSortBy.SortName, SortOrder.Descending));
|
||||
sortOptions.put(5, new SortOption(getString(R.string.lbl_critic_rating), ItemSortBy.CriticRating + "," + ItemSortBy.SortName, SortOrder.Descending));
|
||||
sortOptions.put(6, new SortOption(getString(R.string.lbl_last_played), ItemSortBy.DatePlayed + "," + ItemSortBy.SortName, SortOrder.Descending));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the grid presenter.
|
||||
*/
|
||||
public void setGridPresenter(HorizontalGridPresenter gridPresenter) {
|
||||
if (gridPresenter == null) {
|
||||
throw new IllegalArgumentException("Grid presenter may not be null");
|
||||
}
|
||||
gridPresenter.setOnItemViewSelectedListener(mRowSelectedListener);
|
||||
if (mOnItemViewClickedListener != null) {
|
||||
gridPresenter.setOnItemViewClickedListener(mOnItemViewClickedListener);
|
||||
}
|
||||
mGridPresenter = gridPresenter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the grid presenter.
|
||||
*/
|
||||
public void setGridPresenter(VerticalGridPresenter gridPresenter) {
|
||||
if (gridPresenter == null) {
|
||||
throw new IllegalArgumentException("Grid presenter may not be null");
|
||||
}
|
||||
gridPresenter.setOnItemViewSelectedListener(mRowSelectedListener);
|
||||
if (mOnItemViewClickedListener != null) {
|
||||
gridPresenter.setOnItemViewClickedListener(mOnItemViewClickedListener);
|
||||
}
|
||||
mGridPresenter = gridPresenter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the grid presenter.
|
||||
*/
|
||||
public Presenter getGridPresenter() {
|
||||
return mGridPresenter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the object adapter for the fragment.
|
||||
*/
|
||||
public void setAdapter(ItemRowAdapter adapter) {
|
||||
mAdapter = adapter;
|
||||
updateAdapter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the object adapter.
|
||||
*/
|
||||
public ObjectAdapter getAdapter() {
|
||||
return mAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the GridView for the fragment.
|
||||
*/
|
||||
public BaseGridView getGridView() {
|
||||
return mGridView;
|
||||
}
|
||||
|
||||
public int getGridHeight() {
|
||||
return mGridHeight;
|
||||
}
|
||||
|
||||
public int getGridWidth() {
|
||||
return mGridWidth;
|
||||
}
|
||||
|
||||
protected void setGridSize(int height, int width) {
|
||||
mGridHeight = height;
|
||||
mGridWidth = width;
|
||||
}
|
||||
|
||||
public void setItem(BaseRowItem item) {
|
||||
if (item != null) {
|
||||
mTitleView.setText(item.getFullName(requireContext()));
|
||||
InfoLayoutHelper.addInfoRow(requireContext(), item, mInfoRow, true, true);
|
||||
} else {
|
||||
mTitleView.setText("");
|
||||
mInfoRow.removeAllViews();
|
||||
}
|
||||
}
|
||||
|
||||
protected void setGridItemSpacing(int spacingHorizontal, int spacingVertical) {
|
||||
mGridItemSpacingHorizontal = spacingHorizontal;
|
||||
mGridItemSpacingVertical = spacingVertical;
|
||||
}
|
||||
|
||||
protected void setGridPadding(int gridPaddingLeft, int gridPaddingTop) {
|
||||
mGridPaddingLeft = gridPaddingLeft;
|
||||
mGridPaddingTop = gridPaddingTop;
|
||||
}
|
||||
|
||||
public class SortOption {
|
||||
public String name;
|
||||
public String value;
|
||||
public SortOrder order;
|
||||
|
||||
public SortOption(String name, String value, SortOrder order) {
|
||||
this.name = name;
|
||||
this.value = value;
|
||||
this.order = order;
|
||||
}
|
||||
}
|
||||
|
||||
protected Map<Integer, SortOption> sortOptions;
|
||||
|
||||
protected String getSortFriendlyName(String value) {
|
||||
return getSortOption(value).name;
|
||||
}
|
||||
|
||||
protected SortOption getSortOption(String value) {
|
||||
for (Integer key : sortOptions.keySet()) {
|
||||
SortOption option = sortOptions.get(key);
|
||||
if (Objects.requireNonNull(option).value.equals(value)) return option;
|
||||
}
|
||||
|
||||
return new SortOption("Unknown", "", SortOrder.Ascending);
|
||||
}
|
||||
|
||||
public void setTitle(String text) {
|
||||
mTitleView.setText(text);
|
||||
}
|
||||
|
||||
public void setStatusText(String folderName) {
|
||||
String text = getString(R.string.lbl_showing) + " ";
|
||||
FilterOptions filters = mAdapter.getFilters();
|
||||
if (filters == null || (!filters.isFavoriteOnly() && !filters.isUnwatchedOnly())) {
|
||||
text += getString(R.string.lbl_all_items);
|
||||
} else {
|
||||
text += (filters.isUnwatchedOnly() ? getString(R.string.lbl_unwatched) : "") + " " +
|
||||
(filters.isFavoriteOnly() ? getString(R.string.lbl_favorites) : "");
|
||||
}
|
||||
|
||||
if (mAdapter.getStartLetter() != null) {
|
||||
text += " " + getString(R.string.lbl_starting_with) + " " + mAdapter.getStartLetter();
|
||||
}
|
||||
|
||||
text += " " + getString(R.string.lbl_from) + " '" + folderName + "' " + getString(R.string.lbl_sorted_by) + " " + getSortFriendlyName(mAdapter.getSortBy());
|
||||
|
||||
mStatusText.setText(text);
|
||||
}
|
||||
|
||||
public LinearLayout getToolBar() {
|
||||
return mToolBar;
|
||||
}
|
||||
|
||||
final private OnItemViewSelectedListener mRowSelectedListener =
|
||||
new OnItemViewSelectedListener() {
|
||||
@Override
|
||||
public void onItemSelected(Presenter.ViewHolder itemViewHolder, Object item,
|
||||
RowPresenter.ViewHolder rowViewHolder, Row row) {
|
||||
int position = mGridView.getSelectedPosition();
|
||||
Timber.d("row selected position %s", position);
|
||||
onRowSelected(position);
|
||||
if (mOnItemViewSelectedListener != null && position >= 0) {
|
||||
mOnItemViewSelectedListener.onItemSelected(itemViewHolder, item, rowViewHolder, row);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets an item selection listener.
|
||||
*/
|
||||
public void setOnItemViewSelectedListener(OnItemViewSelectedListener listener) {
|
||||
mOnItemViewSelectedListener = listener;
|
||||
}
|
||||
|
||||
private void onRowSelected(int position) {
|
||||
if (position != mSelectedPosition) {
|
||||
mSelectedPosition = position;
|
||||
}
|
||||
// Update the counter
|
||||
updateCounter(position + 1);
|
||||
}
|
||||
|
||||
public void updateCounter(int position) {
|
||||
if (mAdapter != null) {
|
||||
mCounter.setText(MessageFormat.format("{0} | {1}", position, mAdapter.getTotalItems()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an item clicked listener.
|
||||
*/
|
||||
public void setOnItemViewClickedListener(OnItemViewClickedListener listener) {
|
||||
mOnItemViewClickedListener = listener;
|
||||
if (mGridPresenter != null) {
|
||||
if (mGridPresenter instanceof HorizontalGridPresenter)
|
||||
((HorizontalGridPresenter) mGridPresenter).setOnItemViewClickedListener(mOnItemViewClickedListener);
|
||||
else if (mGridPresenter instanceof VerticalGridPresenter)
|
||||
((VerticalGridPresenter) mGridPresenter).setOnItemViewClickedListener(mOnItemViewClickedListener);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the item clicked listener.
|
||||
*/
|
||||
public OnItemViewClickedListener getOnItemViewClickedListener() {
|
||||
return mOnItemViewClickedListener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
|
||||
@Nullable Bundle savedInstanceState) {
|
||||
|
||||
HorizontalGridBrowseBinding binding = HorizontalGridBrowseBinding.inflate(inflater, container, false);
|
||||
mTitleView = binding.title;
|
||||
mStatusText = binding.statusText;
|
||||
mInfoRow = binding.infoRow;
|
||||
mToolBar = binding.toolBar;
|
||||
mCounter = binding.counter;
|
||||
mGridDock = binding.rowsFragment;
|
||||
|
||||
// Hide the description because we don't have room for it
|
||||
binding.npBug.showDescription(false);
|
||||
|
||||
// NOTE: we only get the 100% correct grid size if we render it once, so hook into it here
|
||||
mGridDock.post(() -> {
|
||||
if (mGridDock.getHeight() > 0 && mGridDock.getWidth() > 0) {
|
||||
onGridSizeMeasurements(mGridDock.getHeight(), mGridDock.getWidth());
|
||||
}
|
||||
});
|
||||
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for measured GridSize.
|
||||
*/
|
||||
protected void onGridSizeMeasurements(int gridHeight, int gridWidth) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
createGrid();
|
||||
}
|
||||
|
||||
protected void createGrid() {
|
||||
mGridViewHolder = mGridPresenter.onCreateViewHolder(mGridDock);
|
||||
if (mGridViewHolder instanceof HorizontalGridPresenter.ViewHolder) {
|
||||
mGridView = ((HorizontalGridPresenter.ViewHolder) mGridViewHolder).getGridView();
|
||||
mGridView.setGravity(Gravity.CENTER_VERTICAL);
|
||||
mGridView.setPadding(mGridPaddingLeft, mGridPaddingTop, mGridPaddingLeft, mGridPaddingTop); // prevent initial card cutoffs
|
||||
// Don't use fading, breaks initial view and needs special handling, while not providing much of a visual difference!
|
||||
// ((HorizontalGridView)mGridView).setFadingRightEdge(true);
|
||||
// ((HorizontalGridView)mGridView).setFadingRightEdgeLength(100);
|
||||
} else if (mGridViewHolder instanceof VerticalGridPresenter.ViewHolder) {
|
||||
mGridView = ((VerticalGridPresenter.ViewHolder) mGridViewHolder).getGridView();
|
||||
mGridView.setGravity(Gravity.CENTER_HORIZONTAL);
|
||||
mGridView.setPadding(mGridPaddingLeft, mGridPaddingTop, mGridPaddingLeft, mGridPaddingTop); // prevent initial card cutoffs
|
||||
}
|
||||
mGridView.setHorizontalSpacing(mGridItemSpacingHorizontal);
|
||||
mGridView.setVerticalSpacing(mGridItemSpacingVertical);
|
||||
mGridView.setFocusable(true);
|
||||
mGridDock.removeAllViews();
|
||||
mGridDock.addView(mGridViewHolder.view);
|
||||
|
||||
updateAdapter();
|
||||
}
|
||||
|
||||
public void focusGrid() {
|
||||
if (mGridView != null) mGridView.requestFocus();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
mGridView = null;
|
||||
}
|
||||
|
||||
private void updateAdapter() {
|
||||
if (mGridView != null) {
|
||||
mGridPresenter.onBindViewHolder(mGridViewHolder, mAdapter);
|
||||
if (mSelectedPosition != -1) {
|
||||
mGridView.setSelectedPosition(mSelectedPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,9 @@ import android.widget.TextView;
|
||||
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
|
||||
import org.jellyfin.androidtv.R;
|
||||
import org.jellyfin.androidtv.ui.livetv.LiveTvGuide;
|
||||
import org.jellyfin.androidtv.ui.livetv.LiveTvGuideActivity;
|
||||
import org.jellyfin.androidtv.ui.livetv.LiveTvGuideFragment;
|
||||
import org.jellyfin.androidtv.util.ImageUtils;
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
import org.jellyfin.apiclient.interaction.ApiClient;
|
||||
@@ -23,7 +21,7 @@ import org.jellyfin.apiclient.model.livetv.ChannelInfoDto;
|
||||
import org.koin.java.KoinJavaComponent;
|
||||
|
||||
public class GuideChannelHeader extends RelativeLayout {
|
||||
private ImageView mChannelImage;
|
||||
private AsyncImageView mChannelImage;
|
||||
private ImageView mFavImage;
|
||||
private ChannelInfoDto mChannel;
|
||||
private Context mContext;
|
||||
@@ -43,7 +41,7 @@ public class GuideChannelHeader extends RelativeLayout {
|
||||
int headerWidth = Utils.convertDpToPixel(context, 160);
|
||||
v.setLayoutParams(new AbsListView.LayoutParams(
|
||||
headerWidth,
|
||||
Utils.convertDpToPixel(context, LiveTvGuideActivity.GUIDE_ROW_HEIGHT_DP)
|
||||
Utils.convertDpToPixel(context, LiveTvGuideFragment.GUIDE_ROW_HEIGHT_DP)
|
||||
));
|
||||
this.addView(v);
|
||||
this.setFocusable(true);
|
||||
@@ -57,14 +55,13 @@ public class GuideChannelHeader extends RelativeLayout {
|
||||
}
|
||||
|
||||
public void loadImage() {
|
||||
int imageWidth = Utils.convertDpToPixel(mContext, 50);
|
||||
int imageHeight = Utils.convertDpToPixel(mContext, 30);
|
||||
|
||||
Glide.with(mContext)
|
||||
.load(ImageUtils.getPrimaryImageUrl(mChannel, KoinJavaComponent.<ApiClient>get(ApiClient.class)))
|
||||
.override(imageWidth, imageHeight)
|
||||
.centerInside()
|
||||
.into(mChannelImage);
|
||||
mChannelImage.load(
|
||||
ImageUtils.getPrimaryImageUrl(mChannel, KoinJavaComponent.<ApiClient>get(ApiClient.class)),
|
||||
null,
|
||||
null,
|
||||
0.0,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
public ChannelInfoDto getChannel() { return mChannel; }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.jellyfin.androidtv.ui;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.graphics.Rect;
|
||||
import android.util.AttributeSet;
|
||||
@@ -10,7 +9,7 @@ import android.widget.RelativeLayout;
|
||||
import org.jellyfin.androidtv.R;
|
||||
import org.jellyfin.androidtv.databinding.ProgramGridCellBinding;
|
||||
import org.jellyfin.androidtv.ui.livetv.LiveTvGuide;
|
||||
import org.jellyfin.androidtv.ui.livetv.LiveTvGuideActivity;
|
||||
import org.jellyfin.androidtv.ui.livetv.LiveTvGuideFragment;
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
|
||||
public class GuidePagingButton extends RelativeLayout {
|
||||
@@ -23,16 +22,16 @@ public class GuidePagingButton extends RelativeLayout {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public GuidePagingButton(final Activity activity, final LiveTvGuide guide, int start, String label) {
|
||||
super(activity);
|
||||
public GuidePagingButton(Context context, final LiveTvGuide guide, int start, String label) {
|
||||
super(context);
|
||||
|
||||
LayoutInflater inflater = LayoutInflater.from(activity);
|
||||
LayoutInflater inflater = LayoutInflater.from(context);
|
||||
ProgramGridCellBinding binding = ProgramGridCellBinding.inflate(inflater, this, true);
|
||||
binding.programName.setText(label);
|
||||
|
||||
setBackgroundColor(Utils.getThemeColor(activity, R.attr.buttonDefaultNormalBackground));
|
||||
setBackgroundColor(Utils.getThemeColor(context, R.attr.buttonDefaultNormalBackground));
|
||||
setFocusable(true);
|
||||
setOnClickListener(v -> guide.displayChannels(start, LiveTvGuideActivity.PAGE_SIZE));
|
||||
setOnClickListener(v -> guide.displayChannels(start, LiveTvGuideFragment.PAGE_SIZE));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -14,8 +14,10 @@ import android.widget.TextView;
|
||||
import org.jellyfin.androidtv.R;
|
||||
import org.jellyfin.androidtv.databinding.ItemRowBinding;
|
||||
import org.jellyfin.androidtv.util.TimeUtils;
|
||||
import org.jellyfin.androidtv.util.apiclient.BaseItemUtils;
|
||||
import org.jellyfin.androidtv.util.sdk.BaseItemExtensionsKt;
|
||||
import org.jellyfin.androidtv.util.sdk.compat.ModelCompat;
|
||||
import org.jellyfin.apiclient.model.dto.BaseItemDto;
|
||||
import org.jellyfin.sdk.model.constant.MediaType;
|
||||
|
||||
public class ItemRowView extends FrameLayout {
|
||||
Context mContext;
|
||||
@@ -100,7 +102,7 @@ public class ItemRowView extends FrameLayout {
|
||||
}
|
||||
break;
|
||||
default:
|
||||
String series = item.getSeriesName() != null ? BaseItemUtils.getFullName(item, mContext) : null;
|
||||
String series = item.getSeriesName() != null ? BaseItemExtensionsKt.getFullName(ModelCompat.asSdk(item), mContext) : null;
|
||||
if (!TextUtils.isEmpty(series)) {
|
||||
mItemName.setText(series);
|
||||
mExtraName.setText(item.getName());
|
||||
@@ -117,7 +119,7 @@ public class ItemRowView extends FrameLayout {
|
||||
|
||||
public void updateWatched() {
|
||||
if (mBaseItem == null) return;
|
||||
if ("Video".equals(mBaseItem.getMediaType()) && mBaseItem.getUserData() != null && mBaseItem.getUserData().getPlayed()) {
|
||||
if (MediaType.Video.equals(mBaseItem.getMediaType()) && mBaseItem.getUserData() != null && mBaseItem.getUserData().getPlayed()) {
|
||||
mWatchedMark.setText("✓");
|
||||
} else {
|
||||
mWatchedMark.setText("");
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.jellyfin.androidtv.ui;
|
||||
|
||||
import static org.koin.java.KoinJavaComponent.inject;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
@@ -16,18 +17,19 @@ import android.widget.PopupWindow;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
|
||||
import org.jellyfin.androidtv.R;
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository;
|
||||
import org.jellyfin.androidtv.data.model.DataRefreshService;
|
||||
import org.jellyfin.androidtv.ui.livetv.LiveTvGuide;
|
||||
import org.jellyfin.androidtv.ui.livetv.TvManager;
|
||||
import org.jellyfin.androidtv.ui.shared.BaseActivity;
|
||||
import org.jellyfin.androidtv.util.InfoLayoutHelper;
|
||||
import org.jellyfin.androidtv.util.TimeUtils;
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
import org.jellyfin.androidtv.util.apiclient.EmptyLifecycleAwareResponse;
|
||||
import org.jellyfin.androidtv.util.sdk.compat.ModelCompat;
|
||||
import org.jellyfin.apiclient.interaction.ApiClient;
|
||||
import org.jellyfin.apiclient.interaction.EmptyResponse;
|
||||
import org.jellyfin.apiclient.interaction.Response;
|
||||
import org.jellyfin.apiclient.model.dto.BaseItemDto;
|
||||
import org.jellyfin.apiclient.model.dto.UserItemDataDto;
|
||||
@@ -44,7 +46,8 @@ public class LiveProgramDetailPopup {
|
||||
private PopupWindow mPopup;
|
||||
private BaseItemDto mProgram;
|
||||
private ProgramGridCell mSelectedProgramView;
|
||||
private BaseActivity mActivity;
|
||||
private Activity mActivity;
|
||||
private final Lifecycle lifecycle;
|
||||
private LiveTvGuide mTvGuide;
|
||||
private TextView mDTitle;
|
||||
private TextView mDSummary;
|
||||
@@ -56,7 +59,7 @@ public class LiveProgramDetailPopup {
|
||||
private Button mFirstButton;
|
||||
private Button mSeriesSettingsButton;
|
||||
|
||||
private EmptyResponse mTuneAction;
|
||||
private EmptyLifecycleAwareResponse mTuneAction;
|
||||
|
||||
private View mAnchor;
|
||||
private int mPosLeft;
|
||||
@@ -64,8 +67,9 @@ public class LiveProgramDetailPopup {
|
||||
|
||||
private Lazy<ApiClient> apiClient = inject(ApiClient.class);
|
||||
|
||||
public LiveProgramDetailPopup(BaseActivity activity, LiveTvGuide tvGuide, int width, EmptyResponse tuneAction) {
|
||||
public LiveProgramDetailPopup(Activity activity, Lifecycle lifecycle, LiveTvGuide tvGuide, int width, EmptyLifecycleAwareResponse tuneAction) {
|
||||
mActivity = activity;
|
||||
this.lifecycle = lifecycle;
|
||||
mTvGuide = tvGuide;
|
||||
mTuneAction = tuneAction;
|
||||
LayoutInflater inflater = (LayoutInflater) mActivity.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
|
||||
@@ -110,13 +114,12 @@ public class LiveProgramDetailPopup {
|
||||
} else {
|
||||
mDSummary.setGravity(Gravity.LEFT);
|
||||
}
|
||||
//TvApp.getApplication().getLogger().Debug("Text height: "+mDSummary.getHeight() + " (120 = "+Utils.convertDpToPixel(mActivity, 120)+")");
|
||||
|
||||
// build timeline info
|
||||
TvManager.setTimelineRow(mActivity, mDTimeline, program);
|
||||
|
||||
//info row
|
||||
InfoLayoutHelper.addInfoRow(mActivity, program, mDInfoRow, false, false);
|
||||
InfoLayoutHelper.addInfoRow(mActivity, ModelCompat.asSdk(program), mDInfoRow, false, false);
|
||||
|
||||
//buttons
|
||||
mFirstButton = null;
|
||||
@@ -138,9 +141,11 @@ public class LiveProgramDetailPopup {
|
||||
cancel.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
apiClient.getValue().CancelLiveTvTimerAsync(program.getTimerId(), new EmptyResponse() {
|
||||
apiClient.getValue().CancelLiveTvTimerAsync(program.getTimerId(), new EmptyLifecycleAwareResponse(lifecycle) {
|
||||
@Override
|
||||
public void onResponse() {
|
||||
if (!getActive()) return;
|
||||
|
||||
selectedGridView.setRecTimer(null);
|
||||
program.setTimerId(null);
|
||||
dismiss();
|
||||
@@ -149,6 +154,8 @@ public class LiveProgramDetailPopup {
|
||||
|
||||
@Override
|
||||
public void onError(Exception ex) {
|
||||
if (!getActive()) return;
|
||||
|
||||
Utils.showToast(mActivity, R.string.msg_unable_to_cancel);
|
||||
}
|
||||
});
|
||||
@@ -171,9 +178,11 @@ public class LiveProgramDetailPopup {
|
||||
apiClient.getValue().GetDefaultLiveTvTimerInfo(mProgram.getId(), new Response<SeriesTimerInfoDto>() {
|
||||
@Override
|
||||
public void onResponse(SeriesTimerInfoDto response) {
|
||||
apiClient.getValue().CreateLiveTvTimerAsync(response, new EmptyResponse() {
|
||||
apiClient.getValue().CreateLiveTvTimerAsync(response, new EmptyLifecycleAwareResponse(lifecycle) {
|
||||
@Override
|
||||
public void onResponse() {
|
||||
if (!getActive()) return;
|
||||
|
||||
// we have to re-retrieve the program to get the timer id
|
||||
apiClient.getValue().GetLiveTvProgramAsync(mProgram.getId(), KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString(), new Response<BaseItemDto>() {
|
||||
@Override
|
||||
@@ -189,6 +198,8 @@ public class LiveProgramDetailPopup {
|
||||
|
||||
@Override
|
||||
public void onError(Exception ex) {
|
||||
if (!getActive()) return;
|
||||
|
||||
Timber.e(ex, "Error creating recording");
|
||||
Utils.showToast(mActivity, R.string.msg_unable_to_create_recording);
|
||||
}
|
||||
@@ -224,9 +235,11 @@ public class LiveProgramDetailPopup {
|
||||
.setPositiveButton(R.string.lbl_yes, new DialogInterface.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which) {
|
||||
apiClient.getValue().CancelLiveTvSeriesTimerAsync(program.getSeriesTimerId(), new EmptyResponse() {
|
||||
apiClient.getValue().CancelLiveTvSeriesTimerAsync(program.getSeriesTimerId(), new EmptyLifecycleAwareResponse(lifecycle) {
|
||||
@Override
|
||||
public void onResponse() {
|
||||
if (!getActive()) return;
|
||||
|
||||
selectedGridView.setRecSeriesTimer(null);
|
||||
program.setSeriesTimerId(null);
|
||||
mSeriesSettingsButton.setVisibility(View.GONE);
|
||||
@@ -236,6 +249,8 @@ public class LiveProgramDetailPopup {
|
||||
|
||||
@Override
|
||||
public void onError(Exception ex) {
|
||||
if (!getActive()) return;
|
||||
|
||||
Utils.showToast(mActivity, R.string.msg_unable_to_cancel);
|
||||
}
|
||||
});
|
||||
@@ -257,9 +272,11 @@ public class LiveProgramDetailPopup {
|
||||
apiClient.getValue().GetDefaultLiveTvTimerInfo(mProgram.getId(), new Response<SeriesTimerInfoDto>() {
|
||||
@Override
|
||||
public void onResponse(SeriesTimerInfoDto response) {
|
||||
apiClient.getValue().CreateLiveTvSeriesTimerAsync(response, new EmptyResponse() {
|
||||
apiClient.getValue().CreateLiveTvSeriesTimerAsync(response, new EmptyLifecycleAwareResponse(lifecycle) {
|
||||
@Override
|
||||
public void onResponse() {
|
||||
if (!getActive()) return;
|
||||
|
||||
// we have to re-retrieve the program to get the timer id
|
||||
apiClient.getValue().GetLiveTvProgramAsync(mProgram.getId(), KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString(), new Response<BaseItemDto>() {
|
||||
@Override
|
||||
@@ -276,6 +293,8 @@ public class LiveProgramDetailPopup {
|
||||
|
||||
@Override
|
||||
public void onError(Exception ex) {
|
||||
if (!getActive()) return;
|
||||
|
||||
Timber.e(ex, "Error creating recording");
|
||||
Utils.showToast(mActivity, R.string.msg_unable_to_create_recording);
|
||||
}
|
||||
@@ -339,7 +358,7 @@ public class LiveProgramDetailPopup {
|
||||
tune.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
if (mTuneAction != null) mTuneAction.onResponse();
|
||||
if (mTuneAction != null && mTuneAction.getActive()) mTuneAction.onResponse();
|
||||
mPopup.dismiss();
|
||||
}
|
||||
});
|
||||
@@ -405,11 +424,11 @@ public class LiveProgramDetailPopup {
|
||||
private RecordPopup mRecordPopup;
|
||||
|
||||
public void showRecordingOptions(final boolean recordSeries) {
|
||||
if (mRecordPopup == null) mRecordPopup = new RecordPopup(mActivity, mAnchor, mPosLeft, mPosTop, mPopup.getWidth());
|
||||
if (mRecordPopup == null) mRecordPopup = new RecordPopup(mActivity, lifecycle, mAnchor, mPosLeft, mPosTop, mPopup.getWidth());
|
||||
apiClient.getValue().GetLiveTvSeriesTimerAsync(mProgram.getSeriesTimerId(), new Response<SeriesTimerInfoDto>() {
|
||||
@Override
|
||||
public void onResponse(SeriesTimerInfoDto response) {
|
||||
mRecordPopup.setContent(mActivity, mProgram, response, mSelectedProgramView, recordSeries);
|
||||
mRecordPopup.setContent(mActivity, ModelCompat.asSdk(mProgram), response, mSelectedProgramView, recordSeries);
|
||||
mRecordPopup.show();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.jellyfin.androidtv.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.FrameLayout
|
||||
@@ -10,14 +9,13 @@ import androidx.core.view.isVisible
|
||||
import androidx.core.view.setPadding
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.databinding.ViewNowPlayingBinding
|
||||
import org.jellyfin.androidtv.ui.navigation.Destinations
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
|
||||
import org.jellyfin.androidtv.ui.playback.AudioEventListener
|
||||
import org.jellyfin.androidtv.ui.playback.AudioNowPlayingActivity
|
||||
import org.jellyfin.androidtv.ui.playback.MediaManager
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackController
|
||||
import org.jellyfin.androidtv.util.ImageUtils
|
||||
import org.jellyfin.androidtv.util.TimeUtils
|
||||
import org.jellyfin.apiclient.model.dto.BaseItemDto
|
||||
import org.jellyfin.apiclient.model.entities.ImageType
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
@@ -30,14 +28,14 @@ class NowPlayingView @JvmOverloads constructor(
|
||||
val binding = ViewNowPlayingBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
|
||||
private val mediaManager by inject<MediaManager>()
|
||||
private val navigationRepository by inject<NavigationRepository>()
|
||||
private var currentDuration: String = ""
|
||||
|
||||
init {
|
||||
setPadding(0)
|
||||
|
||||
if (!isInEditMode) setOnClickListener {
|
||||
val intent = Intent(context, AudioNowPlayingActivity::class.java)
|
||||
context.startActivity(intent)
|
||||
navigationRepository.navigate(Destinations.nowPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,12 +60,12 @@ class NowPlayingView @JvmOverloads constructor(
|
||||
if (!isInEditMode) mediaManager.removeAudioEventListener(audioEventListener)
|
||||
}
|
||||
|
||||
private fun setInfo(item: BaseItemDto) {
|
||||
private fun setInfo(item: org.jellyfin.sdk.model.api.BaseItemDto) {
|
||||
val placeholder = ContextCompat.getDrawable(context, R.drawable.ic_album)
|
||||
val blurHash = item.imageBlurHashes?.get(ImageType.Primary)?.get(item.imageTags?.get(ImageType.Primary))
|
||||
val blurHash = item.imageBlurHashes?.get(org.jellyfin.sdk.model.api.ImageType.PRIMARY)?.get(item.imageTags?.get(org.jellyfin.sdk.model.api.ImageType.PRIMARY))
|
||||
binding.npIcon.load(ImageUtils.getPrimaryImageUrl(item), blurHash, placeholder, item.primaryImageAspectRatio ?: 1.0)
|
||||
|
||||
currentDuration = TimeUtils.formatMillis(if (item.runTimeTicks != null) item.runTimeTicks / 10_000 else 0)
|
||||
currentDuration = TimeUtils.formatMillis(if (item.runTimeTicks != null) item.runTimeTicks!! / 10_000 else 0)
|
||||
binding.npDesc.text = if (item.albumArtist != null) item.albumArtist else item.name
|
||||
}
|
||||
|
||||
@@ -80,7 +78,7 @@ class NowPlayingView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
private var audioEventListener: AudioEventListener = object : AudioEventListener {
|
||||
override fun onPlaybackStateChange(newState: PlaybackController.PlaybackState?, currentItem: BaseItemDto?) {
|
||||
override fun onPlaybackStateChange(newState: PlaybackController.PlaybackState, currentItem: org.jellyfin.sdk.model.api.BaseItemDto?) {
|
||||
when {
|
||||
currentItem == null -> Unit
|
||||
newState == PlaybackController.PlaybackState.PLAYING -> setInfo(currentItem)
|
||||
|
||||
@@ -20,7 +20,8 @@ import org.jellyfin.androidtv.ui.livetv.LiveTvGuide;
|
||||
import org.jellyfin.androidtv.util.InfoLayoutHelper;
|
||||
import org.jellyfin.androidtv.util.TimeUtils;
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
import org.jellyfin.androidtv.util.apiclient.BaseItemUtils;
|
||||
import org.jellyfin.androidtv.util.sdk.BaseItemExtensionsKt;
|
||||
import org.jellyfin.androidtv.util.sdk.compat.ModelCompat;
|
||||
import org.jellyfin.apiclient.model.dto.BaseItemDto;
|
||||
|
||||
import java.util.Date;
|
||||
@@ -72,7 +73,7 @@ public class ProgramGridCell extends RelativeLayout implements RecordingIndicato
|
||||
|
||||
LiveTvPreferences liveTvPreferences = get(LiveTvPreferences.class);
|
||||
|
||||
if (liveTvPreferences.get(LiveTvPreferences.Companion.getShowNewIndicator()) && BaseItemUtils.isNew(program) && (!liveTvPreferences.get(LiveTvPreferences.Companion.getShowPremiereIndicator()) || !Utils.isTrue(program.getIsPremiere()))) {
|
||||
if (liveTvPreferences.get(LiveTvPreferences.Companion.getShowNewIndicator()) && BaseItemExtensionsKt.isNew(ModelCompat.asSdk(program)) && (!liveTvPreferences.get(LiveTvPreferences.Companion.getShowPremiereIndicator()) || !Utils.isTrue(program.getIsPremiere()))) {
|
||||
InfoLayoutHelper.addBlockText(context, mInfoRow, context.getString(R.string.lbl_new), 10, Color.GRAY, R.drawable.dark_green_gradient);
|
||||
}
|
||||
|
||||
@@ -139,8 +140,6 @@ public class ProgramGridCell extends RelativeLayout implements RecordingIndicato
|
||||
} else {
|
||||
setBackgroundColor(mBackgroundColor);
|
||||
}
|
||||
|
||||
// TvApp.getApplication().getLogger().Debug("Focus on " + mProgram.getName() + " was " + (gainFocus ? "gained" : "lost"));
|
||||
}
|
||||
|
||||
public BaseItemDto getProgram() { return mProgram; }
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.jellyfin.androidtv.ui;
|
||||
|
||||
import static org.koin.java.KoinJavaComponent.inject;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Typeface;
|
||||
@@ -19,18 +20,20 @@ import android.widget.PopupWindow;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
|
||||
import org.jellyfin.androidtv.R;
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository;
|
||||
import org.jellyfin.androidtv.constant.CustomMessage;
|
||||
import org.jellyfin.androidtv.ui.shared.BaseActivity;
|
||||
import org.jellyfin.androidtv.data.repository.CustomMessageRepository;
|
||||
import org.jellyfin.androidtv.util.TimeUtils;
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
import org.jellyfin.androidtv.util.apiclient.EmptyLifecycleAwareResponse;
|
||||
import org.jellyfin.apiclient.interaction.ApiClient;
|
||||
import org.jellyfin.apiclient.interaction.EmptyResponse;
|
||||
import org.jellyfin.apiclient.interaction.Response;
|
||||
import org.jellyfin.apiclient.model.dto.BaseItemDto;
|
||||
import org.jellyfin.apiclient.model.livetv.SeriesTimerInfoDto;
|
||||
import org.jellyfin.apiclient.model.livetv.TimerInfoDto;
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto;
|
||||
import org.koin.java.KoinJavaComponent;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -49,7 +52,8 @@ public class RecordPopup {
|
||||
int mPosTop;
|
||||
boolean mRecordSeries;
|
||||
|
||||
BaseActivity mActivity;
|
||||
Activity mActivity;
|
||||
private final Lifecycle lifecycle;
|
||||
TextView mDTitle;
|
||||
LinearLayout mDTimeline;
|
||||
View mSeriesOptions;
|
||||
@@ -66,9 +70,11 @@ public class RecordPopup {
|
||||
ArrayList<Integer> mPaddingValues = new ArrayList<>(Arrays.asList(0,60,300,900,1800,3600,5400,7200,10800));
|
||||
|
||||
private Lazy<ApiClient> apiClient = inject(ApiClient.class);
|
||||
private Lazy<CustomMessageRepository> customMessageRepository = inject(CustomMessageRepository.class);
|
||||
|
||||
public RecordPopup(BaseActivity activity, View anchorView, int left, int top, int width) {
|
||||
public RecordPopup(Activity activity, Lifecycle lifecycle, View anchorView, int left, int top, int width) {
|
||||
mActivity = activity;
|
||||
this.lifecycle = lifecycle;
|
||||
mAnchorView = anchorView;
|
||||
mPosLeft = left;
|
||||
mPosTop = top;
|
||||
@@ -136,11 +142,11 @@ public class RecordPopup {
|
||||
mCurrentOptions.setRecordAnyChannel(mAnyChannel.isChecked());
|
||||
mCurrentOptions.setRecordAnyTime(mAnyTime.isChecked());
|
||||
|
||||
apiClient.getValue().UpdateLiveTvSeriesTimerAsync(mCurrentOptions, new EmptyResponse() {
|
||||
apiClient.getValue().UpdateLiveTvSeriesTimerAsync(mCurrentOptions, new EmptyLifecycleAwareResponse(lifecycle) {
|
||||
@Override
|
||||
public void onResponse() {
|
||||
mPopup.dismiss();
|
||||
mActivity.sendMessage(CustomMessage.ActionComplete);
|
||||
customMessageRepository.getValue().pushMessage(CustomMessage.ActionComplete.INSTANCE);
|
||||
Utils.showToast(mActivity, R.string.msg_settings_updated);
|
||||
}
|
||||
|
||||
@@ -159,15 +165,17 @@ public class RecordPopup {
|
||||
updated.setIsPrePaddingRequired(mCurrentOptions.getIsPrePaddingRequired());
|
||||
updated.setIsPostPaddingRequired(mCurrentOptions.getIsPostPaddingRequired());
|
||||
|
||||
apiClient.getValue().UpdateLiveTvTimerAsync(updated, new EmptyResponse() {
|
||||
apiClient.getValue().UpdateLiveTvTimerAsync(updated, new EmptyLifecycleAwareResponse(lifecycle) {
|
||||
@Override
|
||||
public void onResponse() {
|
||||
if (!getActive()) return;
|
||||
|
||||
mPopup.dismiss();
|
||||
mActivity.sendMessage(CustomMessage.ActionComplete);
|
||||
customMessageRepository.getValue().pushMessage(CustomMessage.ActionComplete.INSTANCE);
|
||||
// we have to re-retrieve the program to get the timer id
|
||||
apiClient.getValue().GetLiveTvProgramAsync(mProgramId, KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString(), new Response<BaseItemDto>() {
|
||||
apiClient.getValue().GetLiveTvProgramAsync(mProgramId, KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString(), new Response<org.jellyfin.apiclient.model.dto.BaseItemDto>() {
|
||||
@Override
|
||||
public void onResponse(BaseItemDto response) {
|
||||
public void onResponse(org.jellyfin.apiclient.model.dto.BaseItemDto response) {
|
||||
mSelectedView.setRecTimer(response.getTimerId());
|
||||
mSelectedView.setRecSeriesTimer(response.getSeriesTimerId());
|
||||
}
|
||||
@@ -177,6 +185,8 @@ public class RecordPopup {
|
||||
|
||||
@Override
|
||||
public void onError(Exception ex) {
|
||||
if (!getActive()) return;
|
||||
|
||||
Utils.showToast(mActivity, R.string.msg_unable_to_create_recording);
|
||||
}
|
||||
});
|
||||
@@ -200,7 +210,7 @@ public class RecordPopup {
|
||||
}
|
||||
|
||||
public void setContent(Context context, BaseItemDto program, SeriesTimerInfoDto current, RecordingIndicatorView selectedView, boolean recordSeries) {
|
||||
mProgramId = program.getId();
|
||||
mProgramId = program.getId().toString();
|
||||
mCurrentOptions = current;
|
||||
mRecordSeries = recordSeries;
|
||||
mSelectedView = selectedView;
|
||||
@@ -252,7 +262,7 @@ public class RecordPopup {
|
||||
timelineRow.removeAllViews();
|
||||
if (program.getStartDate() == null) return;
|
||||
|
||||
Date local = TimeUtils.convertToLocalDate(program.getStartDate());
|
||||
Date local = TimeUtils.getDate(program.getStartDate());
|
||||
TextView on = new TextView(mActivity);
|
||||
on.setText(mActivity.getString(R.string.lbl_on));
|
||||
timelineRow.addView(on);
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
package org.jellyfin.androidtv.ui;
|
||||
|
||||
public interface RecordingIndicatorView {
|
||||
public void setRecTimer(String id);
|
||||
public void setRecSeriesTimer(String id);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.jellyfin.androidtv.ui
|
||||
|
||||
interface RecordingIndicatorView {
|
||||
fun setRecTimer(id: String?)
|
||||
fun setRecSeriesTimer(id: String?)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package org.jellyfin.androidtv.ui;
|
||||
|
||||
public abstract class ScrollViewListener {
|
||||
public abstract void onScrollChanged(ObservableScrollView scrollView, int x, int y, int oldx, int oldy);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.jellyfin.androidtv.ui
|
||||
|
||||
fun interface ScrollViewListener {
|
||||
fun onScrollChanged(scrollView: ObservableScrollView?, x: Int, y: Int, oldx: Int, oldy: Int)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.jellyfin.androidtv.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.FrameLayout
|
||||
@@ -22,6 +23,7 @@ class TextUnderButton @JvmOverloads constructor(
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
descendantFocusability = FOCUS_BLOCK_DESCENDANTS
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) defaultFocusHighlightEnabled = false
|
||||
}
|
||||
|
||||
fun setLabel(text: String?) {
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package org.jellyfin.androidtv.ui.browsing;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto;
|
||||
|
||||
import kotlinx.serialization.json.Json;
|
||||
import timber.log.Timber;
|
||||
|
||||
public class BrowseFolderFragment extends StdBrowseFragment {
|
||||
protected String includeType;
|
||||
protected BaseItemDto mFolder;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
Intent intent = requireActivity().getIntent();
|
||||
|
||||
mFolder = Json.Default.decodeFromString(BaseItemDto.Companion.serializer(), intent.getStringExtra(GroupedItemsActivity.EXTRA_FOLDER));
|
||||
includeType = intent.getStringExtra(GroupedItemsActivity.EXTRA_INCLUDE_TYPE);
|
||||
MainTitle = mFolder.getName();
|
||||
ShowBadge = false;
|
||||
|
||||
Timber.d("Item type: %s", includeType);
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package org.jellyfin.androidtv.ui.browsing
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.leanback.app.BrowseSupportFragment
|
||||
import androidx.leanback.widget.HeaderItem
|
||||
import androidx.leanback.widget.ListRow
|
||||
import androidx.leanback.widget.OnItemViewClickedListener
|
||||
import androidx.leanback.widget.OnItemViewSelectedListener
|
||||
import androidx.leanback.widget.Presenter
|
||||
import androidx.leanback.widget.Row
|
||||
import androidx.leanback.widget.RowPresenter
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.constant.Extras
|
||||
import org.jellyfin.androidtv.data.service.BackgroundService
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.preference.UserPreferences.Companion.clockBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.ClockBehavior
|
||||
import org.jellyfin.androidtv.ui.ClockUserView
|
||||
import org.jellyfin.androidtv.ui.itemhandling.BaseRowItem
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter
|
||||
import org.jellyfin.androidtv.ui.presentation.CardPresenter
|
||||
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter
|
||||
import org.jellyfin.androidtv.ui.presentation.PositionableListRowPresenter
|
||||
import org.jellyfin.androidtv.util.Utils.convertDpToPixel
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
abstract class BrowseFolderFragment : BrowseSupportFragment(), RowLoader {
|
||||
protected var folder: BaseItemDto? = null
|
||||
protected var includeType: String? = null
|
||||
|
||||
protected var rows = mutableListOf<BrowseRowDef>()
|
||||
|
||||
private val userPreferences by inject<UserPreferences>()
|
||||
private val backgroundService by inject<BackgroundService>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Parse intent
|
||||
folder = Json.decodeFromString<BaseItemDto>(arguments?.getString(Extras.Folder)!!)
|
||||
includeType = arguments?.getString(Extras.IncludeType)
|
||||
|
||||
// Set BrowseSupportFragment properties
|
||||
title = folder?.name
|
||||
headersState = HEADERS_DISABLED
|
||||
|
||||
// Add event listeners
|
||||
onItemViewClickedListener = OnItemViewClickedListener { _: Presenter.ViewHolder?, item: Any?, _: RowPresenter.ViewHolder?, row: Row ->
|
||||
if (item is BaseRowItem) {
|
||||
ItemLauncher.launch(
|
||||
item,
|
||||
(row as ListRow).adapter as ItemRowAdapter,
|
||||
item.index,
|
||||
activity
|
||||
)
|
||||
}
|
||||
}
|
||||
onItemViewSelectedListener = OnItemViewSelectedListener { _: Presenter.ViewHolder?, item: Any?, _: RowPresenter.ViewHolder?, row: Row ->
|
||||
if (item !is BaseRowItem) {
|
||||
backgroundService.clearBackgrounds()
|
||||
} else {
|
||||
val adapter = (row as? ListRow)?.adapter
|
||||
if (adapter is ItemRowAdapter) adapter.loadMoreItemsIfNeeded(item.index.toLong())
|
||||
|
||||
backgroundService.setBackground(item.baseItem)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
lifecycleScope.launch {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||
setupQueries(this@BrowseFolderFragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract suspend fun setupQueries(rowLoader: RowLoader)
|
||||
|
||||
override fun loadRows(rows: MutableList<BrowseRowDef>) {
|
||||
val mutableAdapter = MutableObjectAdapter<Row>(PositionableListRowPresenter()).also {
|
||||
adapter = it
|
||||
}
|
||||
|
||||
val cardPresenter = CardPresenter()
|
||||
|
||||
for (def in rows) {
|
||||
ItemRowAdapter(
|
||||
requireContext(),
|
||||
def.query,
|
||||
def.chunkSize,
|
||||
def.preferParentThumb,
|
||||
def.isStaticHeight,
|
||||
cardPresenter,
|
||||
mutableAdapter,
|
||||
def.queryType
|
||||
).apply {
|
||||
val row = ListRow(HeaderItem(def.headerText), this)
|
||||
setReRetrieveTriggers(def.changeTriggers)
|
||||
setRow(row)
|
||||
Retrieve()
|
||||
mutableAdapter.add(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
// Check if clock should be visible
|
||||
val showClock = userPreferences[clockBehavior]
|
||||
if (showClock !== ClockBehavior.ALWAYS && showClock !== ClockBehavior.IN_MENUS) return
|
||||
|
||||
val root = requireActivity().findViewById<ViewGroup>(R.id.browse_frame)
|
||||
|
||||
// Move the title to the left to make way for the clock
|
||||
val titleView = root.findViewById<TextView>(R.id.title_text)
|
||||
if (titleView != null) {
|
||||
val layoutParams = titleView.layoutParams as FrameLayout.LayoutParams
|
||||
layoutParams.rightMargin = convertDpToPixel(root.context, 120)
|
||||
titleView.layoutParams = layoutParams
|
||||
}
|
||||
|
||||
// Add the clock element
|
||||
val layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
val clockView = ClockUserView(root.context, null)
|
||||
layoutParams.gravity = Gravity.TOP or Gravity.END
|
||||
layoutParams.rightMargin = convertDpToPixel(root.context, 40)
|
||||
layoutParams.topMargin = convertDpToPixel(root.context, 20)
|
||||
clockView.layoutParams = layoutParams
|
||||
root.addView(clockView)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,550 @@
|
||||
package org.jellyfin.androidtv.ui.browsing;
|
||||
|
||||
import static org.koin.java.KoinJavaComponent.inject;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.view.Gravity;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.PopupMenu;
|
||||
import android.widget.PopupWindow;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.leanback.widget.BaseGridView;
|
||||
import androidx.leanback.widget.OnItemViewClickedListener;
|
||||
import androidx.leanback.widget.OnItemViewSelectedListener;
|
||||
import androidx.leanback.widget.Presenter;
|
||||
import androidx.leanback.widget.Row;
|
||||
import androidx.leanback.widget.RowPresenter;
|
||||
import androidx.leanback.widget.VerticalGridPresenter;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
|
||||
import org.jellyfin.androidtv.R;
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository;
|
||||
import org.jellyfin.androidtv.constant.ChangeTriggerType;
|
||||
import org.jellyfin.androidtv.constant.CustomMessage;
|
||||
import org.jellyfin.androidtv.constant.Extras;
|
||||
import org.jellyfin.androidtv.constant.GridDirection;
|
||||
import org.jellyfin.androidtv.constant.ImageType;
|
||||
import org.jellyfin.androidtv.constant.PosterSize;
|
||||
import org.jellyfin.androidtv.constant.QueryType;
|
||||
import org.jellyfin.androidtv.data.model.FilterOptions;
|
||||
import org.jellyfin.androidtv.data.querying.StdItemQuery;
|
||||
import org.jellyfin.androidtv.data.querying.ViewQuery;
|
||||
import org.jellyfin.androidtv.data.repository.CustomMessageRepository;
|
||||
import org.jellyfin.androidtv.data.repository.UserViewsRepository;
|
||||
import org.jellyfin.androidtv.data.service.BackgroundService;
|
||||
import org.jellyfin.androidtv.databinding.HorizontalGridBrowseBinding;
|
||||
import org.jellyfin.androidtv.databinding.PopupEmptyBinding;
|
||||
import org.jellyfin.androidtv.preference.LibraryPreferences;
|
||||
import org.jellyfin.androidtv.preference.PreferencesRepository;
|
||||
import org.jellyfin.androidtv.ui.AlphaPickerView;
|
||||
import org.jellyfin.androidtv.ui.itemhandling.BaseRowItem;
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher;
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter;
|
||||
import org.jellyfin.androidtv.ui.navigation.Destinations;
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepository;
|
||||
import org.jellyfin.androidtv.ui.playback.MediaManager;
|
||||
import org.jellyfin.androidtv.ui.presentation.CardPresenter;
|
||||
import org.jellyfin.androidtv.ui.presentation.HorizontalGridPresenter;
|
||||
import org.jellyfin.androidtv.util.CoroutineUtils;
|
||||
import org.jellyfin.androidtv.util.ImageUtils;
|
||||
import org.jellyfin.androidtv.util.InfoLayoutHelper;
|
||||
import org.jellyfin.androidtv.util.KeyProcessor;
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
import org.jellyfin.androidtv.util.apiclient.EmptyLifecycleAwareResponse;
|
||||
import org.jellyfin.apiclient.model.querying.ArtistsQuery;
|
||||
import org.jellyfin.apiclient.model.querying.ItemFields;
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto;
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind;
|
||||
import org.koin.java.KoinJavaComponent;
|
||||
import org.jellyfin.sdk.model.api.SortOrder;
|
||||
import org.jellyfin.sdk.model.constant.CollectionType;
|
||||
import org.jellyfin.sdk.model.constant.ItemSortBy;
|
||||
|
||||
public class BrowseGridFragment extends StdGridFragment {
|
||||
import java.text.MessageFormat;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
import kotlin.Lazy;
|
||||
import kotlinx.serialization.json.Json;
|
||||
import timber.log.Timber;
|
||||
|
||||
public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
private final static int CHUNK_SIZE_MINIMUM = 25;
|
||||
|
||||
private String mainTitle;
|
||||
private Activity mActivity;
|
||||
private BaseRowItem mCurrentItem;
|
||||
private CompositeClickedListener mClickedListener = new CompositeClickedListener();
|
||||
private CompositeSelectedListener mSelectedListener = new CompositeSelectedListener();
|
||||
private final Handler mHandler = new Handler();
|
||||
private int mCardHeight;
|
||||
private BrowseRowDef mRowDef;
|
||||
private CardPresenter mCardPresenter;
|
||||
|
||||
private boolean justLoaded = true;
|
||||
private PosterSize mPosterSizeSetting = PosterSize.MED;
|
||||
private ImageType mImageType = ImageType.POSTER;
|
||||
private GridDirection mGridDirection = GridDirection.HORIZONTAL;
|
||||
private boolean determiningPosterSize = false;
|
||||
|
||||
private UUID mParentId;
|
||||
private BaseItemDto mFolder;
|
||||
private LibraryPreferences libraryPreferences;
|
||||
|
||||
private HorizontalGridBrowseBinding binding;
|
||||
private ItemRowAdapter mAdapter;
|
||||
private Presenter mGridPresenter;
|
||||
private Presenter.ViewHolder mGridViewHolder;
|
||||
private BaseGridView mGridView;
|
||||
private int mSelectedPosition = -1;
|
||||
private int mGridHeight = -1;
|
||||
private int mGridWidth = -1;
|
||||
private int mGridItemSpacingHorizontal = 0;
|
||||
private int mGridItemSpacingVertical = 0;
|
||||
private int mGridPaddingLeft = 0;
|
||||
private int mGridPaddingTop = 0;
|
||||
|
||||
private final Lazy<BackgroundService> backgroundService = inject(BackgroundService.class);
|
||||
private final Lazy<MediaManager> mediaManager = inject(MediaManager.class);
|
||||
private final Lazy<PreferencesRepository> preferencesRepository = inject(PreferencesRepository.class);
|
||||
private final Lazy<UserViewsRepository> userViewsRepository = inject(UserViewsRepository.class);
|
||||
private final Lazy<UserRepository> userRepository = inject(UserRepository.class);
|
||||
private final Lazy<CustomMessageRepository> customMessageRepository = inject(CustomMessageRepository.class);
|
||||
private final Lazy<NavigationRepository> navigationRepository = inject(NavigationRepository.class);
|
||||
|
||||
private int mCardsScreenEst = 0;
|
||||
private int mCardsScreenStride = 0;
|
||||
private double mCardFocusScale = 1.15; // 115%, just a default we use the resource card_scale_focus otherwise
|
||||
private final int MIN_NUM_CARDS = 5; // minimum number of visible cards we allow, this results in more empty space
|
||||
private final double CARD_SPACING_PCT = 1.0; // 100% expressed as relative to the padding_left/top, which depends on the mCardFocusScale and AspectRatio
|
||||
private final double CARD_SPACING_HORIZONTAL_BANNER_PCT = 0.5; // 50% allow horizontal card overlapping for banners, otherwise spacing is too large
|
||||
private final int MIN_GRIDSIZE_CHANGE_DELTA = 4; // minimum pixel size changes, to trigger a recreate of the grid via onGridSizeMeasurements
|
||||
private final int VIEW_SELECT_UPDATE_DELAY = 250; // delay in ms until we update the top-row info for a selected item
|
||||
|
||||
private boolean mDirty = true; // CardHeight, RowDef or GridSize changed
|
||||
|
||||
@Override
|
||||
protected void setupQueries() {
|
||||
StdItemQuery query = new StdItemQuery(new ItemFields[] {
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
// init with some working defaults
|
||||
DisplayMetrics display = requireContext().getResources().getDisplayMetrics();
|
||||
// top + bottom in dp, elements scale with density so adjust accordingly
|
||||
mGridHeight = Math.round(display.heightPixels / getResources().getDisplayMetrics().density - 130.6f);
|
||||
mGridWidth = Math.round(display.widthPixels / getResources().getDisplayMetrics().density);
|
||||
|
||||
sortOptions = new HashMap<>();
|
||||
{
|
||||
sortOptions.put(0, new SortOption(getString(R.string.lbl_name), ItemSortBy.SortName, SortOrder.ASCENDING));
|
||||
sortOptions.put(1, new SortOption(getString(R.string.lbl_date_added), ItemSortBy.DateCreated + "," + ItemSortBy.SortName, SortOrder.DESCENDING));
|
||||
sortOptions.put(2, new SortOption(getString(R.string.lbl_premier_date), ItemSortBy.PremiereDate + "," + ItemSortBy.SortName, SortOrder.DESCENDING));
|
||||
sortOptions.put(3, new SortOption(getString(R.string.lbl_rating), ItemSortBy.OfficialRating + "," + ItemSortBy.SortName, SortOrder.ASCENDING));
|
||||
sortOptions.put(4, new SortOption(getString(R.string.lbl_community_rating), ItemSortBy.CommunityRating + "," + ItemSortBy.SortName, SortOrder.DESCENDING));
|
||||
sortOptions.put(5, new SortOption(getString(R.string.lbl_critic_rating), ItemSortBy.CriticRating + "," + ItemSortBy.SortName, SortOrder.DESCENDING));
|
||||
sortOptions.put(6, new SortOption(getString(R.string.lbl_last_played), ItemSortBy.DatePlayed + "," + ItemSortBy.SortName, SortOrder.DESCENDING));
|
||||
}
|
||||
|
||||
mActivity = getActivity();
|
||||
|
||||
mFolder = Json.Default.decodeFromString(BaseItemDto.Companion.serializer(), getArguments().getString(Extras.Folder));
|
||||
mParentId = mFolder.getId();
|
||||
mainTitle = mFolder.getName();
|
||||
libraryPreferences = preferencesRepository.getValue().getLibraryPreferences(Objects.requireNonNull(mFolder.getDisplayPreferencesId()));
|
||||
mPosterSizeSetting = libraryPreferences.get(LibraryPreferences.Companion.getPosterSize());
|
||||
mImageType = libraryPreferences.get(LibraryPreferences.Companion.getImageType());
|
||||
mGridDirection = libraryPreferences.get(LibraryPreferences.Companion.getGridDirection());
|
||||
mCardFocusScale = getResources().getFraction(R.fraction.card_scale_focus, 1, 1);
|
||||
|
||||
if (mGridDirection.equals(GridDirection.VERTICAL))
|
||||
setGridPresenter(new VerticalGridPresenter());
|
||||
else
|
||||
setGridPresenter(new HorizontalGridPresenter());
|
||||
|
||||
setDefaultGridRowCols(mPosterSizeSetting, mImageType);
|
||||
setAutoCardGridValues();
|
||||
setupQueries();
|
||||
setupEventListeners();
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
|
||||
@Nullable Bundle savedInstanceState) {
|
||||
|
||||
binding = HorizontalGridBrowseBinding.inflate(inflater, container, false);
|
||||
|
||||
// Hide the description because we don't have room for it
|
||||
binding.npBug.showDescription(false);
|
||||
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
createGrid();
|
||||
loadGrid();
|
||||
addTools();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onKey(View v, int keyCode, KeyEvent event) {
|
||||
if (event.getAction() != KeyEvent.ACTION_UP) return false;
|
||||
|
||||
if (keyCode == KeyEvent.KEYCODE_MEDIA_PLAY || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) {
|
||||
mediaManager.getValue().setCurrentMediaAdapter(mAdapter);
|
||||
mediaManager.getValue().setCurrentMediaPosition(mCurrentItem.getIndex());
|
||||
mediaManager.getValue().setCurrentMediaTitle(mFolder.getName());
|
||||
}
|
||||
return KeyProcessor.HandleKey(keyCode, mCurrentItem, mActivity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
mGridView = null;
|
||||
}
|
||||
|
||||
private void createGrid() {
|
||||
mGridViewHolder = mGridPresenter.onCreateViewHolder(binding.rowsFragment);
|
||||
if (mGridViewHolder instanceof HorizontalGridPresenter.ViewHolder) {
|
||||
mGridView = ((HorizontalGridPresenter.ViewHolder) mGridViewHolder).getGridView();
|
||||
mGridView.setGravity(Gravity.CENTER_VERTICAL);
|
||||
mGridView.setPadding(mGridPaddingLeft, mGridPaddingTop, mGridPaddingLeft, mGridPaddingTop); // prevent initial card cutoffs
|
||||
} else if (mGridViewHolder instanceof VerticalGridPresenter.ViewHolder) {
|
||||
mGridView = ((VerticalGridPresenter.ViewHolder) mGridViewHolder).getGridView();
|
||||
mGridView.setGravity(Gravity.CENTER_HORIZONTAL);
|
||||
mGridView.setPadding(mGridPaddingLeft, mGridPaddingTop, mGridPaddingLeft, mGridPaddingTop); // prevent initial card cutoffs
|
||||
}
|
||||
mGridView.setHorizontalSpacing(mGridItemSpacingHorizontal);
|
||||
mGridView.setVerticalSpacing(mGridItemSpacingVertical);
|
||||
mGridView.setFocusable(true);
|
||||
binding.rowsFragment.removeAllViews();
|
||||
binding.rowsFragment.addView(mGridViewHolder.view);
|
||||
|
||||
updateAdapter();
|
||||
}
|
||||
|
||||
private void updateAdapter() {
|
||||
if (mGridView != null) {
|
||||
mGridPresenter.onBindViewHolder(mGridViewHolder, mAdapter);
|
||||
if (mSelectedPosition != -1) {
|
||||
mGridView.setSelectedPosition(mSelectedPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the grid presenter.
|
||||
*/
|
||||
public void setGridPresenter(HorizontalGridPresenter gridPresenter) {
|
||||
if (gridPresenter == null) {
|
||||
throw new IllegalArgumentException("Grid presenter may not be null");
|
||||
}
|
||||
gridPresenter.setOnItemViewSelectedListener(mRowSelectedListener);
|
||||
gridPresenter.setOnItemViewClickedListener(mClickedListener);
|
||||
mGridPresenter = gridPresenter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the grid presenter.
|
||||
*/
|
||||
public void setGridPresenter(VerticalGridPresenter gridPresenter) {
|
||||
if (gridPresenter == null) {
|
||||
throw new IllegalArgumentException("Grid presenter may not be null");
|
||||
}
|
||||
gridPresenter.setOnItemViewSelectedListener(mRowSelectedListener);
|
||||
gridPresenter.setOnItemViewClickedListener(mClickedListener);
|
||||
mGridPresenter = gridPresenter;
|
||||
}
|
||||
|
||||
|
||||
public void setItem(BaseRowItem item) {
|
||||
if (item != null) {
|
||||
binding.title.setText(item.getFullName(requireContext()));
|
||||
InfoLayoutHelper.addInfoRow(requireContext(), item, binding.infoRow, true, true);
|
||||
} else {
|
||||
binding.title.setText("");
|
||||
binding.infoRow.removeAllViews();
|
||||
}
|
||||
}
|
||||
|
||||
public class SortOption {
|
||||
public String name;
|
||||
public String value;
|
||||
public SortOrder order;
|
||||
|
||||
public SortOption(String name, String value, SortOrder order) {
|
||||
this.name = name;
|
||||
this.value = value;
|
||||
this.order = order;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<Integer, SortOption> sortOptions;
|
||||
|
||||
private SortOption getSortOption(String value) {
|
||||
for (Integer key : sortOptions.keySet()) {
|
||||
SortOption option = sortOptions.get(key);
|
||||
if (Objects.requireNonNull(option).value.equals(value)) return option;
|
||||
}
|
||||
|
||||
return new SortOption("Unknown", "", SortOrder.ASCENDING);
|
||||
}
|
||||
|
||||
public void setStatusText(String folderName) {
|
||||
String text = getString(R.string.lbl_showing) + " ";
|
||||
FilterOptions filters = mAdapter.getFilters();
|
||||
if (filters == null || (!filters.isFavoriteOnly() && !filters.isUnwatchedOnly())) {
|
||||
text += getString(R.string.lbl_all_items);
|
||||
} else {
|
||||
text += (filters.isUnwatchedOnly() ? getString(R.string.lbl_unwatched) : "") + " " +
|
||||
(filters.isFavoriteOnly() ? getString(R.string.lbl_favorites) : "");
|
||||
}
|
||||
|
||||
if (mAdapter.getStartLetter() != null) {
|
||||
text += " " + getString(R.string.lbl_starting_with) + " " + mAdapter.getStartLetter();
|
||||
}
|
||||
|
||||
text += " " + getString(R.string.lbl_from) + " '" + folderName + "' " + getString(R.string.lbl_sorted_by) + " " + getSortOption(mAdapter.getSortBy()).name;
|
||||
|
||||
binding.statusText.setText(text);
|
||||
}
|
||||
|
||||
final private OnItemViewSelectedListener mRowSelectedListener =
|
||||
new OnItemViewSelectedListener() {
|
||||
@Override
|
||||
public void onItemSelected(Presenter.ViewHolder itemViewHolder, Object item,
|
||||
RowPresenter.ViewHolder rowViewHolder, Row row) {
|
||||
int position = mGridView.getSelectedPosition();
|
||||
Timber.d("row selected position %s", position);
|
||||
if (position != mSelectedPosition) {
|
||||
mSelectedPosition = position;
|
||||
}
|
||||
// Update the counter
|
||||
updateCounter(position + 1);
|
||||
if (position >= 0) {
|
||||
mSelectedListener.onItemSelected(itemViewHolder, item, rowViewHolder, row);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public void updateCounter(int position) {
|
||||
if (mAdapter != null) {
|
||||
binding.counter.setText(MessageFormat.format("{0} | {1}", position, mAdapter.getTotalItems()));
|
||||
}
|
||||
}
|
||||
|
||||
private void setRowDef(final BrowseRowDef rowDef) {
|
||||
if (mRowDef == null || mRowDef.hashCode() != rowDef.hashCode()) {
|
||||
mDirty = true;
|
||||
}
|
||||
mRowDef = rowDef;
|
||||
}
|
||||
|
||||
private double getCardWidthBy(final double cardHeight, ImageType imageType, BaseItemDto folder) {
|
||||
switch (imageType) {
|
||||
case POSTER:
|
||||
// special handling for square posters
|
||||
BaseItemKind fType = folder.getType();
|
||||
if (fType == BaseItemKind.AUDIO || fType == BaseItemKind.GENRE || fType == BaseItemKind.MUSIC_ALBUM || fType == BaseItemKind.MUSIC_ARTIST || fType == BaseItemKind.MUSIC_GENRE) {
|
||||
return cardHeight;
|
||||
} else if (fType == BaseItemKind.COLLECTION_FOLDER && CollectionType.Music.equals(folder.getCollectionType())) {
|
||||
return cardHeight;
|
||||
} else {
|
||||
return cardHeight * ImageUtils.ASPECT_RATIO_2_3;
|
||||
}
|
||||
case THUMB:
|
||||
return cardHeight * ImageUtils.ASPECT_RATIO_16_9;
|
||||
case BANNER:
|
||||
return cardHeight * CardPresenter.ASPECT_RATIO_BANNER;
|
||||
default:
|
||||
throw new IllegalStateException("Unexpected value: " + imageType);
|
||||
}
|
||||
}
|
||||
|
||||
private double getCardHeightBy(final double cardWidth, ImageType imageType, BaseItemDto folder) {
|
||||
switch (imageType) {
|
||||
case POSTER:
|
||||
// special handling for square posters
|
||||
BaseItemKind fType = folder.getType();
|
||||
if (fType == BaseItemKind.AUDIO || fType == BaseItemKind.GENRE || fType == BaseItemKind.MUSIC_ALBUM || fType == BaseItemKind.MUSIC_ARTIST || fType == BaseItemKind.MUSIC_GENRE) {
|
||||
return cardWidth;
|
||||
} else if (fType == BaseItemKind.COLLECTION_FOLDER && CollectionType.Music.equals(folder.getCollectionType())) {
|
||||
return cardWidth;
|
||||
} else {
|
||||
return cardWidth / ImageUtils.ASPECT_RATIO_2_3;
|
||||
}
|
||||
case THUMB:
|
||||
return cardWidth / ImageUtils.ASPECT_RATIO_16_9;
|
||||
case BANNER:
|
||||
return cardWidth / CardPresenter.ASPECT_RATIO_BANNER;
|
||||
default:
|
||||
throw new IllegalArgumentException("Unexpected value: " + imageType);
|
||||
}
|
||||
}
|
||||
|
||||
private void setDefaultGridRowCols(PosterSize posterSize, ImageType imageType) {
|
||||
// HINT: use uneven Rows/Cols if possible, so selected middle lines up with TV middle!
|
||||
if (mGridPresenter instanceof VerticalGridPresenter) {
|
||||
int numCols;
|
||||
switch (posterSize) {
|
||||
case SMALLEST:
|
||||
numCols = imageType.equals(ImageType.BANNER) ? 6 : imageType.equals(ImageType.THUMB) ? 11 : 15;
|
||||
break;
|
||||
case SMALL:
|
||||
numCols = imageType.equals(ImageType.BANNER) ? 5 : imageType.equals(ImageType.THUMB) ? 9 : 13;
|
||||
break;
|
||||
case MED:
|
||||
numCols = imageType.equals(ImageType.BANNER) ? 4 : imageType.equals(ImageType.THUMB) ? 7 : 11;
|
||||
break;
|
||||
case LARGE:
|
||||
numCols = imageType.equals(ImageType.BANNER) ? 3 : imageType.equals(ImageType.THUMB) ? 5 : 7;
|
||||
break;
|
||||
case X_LARGE:
|
||||
numCols = imageType.equals(ImageType.BANNER) ? 2 : imageType.equals(ImageType.THUMB) ? 3 : 5;
|
||||
break;
|
||||
default:
|
||||
throw new IllegalStateException("Unexpected value: " + mPosterSizeSetting);
|
||||
}
|
||||
((VerticalGridPresenter) mGridPresenter).setNumberOfColumns(numCols);
|
||||
} else if (mGridPresenter instanceof HorizontalGridPresenter) {
|
||||
int numRows;
|
||||
switch (posterSize) {
|
||||
case SMALLEST:
|
||||
numRows = imageType.equals(ImageType.BANNER) ? 13 : imageType.equals(ImageType.THUMB) ? 7 : 5;
|
||||
break;
|
||||
case SMALL:
|
||||
numRows = imageType.equals(ImageType.BANNER) ? 11 : imageType.equals(ImageType.THUMB) ? 6 : 4;
|
||||
break;
|
||||
case MED:
|
||||
numRows = imageType.equals(ImageType.BANNER) ? 9 : imageType.equals(ImageType.THUMB) ? 5 : 3;
|
||||
break;
|
||||
case LARGE:
|
||||
numRows = imageType.equals(ImageType.BANNER) ? 7 : imageType.equals(ImageType.THUMB) ? 4 : 2;
|
||||
break;
|
||||
case X_LARGE:
|
||||
numRows = imageType.equals(ImageType.BANNER) ? 5 : imageType.equals(ImageType.THUMB) ? 2 : 1;
|
||||
break;
|
||||
default:
|
||||
throw new IllegalStateException("Unexpected value: " + mPosterSizeSetting);
|
||||
}
|
||||
((HorizontalGridPresenter) mGridPresenter).setNumberOfRows(numRows);
|
||||
}
|
||||
}
|
||||
|
||||
private void setAutoCardGridValues() {
|
||||
if (mGridPresenter == null) {
|
||||
Timber.e("Invalid presenter, cant calculate CardGridValues!");
|
||||
return;
|
||||
}
|
||||
double cardScaling = Math.max(mCardFocusScale - 1.0, 0.0);
|
||||
int cardHeightInt = 100;
|
||||
int spacingHorizontalInt = 0;
|
||||
int spacingVerticalInt = 0;
|
||||
int paddingLeftInt = 0;
|
||||
int paddingTopInt = 0;
|
||||
int numRows = 0;
|
||||
int numCols = 0;
|
||||
int numCardsScreen = 0; // number of cards visible, including cutoff's
|
||||
|
||||
if (mGridPresenter instanceof HorizontalGridPresenter) {
|
||||
numRows = ((HorizontalGridPresenter) mGridPresenter).getNumberOfRows();
|
||||
if (numRows == 1) { // reduce size so minimal cards are shown
|
||||
numRows = 0;
|
||||
numCols = MIN_NUM_CARDS;
|
||||
}
|
||||
} else if (mGridPresenter instanceof VerticalGridPresenter) {
|
||||
numCols = ((VerticalGridPresenter) mGridPresenter).getNumberOfColumns();
|
||||
}
|
||||
|
||||
if (numRows > 0) {
|
||||
double paddingPct = cardScaling / numRows;
|
||||
double spaceingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numRows - 1);
|
||||
|
||||
double wastedSpacePct = paddingPct + spaceingPct;
|
||||
double useableCardSpace = mGridHeight / (1.0 + wastedSpacePct); // decrease size
|
||||
double cardHeight = useableCardSpace / numRows;
|
||||
|
||||
// fix any rounding errors and make pixel perfect
|
||||
cardHeightInt = (int) Math.round(cardHeight);
|
||||
double cardPaddingTopBottomAdj = cardHeightInt * cardScaling;
|
||||
spacingVerticalInt = Math.max((int) (Math.round((cardPaddingTopBottomAdj / 2.0) * CARD_SPACING_PCT)), 0); // round spacing
|
||||
int paddingTopBottomInt = mGridHeight - ((cardHeightInt * numRows) + (spacingVerticalInt * (numRows - 1)));
|
||||
paddingTopInt = Math.max(paddingTopBottomInt / 2, 0);
|
||||
|
||||
int sumSize = (cardHeightInt * numRows) + (spacingVerticalInt * (numRows - 1)) + (paddingTopInt * 2);
|
||||
if (Math.abs(sumSize - mGridHeight) > 2) {
|
||||
Timber.w("setAutoCardGridValues calculation delta > 2, something is off GridHeight <%s> sumSize <%s>!", mGridHeight, sumSize);
|
||||
}
|
||||
int cardWidthInt = (int) getCardWidthBy(cardHeightInt, mImageType, mFolder);
|
||||
paddingLeftInt = (int) Math.round((cardWidthInt * cardScaling) / 2.0);
|
||||
spacingHorizontalInt = Math.max((int) (Math.round(paddingLeftInt * CARD_SPACING_PCT)), 0); // round spacing
|
||||
if (mImageType == ImageType.BANNER) {
|
||||
spacingHorizontalInt = Math.max((int) (Math.round(paddingLeftInt * CARD_SPACING_HORIZONTAL_BANNER_PCT)), 0); // round spacing
|
||||
}
|
||||
int cardsCol = (int) Math.round(((double) mGridWidth / (cardWidthInt + spacingHorizontalInt)) + 0.5);
|
||||
mCardsScreenEst = numRows * cardsCol;
|
||||
mCardsScreenStride = numRows;
|
||||
} else if (numCols > 0) {
|
||||
double paddingPct = cardScaling / numCols;
|
||||
double spaceingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numCols - 1);
|
||||
if (mImageType == ImageType.BANNER) {
|
||||
spaceingPct = ((paddingPct / 2.0) * CARD_SPACING_HORIZONTAL_BANNER_PCT) * (numCols - 1);
|
||||
}
|
||||
|
||||
double wastedSpacePct = paddingPct + spaceingPct;
|
||||
double useableCardSpace = mGridWidth / (1.0 + wastedSpacePct); // decrease size
|
||||
double cardWidth = useableCardSpace / numCols;
|
||||
|
||||
// fix any rounding errors and make pixel perfect
|
||||
cardHeightInt = (int) Math.round(getCardHeightBy(cardWidth, mImageType, mFolder));
|
||||
int cardWidthInt = (int) getCardWidthBy(cardHeightInt, mImageType, mFolder);
|
||||
double cardPaddingLeftRightAdj = cardWidthInt * cardScaling;
|
||||
spacingHorizontalInt = Math.max((int) (Math.round((cardPaddingLeftRightAdj / 2.0) * CARD_SPACING_PCT)), 0); // round spacing
|
||||
if (mImageType == ImageType.BANNER) {
|
||||
spacingHorizontalInt = Math.max((int) (Math.round((cardPaddingLeftRightAdj / 2.0) * CARD_SPACING_HORIZONTAL_BANNER_PCT)), 0); // round spacing
|
||||
}
|
||||
int paddingLeftRightInt = mGridWidth - ((cardWidthInt * numCols) + (spacingHorizontalInt * (numCols - 1)));
|
||||
paddingLeftInt = Math.max(paddingLeftRightInt / 2, 0);
|
||||
|
||||
int sumSize = (cardWidthInt * numCols) + (spacingHorizontalInt * (numCols - 1)) + (paddingLeftInt * 2);
|
||||
if (Math.abs(sumSize - mGridWidth) > 2) {
|
||||
Timber.w("setAutoCardGridValues calculation delta > 2, something is off GridWidth <%s> sumSize <%s>!", mGridWidth, sumSize);
|
||||
}
|
||||
paddingTopInt = (int) Math.round((cardHeightInt * cardScaling) / 2.0);
|
||||
spacingVerticalInt = Math.max((int) (Math.round(paddingTopInt * CARD_SPACING_PCT)), 0); // round spacing
|
||||
int cardsRow = (int) Math.round(((double) mGridHeight / (cardHeightInt + spacingVerticalInt)) + 0.5);
|
||||
mCardsScreenEst = numCols * cardsRow;
|
||||
mCardsScreenStride = numCols;
|
||||
}
|
||||
|
||||
Timber.d("numCardsScreen <%s>", numCardsScreen);
|
||||
|
||||
if (mCardHeight != cardHeightInt) {
|
||||
mDirty = true;
|
||||
}
|
||||
mCardHeight = cardHeightInt;
|
||||
mGridItemSpacingHorizontal = spacingHorizontalInt;
|
||||
mGridItemSpacingVertical = spacingVerticalInt;
|
||||
mGridPaddingLeft = paddingLeftInt;
|
||||
mGridPaddingTop = paddingTopInt;
|
||||
}
|
||||
|
||||
private void setupQueries() {
|
||||
StdItemQuery query = new StdItemQuery(new ItemFields[]{
|
||||
ItemFields.PrimaryImageAspectRatio,
|
||||
ItemFields.ChildCount,
|
||||
ItemFields.MediaSources,
|
||||
@@ -25,32 +555,32 @@ public class BrowseGridFragment extends StdGridFragment {
|
||||
if (mFolder.getType() == BaseItemKind.USER_VIEW || mFolder.getType() == BaseItemKind.COLLECTION_FOLDER) {
|
||||
String type = mFolder.getCollectionType() != null ? mFolder.getCollectionType().toLowerCase() : "";
|
||||
switch (type) {
|
||||
case "movies":
|
||||
case CollectionType.Movies:
|
||||
query.setIncludeItemTypes(new String[]{"Movie"});
|
||||
query.setRecursive(true);
|
||||
break;
|
||||
case "tvshows":
|
||||
case CollectionType.TvShows:
|
||||
query.setIncludeItemTypes(new String[]{"Series"});
|
||||
query.setRecursive(true);
|
||||
break;
|
||||
case "boxsets":
|
||||
case CollectionType.BoxSets:
|
||||
query.setIncludeItemTypes(new String[]{"BoxSet"});
|
||||
query.setParentId(null);
|
||||
query.setRecursive(true);
|
||||
break;
|
||||
case "music":
|
||||
case CollectionType.Music:
|
||||
//Special queries needed for album artists
|
||||
String includeType = requireActivity().getIntent().getStringExtra(Extras.IncludeType);
|
||||
String includeType = getArguments().getString(Extras.IncludeType);
|
||||
if ("AlbumArtist".equals(includeType)) {
|
||||
ArtistsQuery albumArtists = new ArtistsQuery();
|
||||
albumArtists.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
|
||||
albumArtists.setUserId(userRepository.getValue().getCurrentUser().getValue().getId().toString());
|
||||
albumArtists.setFields(new ItemFields[]{
|
||||
ItemFields.PrimaryImageAspectRatio,
|
||||
ItemFields.ItemCounts,
|
||||
ItemFields.ChildCount
|
||||
});
|
||||
albumArtists.setParentId(mParentId.toString());
|
||||
setRowDef(new BrowseRowDef("", albumArtists, CHUNK_SIZE_MINIMUM, new ChangeTriggerType[] {}));
|
||||
setRowDef(new BrowseRowDef("", albumArtists, CHUNK_SIZE_MINIMUM, new ChangeTriggerType[]{}));
|
||||
return;
|
||||
}
|
||||
query.setIncludeItemTypes(new String[]{includeType != null ? includeType : "MusicAlbum"});
|
||||
@@ -61,4 +591,393 @@ public class BrowseGridFragment extends StdGridFragment {
|
||||
|
||||
setRowDef(new BrowseRowDef("", query, CHUNK_SIZE_MINIMUM, false, true));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
|
||||
PosterSize posterSizeSetting = libraryPreferences.get(LibraryPreferences.Companion.getPosterSize());
|
||||
ImageType imageType = libraryPreferences.get(LibraryPreferences.Companion.getImageType());
|
||||
GridDirection gridDirection = libraryPreferences.get(LibraryPreferences.Companion.getGridDirection());
|
||||
|
||||
|
||||
if (mImageType != imageType || mPosterSizeSetting != posterSizeSetting || mGridDirection != gridDirection || mDirty) {
|
||||
determiningPosterSize = true;
|
||||
|
||||
mImageType = imageType;
|
||||
mPosterSizeSetting = posterSizeSetting;
|
||||
mGridDirection = gridDirection;
|
||||
|
||||
if (mGridDirection.equals(GridDirection.VERTICAL) && (mGridPresenter == null || !(mGridPresenter instanceof VerticalGridPresenter))) {
|
||||
setGridPresenter(new VerticalGridPresenter());
|
||||
} else if (mGridDirection.equals(GridDirection.HORIZONTAL) && (mGridPresenter == null || !(mGridPresenter instanceof HorizontalGridPresenter))) {
|
||||
setGridPresenter(new HorizontalGridPresenter());
|
||||
}
|
||||
setDefaultGridRowCols(mPosterSizeSetting, mImageType);
|
||||
setAutoCardGridValues();
|
||||
createGrid();
|
||||
loadGrid();
|
||||
determiningPosterSize = false;
|
||||
}
|
||||
|
||||
if (!justLoaded) {
|
||||
//Re-retrieve anything that needs it but delay slightly so we don't take away gui landing
|
||||
if (mAdapter != null) {
|
||||
mHandler.postDelayed(() -> {
|
||||
if (!getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED))
|
||||
return;
|
||||
|
||||
if (mAdapter != null && mAdapter.size() > 0) {
|
||||
if (!mAdapter.ReRetrieveIfNeeded()) {
|
||||
refreshCurrentItem();
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
} else {
|
||||
justLoaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void buildAdapter() {
|
||||
mCardPresenter = new CardPresenter(false, mImageType, mCardHeight);
|
||||
mCardPresenter.setUniformAspect(true); // make grid layouts always uniform
|
||||
|
||||
Timber.d("buildAdapter cardHeight <%s> getCardWidthBy <%s> chunks <%s> type <%s>", mCardHeight, (int) getCardWidthBy(mCardHeight, mImageType, mFolder), mRowDef.getChunkSize(), mRowDef.getQueryType().toString());
|
||||
|
||||
// adapt chunk size if needed
|
||||
int chunkSize = mRowDef.getChunkSize();
|
||||
if (mCardsScreenEst > 0 && mCardsScreenEst >= chunkSize) {
|
||||
chunkSize = Math.min(mCardsScreenEst + mCardsScreenStride, 150); // cap at 150
|
||||
Timber.d("buildAdapter adjusting chunkSize to <%s> screenEst <%s>", chunkSize, mCardsScreenEst);
|
||||
}
|
||||
|
||||
switch (mRowDef.getQueryType()) {
|
||||
case NextUp:
|
||||
mAdapter = new ItemRowAdapter(requireContext(), mRowDef.getNextUpQuery(), true, mCardPresenter, null);
|
||||
break;
|
||||
case Season:
|
||||
mAdapter = new ItemRowAdapter(requireContext(), mRowDef.getSeasonQuery(), mCardPresenter, null);
|
||||
break;
|
||||
case Upcoming:
|
||||
mAdapter = new ItemRowAdapter(requireContext(), mRowDef.getUpcomingQuery(), mCardPresenter, null);
|
||||
break;
|
||||
case Views:
|
||||
mAdapter = new ItemRowAdapter(requireContext(), ViewQuery.INSTANCE, mCardPresenter, null);
|
||||
break;
|
||||
case SimilarSeries:
|
||||
mAdapter = new ItemRowAdapter(requireContext(), mRowDef.getSimilarQuery(), QueryType.SimilarSeries, mCardPresenter, null);
|
||||
break;
|
||||
case SimilarMovies:
|
||||
mAdapter = new ItemRowAdapter(requireContext(), mRowDef.getSimilarQuery(), QueryType.SimilarMovies, mCardPresenter, null);
|
||||
break;
|
||||
case Persons:
|
||||
mAdapter = new ItemRowAdapter(requireContext(), mRowDef.getPersonsQuery(), chunkSize, mCardPresenter, null);
|
||||
break;
|
||||
case LiveTvChannel:
|
||||
mAdapter = new ItemRowAdapter(requireContext(), mRowDef.getTvChannelQuery(), 40, mCardPresenter, null);
|
||||
break;
|
||||
case LiveTvProgram:
|
||||
mAdapter = new ItemRowAdapter(requireContext(), mRowDef.getProgramQuery(), mCardPresenter, null);
|
||||
break;
|
||||
case LiveTvRecording:
|
||||
mAdapter = new ItemRowAdapter(requireContext(), mRowDef.getRecordingQuery(), chunkSize, mCardPresenter, null);
|
||||
break;
|
||||
case AlbumArtists:
|
||||
mAdapter = new ItemRowAdapter(requireContext(), mRowDef.getArtistsQuery(), chunkSize, mCardPresenter, null);
|
||||
break;
|
||||
default:
|
||||
mAdapter = new ItemRowAdapter(requireContext(), mRowDef.getQuery(), chunkSize, mRowDef.getPreferParentThumb(), mRowDef.isStaticHeight(), mCardPresenter, null);
|
||||
break;
|
||||
}
|
||||
mDirty = false;
|
||||
|
||||
FilterOptions filters = new FilterOptions();
|
||||
filters.setFavoriteOnly(libraryPreferences.get(LibraryPreferences.Companion.getFilterFavoritesOnly()));
|
||||
filters.setUnwatchedOnly(libraryPreferences.get(LibraryPreferences.Companion.getFilterUnwatchedOnly()));
|
||||
|
||||
mAdapter.setRetrieveFinishedListener(new EmptyLifecycleAwareResponse(getLifecycle()) {
|
||||
@Override
|
||||
public void onResponse() {
|
||||
if (!getActive()) return;
|
||||
|
||||
setStatusText(mFolder.getName());
|
||||
if (mCurrentItem == null) { // don't mess-up pos via loadMoreItemsIfNeeded
|
||||
setItem(null);
|
||||
updateCounter(mAdapter.getTotalItems() > 0 ? 1 : 0);
|
||||
}
|
||||
mLetterButton.setVisibility(ItemSortBy.SortName.equals(mAdapter.getSortBy()) ? View.VISIBLE : View.GONE);
|
||||
if (mAdapter.getTotalItems() == 0) {
|
||||
binding.toolBar.requestFocus();
|
||||
mHandler.postDelayed(() -> {
|
||||
if (!getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED))
|
||||
return;
|
||||
|
||||
binding.title.setText(mFolder.getName());
|
||||
}, 500);
|
||||
} else {
|
||||
if (mGridView != null) mGridView.requestFocus();
|
||||
}
|
||||
}
|
||||
});
|
||||
mAdapter.setFilters(filters);
|
||||
|
||||
updateAdapter();
|
||||
}
|
||||
|
||||
public void loadGrid() {
|
||||
if (mCardPresenter == null || mAdapter == null || mDirty) {
|
||||
buildAdapter();
|
||||
}
|
||||
|
||||
mAdapter.setSortBy(getSortOption(libraryPreferences.get(LibraryPreferences.Companion.getSortBy())));
|
||||
mAdapter.Retrieve();
|
||||
}
|
||||
|
||||
private ImageButton mSortButton;
|
||||
private ImageButton mSettingsButton;
|
||||
private ImageButton mUnwatchedButton;
|
||||
private ImageButton mFavoriteButton;
|
||||
private ImageButton mLetterButton;
|
||||
|
||||
private void updateDisplayPrefs() {
|
||||
libraryPreferences.set(LibraryPreferences.Companion.getFilterFavoritesOnly(), mAdapter.getFilters().isFavoriteOnly());
|
||||
libraryPreferences.set(LibraryPreferences.Companion.getFilterUnwatchedOnly(), mAdapter.getFilters().isUnwatchedOnly());
|
||||
libraryPreferences.set(LibraryPreferences.Companion.getSortBy(), mAdapter.getSortBy());
|
||||
libraryPreferences.set(LibraryPreferences.Companion.getSortOrder(), getSortOption(mAdapter.getSortBy()).order);
|
||||
CoroutineUtils.runBlocking((coroutineScope, continuation) -> libraryPreferences.commit(continuation));
|
||||
}
|
||||
|
||||
private void addTools() {
|
||||
//Add tools
|
||||
int size = Utils.convertDpToPixel(requireContext(), 26);
|
||||
|
||||
mSortButton = new ImageButton(requireContext(), null, 0, R.style.Button_Icon);
|
||||
mSortButton.setImageResource(R.drawable.ic_sort);
|
||||
mSortButton.setMaxHeight(size);
|
||||
mSortButton.setAdjustViewBounds(true);
|
||||
mSortButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
//Create sort menu
|
||||
PopupMenu sortMenu = new PopupMenu(getActivity(), binding.toolBar, Gravity.END);
|
||||
for (Integer key : sortOptions.keySet()) {
|
||||
SortOption option = sortOptions.get(key);
|
||||
if (option == null) option = sortOptions.get(0);
|
||||
MenuItem item = sortMenu.getMenu().add(0, key, key, Objects.requireNonNull(option).name);
|
||||
if (option.value.equals(libraryPreferences.get(LibraryPreferences.Companion.getSortBy())))
|
||||
item.setChecked(true);
|
||||
}
|
||||
sortMenu.getMenu().setGroupCheckable(0, true, true);
|
||||
sortMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
|
||||
@Override
|
||||
public boolean onMenuItemClick(MenuItem item) {
|
||||
mAdapter.setSortBy(Objects.requireNonNull(sortOptions.get(item.getItemId())));
|
||||
mAdapter.Retrieve();
|
||||
item.setChecked(true);
|
||||
updateDisplayPrefs();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
sortMenu.show();
|
||||
}
|
||||
});
|
||||
mSortButton.setContentDescription(getString(R.string.lbl_sort_by));
|
||||
|
||||
binding.toolBar.addView(mSortButton);
|
||||
|
||||
if (mRowDef.getQueryType() == QueryType.Items) {
|
||||
mUnwatchedButton = new ImageButton(requireContext(), null, 0, R.style.Button_Icon);
|
||||
mUnwatchedButton.setImageResource(R.drawable.ic_unwatch);
|
||||
mUnwatchedButton.setActivated(mAdapter.getFilters().isUnwatchedOnly());
|
||||
mUnwatchedButton.setMaxHeight(size);
|
||||
mUnwatchedButton.setAdjustViewBounds(true);
|
||||
mUnwatchedButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
FilterOptions filters = mAdapter.getFilters();
|
||||
if (filters == null) filters = new FilterOptions();
|
||||
|
||||
filters.setUnwatchedOnly(!filters.isUnwatchedOnly());
|
||||
mUnwatchedButton.setActivated(filters.isUnwatchedOnly());
|
||||
mAdapter.setFilters(filters);
|
||||
mAdapter.Retrieve();
|
||||
updateDisplayPrefs();
|
||||
}
|
||||
});
|
||||
mUnwatchedButton.setContentDescription(getString(R.string.lbl_unwatched));
|
||||
binding.toolBar.addView(mUnwatchedButton);
|
||||
}
|
||||
|
||||
mFavoriteButton = new ImageButton(requireContext(), null, 0, R.style.Button_Icon);
|
||||
mFavoriteButton.setImageResource(R.drawable.ic_heart);
|
||||
mFavoriteButton.setActivated(mAdapter.getFilters().isFavoriteOnly());
|
||||
mFavoriteButton.setMaxHeight(size);
|
||||
mFavoriteButton.setAdjustViewBounds(true);
|
||||
mFavoriteButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
FilterOptions filters = mAdapter.getFilters();
|
||||
if (filters == null) filters = new FilterOptions();
|
||||
|
||||
filters.setFavoriteOnly(!filters.isFavoriteOnly());
|
||||
mFavoriteButton.setActivated(filters.isFavoriteOnly());
|
||||
mAdapter.setFilters(filters);
|
||||
mAdapter.Retrieve();
|
||||
updateDisplayPrefs();
|
||||
}
|
||||
});
|
||||
mFavoriteButton.setContentDescription(getString(R.string.lbl_favorite));
|
||||
binding.toolBar.addView(mFavoriteButton);
|
||||
|
||||
JumplistPopup jumplistPopup = new JumplistPopup();
|
||||
mLetterButton = new ImageButton(requireContext(), null, 0, R.style.Button_Icon);
|
||||
mLetterButton.setImageResource(R.drawable.ic_jump_letter);
|
||||
mLetterButton.setMaxHeight(size);
|
||||
mLetterButton.setAdjustViewBounds(true);
|
||||
mLetterButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
//Open letter jump popup
|
||||
jumplistPopup.show();
|
||||
}
|
||||
});
|
||||
mLetterButton.setContentDescription(getString(R.string.lbl_by_letter));
|
||||
binding.toolBar.addView(mLetterButton);
|
||||
|
||||
mSettingsButton = new ImageButton(requireContext(), null, 0, R.style.Button_Icon);
|
||||
mSettingsButton.setImageResource(R.drawable.ic_settings);
|
||||
mSettingsButton.setMaxHeight(size);
|
||||
mSettingsButton.setAdjustViewBounds(true);
|
||||
mSettingsButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
boolean allowViewSelection = userViewsRepository.getValue().allowViewSelection(mFolder.getCollectionType());
|
||||
navigationRepository.getValue().navigate(Destinations.INSTANCE.displayPreferences(mFolder.getDisplayPreferencesId(), allowViewSelection));
|
||||
}
|
||||
});
|
||||
mSettingsButton.setContentDescription(getString(R.string.lbl_settings));
|
||||
binding.toolBar.addView(mSettingsButton);
|
||||
}
|
||||
|
||||
class JumplistPopup {
|
||||
private final int WIDTH = Utils.convertDpToPixel(requireContext(), 900);
|
||||
private final int HEIGHT = Utils.convertDpToPixel(requireContext(), 55);
|
||||
|
||||
private final PopupWindow popupWindow;
|
||||
private final AlphaPickerView alphaPicker;
|
||||
|
||||
JumplistPopup() {
|
||||
PopupEmptyBinding layout = PopupEmptyBinding.inflate(getLayoutInflater(), binding.rowsFragment, false);
|
||||
popupWindow = new PopupWindow(layout.emptyPopup, WIDTH, HEIGHT, true);
|
||||
popupWindow.setOutsideTouchable(true);
|
||||
popupWindow.setAnimationStyle(R.style.WindowAnimation_SlideTop);
|
||||
|
||||
alphaPicker = new AlphaPickerView(requireContext(), null);
|
||||
alphaPicker.setOnAlphaSelected(letter -> {
|
||||
mAdapter.setStartLetter(letter.toString());
|
||||
loadGrid();
|
||||
dismiss();
|
||||
return null;
|
||||
});
|
||||
|
||||
layout.emptyPopup.addView(alphaPicker);
|
||||
}
|
||||
|
||||
public void show() {
|
||||
popupWindow.showAtLocation(binding.rowsFragment, Gravity.TOP, binding.rowsFragment.getLeft(), binding.rowsFragment.getTop());
|
||||
if (mAdapter.getStartLetter() != null && !mAdapter.getStartLetter().isEmpty()) {
|
||||
alphaPicker.focus(mAdapter.getStartLetter().charAt(0));
|
||||
}
|
||||
}
|
||||
|
||||
public void dismiss() {
|
||||
if (popupWindow != null && popupWindow.isShowing()) {
|
||||
popupWindow.dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void setupEventListeners() {
|
||||
if (mGridPresenter != null) {
|
||||
if (mGridPresenter instanceof HorizontalGridPresenter)
|
||||
((HorizontalGridPresenter) mGridPresenter).setOnItemViewClickedListener(mClickedListener);
|
||||
else if (mGridPresenter instanceof VerticalGridPresenter)
|
||||
((VerticalGridPresenter) mGridPresenter).setOnItemViewClickedListener(mClickedListener);
|
||||
}
|
||||
mClickedListener.registerListener(new ItemViewClickedListener());
|
||||
mSelectedListener.registerListener(new ItemViewSelectedListener());
|
||||
|
||||
CoroutineUtils.readCustomMessagesOnLifecycle(getLifecycle(), customMessageRepository.getValue(), message -> {
|
||||
if (message.equals(CustomMessage.RefreshCurrentItem.INSTANCE)) refreshCurrentItem();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private void refreshCurrentItem() {
|
||||
if (mCurrentItem != null && mCurrentItem.getBaseItemType() != BaseItemKind.PHOTO && mCurrentItem.getBaseItemType() != BaseItemKind.PHOTO_ALBUM
|
||||
&& mCurrentItem.getBaseItemType() != BaseItemKind.MUSIC_ARTIST && mCurrentItem.getBaseItemType() != BaseItemKind.MUSIC_ALBUM) {
|
||||
Timber.d("Refresh item \"%s\"", mCurrentItem.getFullName(requireContext()));
|
||||
mCurrentItem.refresh(new EmptyLifecycleAwareResponse(getLifecycle()) {
|
||||
@Override
|
||||
public void onResponse() {
|
||||
if (!getActive()) return;
|
||||
|
||||
mAdapter.notifyItemRangeChanged(mAdapter.indexOf(mCurrentItem), 1);
|
||||
//Now - if filtered make sure we still pass
|
||||
if (mAdapter.getFilters() != null) {
|
||||
if ((mAdapter.getFilters().isFavoriteOnly() && !mCurrentItem.isFavorite()) || (mAdapter.getFilters().isUnwatchedOnly() && mCurrentItem.isPlayed())) {
|
||||
// if we are about to remove the current item, throw focus to toolbar so framework doesn't crash
|
||||
binding.toolBar.requestFocus();
|
||||
mAdapter.remove(mCurrentItem);
|
||||
mAdapter.setTotalItems(mAdapter.getTotalItems() - 1);
|
||||
updateCounter(mCurrentItem.getIndex());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private final class ItemViewClickedListener implements OnItemViewClickedListener {
|
||||
@Override
|
||||
public void onItemClicked(final Presenter.ViewHolder itemViewHolder, Object item,
|
||||
RowPresenter.ViewHolder rowViewHolder, Row row) {
|
||||
|
||||
if (!(item instanceof BaseRowItem)) return;
|
||||
ItemLauncher.launch((BaseRowItem) item, mAdapter, ((BaseRowItem) item).getIndex(), getActivity());
|
||||
}
|
||||
}
|
||||
|
||||
private final Runnable mDelayedSetItem = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (!getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) return;
|
||||
|
||||
backgroundService.getValue().setBackground(mCurrentItem.getBaseItem());
|
||||
setItem(mCurrentItem);
|
||||
}
|
||||
};
|
||||
|
||||
private final class ItemViewSelectedListener implements OnItemViewSelectedListener {
|
||||
@Override
|
||||
public void onItemSelected(Presenter.ViewHolder itemViewHolder, Object item,
|
||||
RowPresenter.ViewHolder rowViewHolder, Row row) {
|
||||
mHandler.removeCallbacks(mDelayedSetItem);
|
||||
if (!(item instanceof BaseRowItem)) {
|
||||
mCurrentItem = null;
|
||||
binding.title.setText(mainTitle);
|
||||
//fill in default background
|
||||
backgroundService.getValue().clearBackgrounds();
|
||||
} else {
|
||||
mCurrentItem = (BaseRowItem) item;
|
||||
binding.title.setText(mCurrentItem.getName(requireContext()));
|
||||
binding.infoRow.removeAllViews();
|
||||
mHandler.postDelayed(mDelayedSetItem, VIEW_SELECT_UPDATE_DELAY);
|
||||
|
||||
if (!determiningPosterSize)
|
||||
mAdapter.loadMoreItemsIfNeeded(mCurrentItem.getIndex());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package org.jellyfin.androidtv.ui.browsing
|
||||
|
||||
import android.os.Bundle
|
||||
import org.jellyfin.androidtv.ui.shared.BaseActivity
|
||||
|
||||
class BrowseRecordingsActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(android.R.id.content, BrowseRecordingsFragment())
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,30 @@
|
||||
package org.jellyfin.androidtv.ui.browsing;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.leanback.widget.ArrayObjectAdapter;
|
||||
import androidx.leanback.widget.HeaderItem;
|
||||
import androidx.leanback.widget.ListRow;
|
||||
import androidx.leanback.widget.Row;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
|
||||
import org.jellyfin.androidtv.R;
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository;
|
||||
import org.jellyfin.androidtv.ui.GridButton;
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter;
|
||||
import org.jellyfin.androidtv.ui.presentation.GridButtonPresenter;
|
||||
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter;
|
||||
import org.jellyfin.androidtv.util.TimeUtils;
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
import org.jellyfin.androidtv.util.apiclient.LifecycleAwareResponse;
|
||||
import org.jellyfin.apiclient.interaction.ApiClient;
|
||||
import org.jellyfin.apiclient.interaction.Response;
|
||||
import org.jellyfin.apiclient.model.dto.BaseItemDto;
|
||||
import org.jellyfin.apiclient.model.dto.BaseItemType;
|
||||
import org.jellyfin.apiclient.model.entities.LocationType;
|
||||
import org.jellyfin.apiclient.model.livetv.RecordingGroupQuery;
|
||||
import org.jellyfin.apiclient.model.livetv.RecordingQuery;
|
||||
import org.jellyfin.apiclient.model.livetv.TimerInfoDto;
|
||||
import org.jellyfin.apiclient.model.livetv.TimerQuery;
|
||||
@@ -37,10 +43,16 @@ public class BrowseRecordingsFragment extends EnhancedBrowseFragment {
|
||||
super.onResume();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
mTitle.setText(getString(R.string.lbl_loading_elipses));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setupQueries(final RowLoader rowLoader) {
|
||||
showViews = true;
|
||||
mTitle.setText(getString(R.string.lbl_loading_elipses));
|
||||
//Latest Recordings
|
||||
RecordingQuery recordings = new RecordingQuery();
|
||||
recordings.setFields(new ItemFields[]{
|
||||
@@ -51,7 +63,7 @@ public class BrowseRecordingsFragment extends EnhancedBrowseFragment {
|
||||
recordings.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
|
||||
recordings.setEnableImages(true);
|
||||
recordings.setLimit(40);
|
||||
mRows.add(new BrowseRowDef(mActivity.getString(R.string.lbl_recent_recordings), recordings, 40));
|
||||
mRows.add(new BrowseRowDef(getString(R.string.lbl_recent_recordings), recordings, 40));
|
||||
|
||||
//Movies
|
||||
RecordingQuery movies = new RecordingQuery();
|
||||
@@ -63,7 +75,7 @@ public class BrowseRecordingsFragment extends EnhancedBrowseFragment {
|
||||
movies.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
|
||||
movies.setEnableImages(true);
|
||||
movies.setIsMovie(true);
|
||||
BrowseRowDef moviesDef = new BrowseRowDef(mActivity.getString(R.string.lbl_movies), movies, 60);
|
||||
BrowseRowDef moviesDef = new BrowseRowDef(getString(R.string.lbl_movies), movies, 60);
|
||||
|
||||
//Shows
|
||||
RecordingQuery shows = new RecordingQuery();
|
||||
@@ -75,7 +87,7 @@ public class BrowseRecordingsFragment extends EnhancedBrowseFragment {
|
||||
shows.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
|
||||
shows.setEnableImages(true);
|
||||
shows.setIsSeries(true);
|
||||
BrowseRowDef showsDef = new BrowseRowDef(mActivity.getString(R.string.lbl_tv_series), shows, 60);
|
||||
BrowseRowDef showsDef = new BrowseRowDef(getString(R.string.lbl_tv_series), shows, 60);
|
||||
|
||||
mRows.add(showsDef);
|
||||
mRows.add(moviesDef);
|
||||
@@ -90,7 +102,7 @@ public class BrowseRecordingsFragment extends EnhancedBrowseFragment {
|
||||
sports.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
|
||||
sports.setEnableImages(true);
|
||||
sports.setIsSports(true);
|
||||
mRows.add(new BrowseRowDef(mActivity.getString(R.string.lbl_sports), sports, 60));
|
||||
mRows.add(new BrowseRowDef(getString(R.string.lbl_sports), sports, 60));
|
||||
|
||||
//Kids
|
||||
RecordingQuery kids = new RecordingQuery();
|
||||
@@ -102,12 +114,7 @@ public class BrowseRecordingsFragment extends EnhancedBrowseFragment {
|
||||
kids.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
|
||||
kids.setEnableImages(true);
|
||||
kids.setIsKids(true);
|
||||
mRows.add(new BrowseRowDef(mActivity.getString(R.string.lbl_kids), kids, 60));
|
||||
|
||||
//All Recordings by group - will only be there for non-internal TV
|
||||
RecordingGroupQuery recordingGroups = new RecordingGroupQuery();
|
||||
recordingGroups.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
|
||||
mRows.add(new BrowseRowDef(mActivity.getString(R.string.lbl_all_recordings), recordingGroups));
|
||||
mRows.add(new BrowseRowDef(getString(R.string.lbl_kids), kids, 60));
|
||||
|
||||
rowLoader.loadRows(mRows);
|
||||
addNext24Timers();
|
||||
@@ -116,9 +123,11 @@ public class BrowseRecordingsFragment extends EnhancedBrowseFragment {
|
||||
private void addNext24Timers() {
|
||||
final TimerQuery scheduled = new TimerQuery();
|
||||
final long ticks24 = 1000 * 60 * 60 * 24;
|
||||
KoinJavaComponent.<ApiClient>get(ApiClient.class).GetLiveTvTimersAsync(scheduled, new Response<TimerInfoDtoResult>() {
|
||||
KoinJavaComponent.<ApiClient>get(ApiClient.class).GetLiveTvTimersAsync(scheduled, new LifecycleAwareResponse<TimerInfoDtoResult>(getLifecycle()) {
|
||||
@Override
|
||||
public void onResponse(TimerInfoDtoResult response) {
|
||||
if (!getActive()) return;
|
||||
|
||||
List<BaseItemDto> nearTimers = new ArrayList<>();
|
||||
long next24 = System.currentTimeMillis() + ticks24;
|
||||
//Get scheduled items for next 24 hours
|
||||
@@ -149,6 +158,8 @@ public class BrowseRecordingsFragment extends EnhancedBrowseFragment {
|
||||
new Handler().postDelayed(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (!getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) return;
|
||||
|
||||
mRowsFragment.setSelectedPosition(0, true);
|
||||
}
|
||||
}, 500);
|
||||
@@ -157,20 +168,22 @@ public class BrowseRecordingsFragment extends EnhancedBrowseFragment {
|
||||
|
||||
@Override
|
||||
public void onError(Exception exception) {
|
||||
Utils.showToast(getContext(), exception.getLocalizedMessage());
|
||||
}
|
||||
if (!getActive()) return;
|
||||
|
||||
Utils.showToast(getContext(), exception.getLocalizedMessage());
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addAdditionalRows(ArrayObjectAdapter rowAdapter) {
|
||||
protected void addAdditionalRows(MutableObjectAdapter<Row> rowAdapter) {
|
||||
HeaderItem gridHeader = new HeaderItem(rowAdapter.size(), getString(R.string.lbl_views));
|
||||
|
||||
GridButtonPresenter mGridPresenter = new GridButtonPresenter();
|
||||
ArrayObjectAdapter gridRowAdapter = new ArrayObjectAdapter(mGridPresenter);
|
||||
gridRowAdapter.add(new GridButton(SCHEDULE, getString(R.string.lbl_schedule), R.drawable.tile_port_time));
|
||||
gridRowAdapter.add(new GridButton(SERIES, mActivity.getString(R.string.lbl_series_recordings), R.drawable.tile_port_series_timer));
|
||||
gridRowAdapter.add(new GridButton(SERIES, getString(R.string.lbl_series_recordings), R.drawable.tile_port_series_timer));
|
||||
rowAdapter.add(new ListRow(gridHeader, gridRowAdapter));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import org.jellyfin.androidtv.constant.QueryType;
|
||||
import org.jellyfin.androidtv.data.querying.ViewQuery;
|
||||
import org.jellyfin.apiclient.model.livetv.LiveTvChannelQuery;
|
||||
import org.jellyfin.apiclient.model.livetv.RecommendedProgramQuery;
|
||||
import org.jellyfin.apiclient.model.livetv.RecordingGroupQuery;
|
||||
import org.jellyfin.apiclient.model.livetv.RecordingQuery;
|
||||
import org.jellyfin.apiclient.model.livetv.SeriesTimerQuery;
|
||||
import org.jellyfin.apiclient.model.querying.ArtistsQuery;
|
||||
@@ -16,6 +15,7 @@ import org.jellyfin.apiclient.model.querying.PersonsQuery;
|
||||
import org.jellyfin.apiclient.model.querying.SeasonQuery;
|
||||
import org.jellyfin.apiclient.model.querying.SimilarItemsQuery;
|
||||
import org.jellyfin.apiclient.model.querying.UpcomingEpisodesQuery;
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest;
|
||||
|
||||
public class BrowseRowDef {
|
||||
private String headerText;
|
||||
@@ -30,11 +30,11 @@ public class BrowseRowDef {
|
||||
private LiveTvChannelQuery tvChannelQuery;
|
||||
private RecommendedProgramQuery programQuery;
|
||||
private RecordingQuery recordingQuery;
|
||||
private RecordingGroupQuery recordingGroupQuery;
|
||||
private SeriesTimerQuery seriesTimerQuery;
|
||||
|
||||
private ArtistsQuery artistsQuery;
|
||||
private SeasonQuery seasonQuery;
|
||||
private GetResumeItemsRequest resumeQuery;
|
||||
private QueryType queryType;
|
||||
|
||||
private int chunkSize = 0;
|
||||
@@ -145,12 +145,6 @@ public class BrowseRowDef {
|
||||
this.queryType = QueryType.LiveTvRecording;
|
||||
}
|
||||
|
||||
public BrowseRowDef(String header, RecordingGroupQuery query) {
|
||||
headerText = header;
|
||||
this.recordingGroupQuery = query;
|
||||
this.queryType = QueryType.LiveTvRecordingGroup;
|
||||
}
|
||||
|
||||
public BrowseRowDef(String header, PersonsQuery query, int chunkSize) {
|
||||
headerText = header;
|
||||
this.personsQuery = query;
|
||||
@@ -182,6 +176,16 @@ public class BrowseRowDef {
|
||||
this.queryType = QueryType.Views;
|
||||
}
|
||||
|
||||
public BrowseRowDef(String header, GetResumeItemsRequest query, int chunkSize, boolean preferParentThumb, boolean staticHeight, ChangeTriggerType[] changeTriggers) {
|
||||
headerText = header;
|
||||
this.resumeQuery = query;
|
||||
this.chunkSize = chunkSize;
|
||||
this.queryType = QueryType.Resume;
|
||||
this.staticHeight = staticHeight;
|
||||
this.preferParentThumb = preferParentThumb;
|
||||
this.changeTriggers = changeTriggers;
|
||||
}
|
||||
|
||||
public int getChunkSize() {
|
||||
return chunkSize;
|
||||
}
|
||||
@@ -238,11 +242,9 @@ public class BrowseRowDef {
|
||||
|
||||
public SeriesTimerQuery getSeriesTimerQuery() { return seriesTimerQuery; }
|
||||
|
||||
public GetResumeItemsRequest getResumeQuery() { return resumeQuery; }
|
||||
|
||||
public ChangeTriggerType[] getChangeTriggers() {
|
||||
return changeTriggers;
|
||||
}
|
||||
|
||||
public RecordingGroupQuery getRecordingGroupQuery() {
|
||||
return recordingGroupQuery;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user