Compare commits
394 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4bd3afd1b | ||
|
|
006a6d008f | ||
|
|
2fea54658e | ||
|
|
33f11515c9 | ||
|
|
f4a291f38c | ||
|
|
eff17801bd | ||
|
|
5eabe9f43d | ||
|
|
5c3d296580 | ||
|
|
b3068e52f7 | ||
|
|
33d24175aa | ||
|
|
79a97c3f4a | ||
|
|
e60c2a49de | ||
|
|
952a53a54b | ||
|
|
1ec062fcd4 | ||
|
|
f90ef25386 | ||
|
|
c734f30bd9 | ||
|
|
d16615796d | ||
|
|
a37995f770 | ||
|
|
9e45391850 | ||
|
|
3a4a4ada1c | ||
|
|
1caaf3de67 | ||
|
|
4928d26d8b | ||
|
|
82089d2734 | ||
|
|
b16f4b8667 | ||
|
|
50088ef5cd | ||
|
|
57b918433f | ||
|
|
8c2caf627f | ||
|
|
b2711084c7 | ||
|
|
7807bb32f2 | ||
|
|
2218246478 | ||
|
|
0ec4d4e4bf | ||
|
|
1a95759d59 | ||
|
|
e83cef2b26 | ||
|
|
b876c4b782 | ||
|
|
300346feb8 | ||
|
|
ee1403aaad | ||
|
|
a572710fd7 | ||
|
|
003b4148cf | ||
|
|
52d844e281 | ||
|
|
f7994d0e38 | ||
|
|
ea33799059 | ||
|
|
487104fdf8 | ||
|
|
59b4bac902 | ||
|
|
67467ec0ed | ||
|
|
307bad3ecc | ||
|
|
93227bd5b0 | ||
|
|
6bb5055136 | ||
|
|
3e059e5857 | ||
|
|
2ba2b70b85 | ||
|
|
9732b3a232 | ||
|
|
af52c4d2d0 | ||
|
|
169d094471 | ||
|
|
87e4e7e0ee | ||
|
|
ae19a4c862 | ||
|
|
f969f019d3 | ||
|
|
e7cbb2077b | ||
|
|
b64ed6a3b2 | ||
|
|
9e508cca75 | ||
|
|
590b813b99 | ||
|
|
ed097cb2b9 | ||
|
|
2544af5c7d | ||
|
|
5cf636c35d | ||
|
|
5c6b798d86 | ||
|
|
06d3fd07d0 | ||
|
|
20484a21be | ||
|
|
69a5e5100f | ||
|
|
d3063d7738 | ||
|
|
fa2a7bbca8 | ||
|
|
9acf4e3800 | ||
|
|
91a27ee0ef | ||
|
|
15554bd8f2 | ||
|
|
17ed07c7f7 | ||
|
|
8f77b68346 | ||
|
|
72163a48aa | ||
|
|
53d982eff7 | ||
|
|
5fb69dcb3c | ||
|
|
3109684eab | ||
|
|
a98e131760 | ||
|
|
b74b63bb0a | ||
|
|
baf313c710 | ||
|
|
5914d752b8 | ||
|
|
28ffa78b8f | ||
|
|
c49e7d289c | ||
|
|
56d0064319 | ||
|
|
18ff2a6ce3 | ||
|
|
77ef06de34 | ||
|
|
e1867b23e7 | ||
|
|
f21a9022d5 | ||
|
|
de883fb709 | ||
|
|
f6e65fadac | ||
|
|
fc03e95d23 | ||
|
|
0d00adc8a6 | ||
|
|
f474e2a753 | ||
|
|
ef236c8a55 | ||
|
|
873f66c256 | ||
|
|
1e7c8bbf38 | ||
|
|
0dae580732 | ||
|
|
98f84c630c | ||
|
|
ef25757b45 | ||
|
|
ef4e2b56d7 | ||
|
|
b0d5a788dc | ||
|
|
2f14d77ed1 | ||
|
|
bc4f246b66 | ||
|
|
3f96c3e7b9 | ||
|
|
1939569320 | ||
|
|
0f97f5a95b | ||
|
|
3657ad707e | ||
|
|
0c378dd7ff | ||
|
|
44ddb3b1b8 | ||
|
|
1bfc00a4d0 | ||
|
|
e41d1405ca | ||
|
|
69a4d13081 | ||
|
|
26a73b9282 | ||
|
|
84f7a26017 | ||
|
|
b616a94287 | ||
|
|
f6c5e6f467 | ||
|
|
a7f51b5a2c | ||
|
|
322985c054 | ||
|
|
0568361b6b | ||
|
|
b3beb8a92d | ||
|
|
1062d7b844 | ||
|
|
e7738c7bc1 | ||
|
|
e44b36e830 | ||
|
|
2b4a5b6f2e | ||
|
|
67d470f961 | ||
|
|
81aa372e40 | ||
|
|
059140f7a2 | ||
|
|
cae32a2015 | ||
|
|
3b3f5e2e42 | ||
|
|
8e1ce880a8 | ||
|
|
f88dd54bcf | ||
|
|
786110b52e | ||
|
|
7b51b848ca | ||
|
|
829334a147 | ||
|
|
b128fa32b9 | ||
|
|
175d04d5ad | ||
|
|
4e67e51271 | ||
|
|
dabb514d17 | ||
|
|
4d0a56aa68 | ||
|
|
b49a09425f | ||
|
|
0ac521096f | ||
|
|
8cdeb65345 | ||
|
|
fbfd681917 | ||
|
|
9ea1cc58a7 | ||
|
|
662b1825c1 | ||
|
|
0cd34ba163 | ||
|
|
24120146ec | ||
|
|
d96e23d001 | ||
|
|
354acf5bf5 | ||
|
|
7f480ea7fc | ||
|
|
e04824ce5e | ||
|
|
d6d48ca78a | ||
|
|
016de2d087 | ||
|
|
c1e8653da8 | ||
|
|
746c662bb9 | ||
|
|
79f6bc9683 | ||
|
|
acf8af6441 | ||
|
|
b4e4029c86 | ||
|
|
957f3643bf | ||
|
|
f97dd87984 | ||
|
|
cade3fd6bf | ||
|
|
19dc038087 | ||
|
|
1957185e18 | ||
|
|
73384b6b9a | ||
|
|
4ba02728c2 | ||
|
|
8902e63a31 | ||
|
|
288c01befb | ||
|
|
f7ff9f768a | ||
|
|
bec60ed4eb | ||
|
|
c509955f21 | ||
|
|
26f43ced62 | ||
|
|
24acb71eee | ||
|
|
e1db0523ee | ||
|
|
84d7639f3e | ||
|
|
8e13a51b68 | ||
|
|
bf4198a8b8 | ||
|
|
dd4bafd5a4 | ||
|
|
2d9cdb7d6f | ||
|
|
f458d3fdf3 | ||
|
|
0537a2031e | ||
|
|
4f23ad47b6 | ||
|
|
dede52b978 | ||
|
|
8fe6d807e3 | ||
|
|
90cc4dc658 | ||
|
|
8acd8c1e69 | ||
|
|
8574947aa1 | ||
|
|
85bbb5daaa | ||
|
|
961c858d55 | ||
|
|
ea397196a7 | ||
|
|
fbcb96f051 | ||
|
|
b9d57641d2 | ||
|
|
10b0d33fc7 | ||
|
|
c522f09cc2 | ||
|
|
095721e021 | ||
|
|
d90827c524 | ||
|
|
1ac4d49e2b | ||
|
|
ef5b764ae2 | ||
|
|
e43492ffa5 | ||
|
|
8096632a79 | ||
|
|
cd1623c6f3 | ||
|
|
50ad7b30b9 | ||
|
|
505cdcd723 | ||
|
|
025a55ca08 | ||
|
|
2e9182a868 | ||
|
|
64924719ba | ||
|
|
dc9f36285c | ||
|
|
64b7fb1e79 | ||
|
|
50848441ac | ||
|
|
939ec5a1c0 | ||
|
|
187bc9c5d1 | ||
|
|
9478600764 | ||
|
|
70fbbcd9db | ||
|
|
f4401578de | ||
|
|
ef4082cb38 | ||
|
|
c6ed49c0ac | ||
|
|
7955cbddb7 | ||
|
|
cbda5142e8 | ||
|
|
b02b7925da | ||
|
|
eb4389f32f | ||
|
|
c993be8450 | ||
|
|
0cfffdbe3d | ||
|
|
d5bb7327f7 | ||
|
|
56eb00197c | ||
|
|
b219f46994 | ||
|
|
241139d5cf | ||
|
|
2c7bd27525 | ||
|
|
30fcb192da | ||
|
|
fdde857be6 | ||
|
|
a607cb4a83 | ||
|
|
70654a2720 | ||
|
|
0c5eda9572 | ||
|
|
3e944fa2bc | ||
|
|
90573f610f | ||
|
|
64ffa02250 | ||
|
|
8d5414ce5c | ||
|
|
ecc5d47146 | ||
|
|
c5ce43c95b | ||
|
|
a3e8482d3e | ||
|
|
d3834f5e73 | ||
|
|
579186d661 | ||
|
|
7b61de171d | ||
|
|
fe66a61ce0 | ||
|
|
208e7c4ee1 | ||
|
|
a3965fcce6 | ||
|
|
249a3f6bbe | ||
|
|
50eee051c3 | ||
|
|
03a0001770 | ||
|
|
933fdbcca4 | ||
|
|
9c6c9c4315 | ||
|
|
949c7e7198 | ||
|
|
2efe6622b9 | ||
|
|
1281785513 | ||
|
|
bcc9462514 | ||
|
|
6d3b27ebac | ||
|
|
f4df2fd1d7 | ||
|
|
0dae1535b2 | ||
|
|
a6a34ffff0 | ||
|
|
14d67e446d | ||
|
|
51cedee9b3 | ||
|
|
715a15a5ba | ||
|
|
9ecea3088f | ||
|
|
a87ea6c060 | ||
|
|
706b64573a | ||
|
|
cd8e619ddc | ||
|
|
8d6d3b3a35 | ||
|
|
25354395ed | ||
|
|
3a6b619aa4 | ||
|
|
309d2dc532 | ||
|
|
1d0e2b0f66 | ||
|
|
0557df1faa | ||
|
|
7684efbf0b | ||
|
|
a4e14dbf45 | ||
|
|
32c3b80008 | ||
|
|
05249719b4 | ||
|
|
987bda9918 | ||
|
|
13f6a70fa5 | ||
|
|
e460c6e403 | ||
|
|
86d6195a06 | ||
|
|
c1df5a69aa | ||
|
|
ea4289b1ef | ||
|
|
7357694c8e | ||
|
|
1d79a8bafe | ||
|
|
dd450c607d | ||
|
|
d64c35c6f9 | ||
|
|
f521abf7bd | ||
|
|
144e501bd2 | ||
|
|
80f2de16b1 | ||
|
|
13f95d5f60 | ||
|
|
990c8d4ad8 | ||
|
|
b7753deb2f | ||
|
|
0c88593d30 | ||
|
|
a304403fc0 | ||
|
|
1235eb1ff5 | ||
|
|
2b1143a362 | ||
|
|
be43311978 | ||
|
|
3916c2a117 | ||
|
|
7c9dd0001e | ||
|
|
b116db0d49 | ||
|
|
b362ac42ff | ||
|
|
de2a27052e | ||
|
|
fe05b82b95 | ||
|
|
50ef4a7718 | ||
|
|
49bcd6fe2c | ||
|
|
c03bec878b | ||
|
|
8cca27238f | ||
|
|
09027c2bc5 | ||
|
|
7297086045 | ||
|
|
f2a47d1c9a | ||
|
|
1f789ac00f | ||
|
|
1199fa5253 | ||
|
|
a9ff992926 | ||
|
|
a4ec607543 | ||
|
|
299751156d | ||
|
|
f8eb6468d4 | ||
|
|
77cc5902bb | ||
|
|
a143dcbec9 | ||
|
|
c1c144f813 | ||
|
|
ae2d74b263 | ||
|
|
c025b62f85 | ||
|
|
5f181b9ded | ||
|
|
ff52e2a9a7 | ||
|
|
e65492c254 | ||
|
|
9ac58e22fb | ||
|
|
cc080fe93c | ||
|
|
2e3e8f258c | ||
|
|
9577e11e41 | ||
|
|
6111cbec5a | ||
|
|
c7a4c89b74 | ||
|
|
35300e605b | ||
|
|
b0cd0c5b37 | ||
|
|
aa5fb63b71 | ||
|
|
d661583a86 | ||
|
|
fe31c19f15 | ||
|
|
fcd9751d82 | ||
|
|
f8c5345871 | ||
|
|
213fa1fcf8 | ||
|
|
157352cf3a | ||
|
|
b5c5478c76 | ||
|
|
0385f63fbe | ||
|
|
6cc1031b8a | ||
|
|
3269a5283d | ||
|
|
587f0fb35a | ||
|
|
1f7c6cc4d3 | ||
|
|
e307b3baa0 | ||
|
|
a8cb3a595c | ||
|
|
044ce67110 | ||
|
|
f6c536880d | ||
|
|
409b34238a | ||
|
|
6ca4b4679c | ||
|
|
5bb74f689a | ||
|
|
daa4188887 | ||
|
|
3b852a031e | ||
|
|
7aa4d2e5fa | ||
|
|
8c3be44664 | ||
|
|
7bc99d2e66 | ||
|
|
9ecf952351 | ||
|
|
2e78a483aa | ||
|
|
e8a81799e2 | ||
|
|
be06693978 | ||
|
|
0f38bd1691 | ||
|
|
a8c9f054ab | ||
|
|
4f3c6b331d | ||
|
|
61b4178dec | ||
|
|
9aa1759ae7 | ||
|
|
2e55987939 | ||
|
|
d3f3b38c31 | ||
|
|
f8cf1f48a3 | ||
|
|
691a8f8ac5 | ||
|
|
af6df123d2 | ||
|
|
dad6570d28 | ||
|
|
8abfc9ebd2 | ||
|
|
8aabf8dc9a | ||
|
|
31abdc6060 | ||
|
|
954491718e | ||
|
|
bb550630dd | ||
|
|
1007e22d97 | ||
|
|
ec6161bc97 | ||
|
|
f1204ccbb6 | ||
|
|
77b27e490e | ||
|
|
6d1fac382b | ||
|
|
c24a7dcc3a | ||
|
|
461a6939cd | ||
|
|
ce13ff4c11 | ||
|
|
70fe0ecd2c | ||
|
|
0630bbd9ec | ||
|
|
40b941ce4b | ||
|
|
ab2298feff | ||
|
|
ed481e6492 | ||
|
|
3f96cab559 | ||
|
|
47ffc9f8ee | ||
|
|
8b14af8770 | ||
|
|
4e8fd15508 | ||
|
|
8230479059 | ||
|
|
96e39c34f3 |
12
.github/workflows/app-build.yaml
vendored
12
.github/workflows/app-build.yaml
vendored
@@ -12,23 +12,23 @@ permissions:
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
|
||||
uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
|
||||
uses: gradle/actions/setup-gradle@cc4fc85e6b35bafd578d5ffbc76a5518407e1af0 # v4.2.1
|
||||
- name: Assemble debug APKs
|
||||
run: ./gradlew assembleDebug
|
||||
- name: Create publish bundle
|
||||
run: mkdir -p build/gh-app-publish/; find app/build/ -iname "*.apk" -exec mv "{}" build/gh-app-publish/ \;
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: build-artifacts
|
||||
retention-days: 14
|
||||
|
||||
12
.github/workflows/app-lint.yaml
vendored
12
.github/workflows/app-lint.yaml
vendored
@@ -14,21 +14,21 @@ permissions:
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
|
||||
uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
|
||||
uses: gradle/actions/setup-gradle@cc4fc85e6b35bafd578d5ffbc76a5518407e1af0 # v4.2.1
|
||||
- name: Run detekt and lint tasks
|
||||
run: ./gradlew detekt lint
|
||||
- name: Upload SARIF files
|
||||
uses: github/codeql-action/upload-sarif@5cf07d8b700b67e235fbb65cbc84f69c0cf10464 # v3.25.14
|
||||
uses: github/codeql-action/upload-sarif@aa578102511db1f4524ed59b8cc2bae4f6e88195 # v3.27.6
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
sarif_file: .
|
||||
|
||||
12
.github/workflows/app-publish.yaml
vendored
12
.github/workflows/app-publish.yaml
vendored
@@ -8,18 +8,18 @@ on:
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
if: ${{ contains(github.repository_owner, 'jellyfin') }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
|
||||
uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
|
||||
uses: gradle/actions/setup-gradle@cc4fc85e6b35bafd578d5ffbc76a5518407e1af0 # v4.2.1
|
||||
- name: Set JELLYFIN_VERSION
|
||||
run: echo "JELLYFIN_VERSION=$(echo ${GITHUB_REF#refs/tags/v} | tr / -)" >> $GITHUB_ENV
|
||||
- name: Assemble release files
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
remote_user: ${{ secrets.REPO_USER }}
|
||||
remote_key: ${{ secrets.REPO_KEY }}
|
||||
- name: Update repo.jellyfin.org symlinks
|
||||
uses: appleboy/ssh-action@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 # v1.0.3
|
||||
uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0
|
||||
with:
|
||||
host: ${{ secrets.REPO_HOST }}
|
||||
username: ${{ secrets.REPO_USER }}
|
||||
|
||||
10
.github/workflows/app-test.yaml
vendored
10
.github/workflows/app-test.yaml
vendored
@@ -13,16 +13,16 @@ permissions:
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
|
||||
uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
|
||||
uses: gradle/actions/setup-gradle@cc4fc85e6b35bafd578d5ffbc76a5518407e1af0 # v4.2.1
|
||||
- name: Run test task
|
||||
run: ./gradlew test
|
||||
|
||||
6
.github/workflows/gradlew-validate.yaml
vendored
6
.github/workflows/gradlew-validate.yaml
vendored
@@ -14,9 +14,9 @@ permissions:
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
|
||||
uses: gradle/actions/wrapper-validation@cc4fc85e6b35bafd578d5ffbc76a5518407e1af0 # v4.2.1
|
||||
|
||||
2
.github/workflows/repo-merge-conflict.yaml
vendored
2
.github/workflows/repo-merge-conflict.yaml
vendored
@@ -9,7 +9,7 @@ on:
|
||||
jobs:
|
||||
triage:
|
||||
name: Triage
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
if: ${{ contains(github.repository_owner, 'jellyfin') }}
|
||||
steps:
|
||||
- uses: eps1lon/actions-label-merge-conflict@1b1b1fcde06a9b3d089f3464c96417961dde1168 # v3.0.2
|
||||
|
||||
2
.github/workflows/repo-stale.yaml
vendored
2
.github/workflows/repo-stale.yaml
vendored
@@ -12,7 +12,7 @@ permissions:
|
||||
jobs:
|
||||
triage:
|
||||
name: Triage
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
if: ${{ contains(github.repository_owner, 'jellyfin') }}
|
||||
steps:
|
||||
- uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- [sparky3387](https://github.com/sparky3387)
|
||||
- [mohd-akram](https://github.com/mohd-akram)
|
||||
- [3l0w](https://github.com/3l0w)
|
||||
- [MajMongoose](https://github.com/majmongoose)
|
||||
|
||||
# Emby Contributors
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ dependencies {
|
||||
implementation(libs.androidx.cardview)
|
||||
implementation(libs.androidx.startup)
|
||||
implementation(libs.bundles.androidx.compose)
|
||||
implementation(libs.androidx.tv.material)
|
||||
|
||||
// Dependency Injection
|
||||
implementation(libs.bundles.koin)
|
||||
|
||||
@@ -108,8 +108,8 @@
|
||||
<activity
|
||||
android:name=".ui.startup.StartupActivity"
|
||||
android:exported="true"
|
||||
android:noHistory="true"
|
||||
android:launchMode="singleTask"
|
||||
android:noHistory="true"
|
||||
android:screenOrientation="landscape"
|
||||
android:windowSoftInputMode="adjustNothing">
|
||||
<intent-filter>
|
||||
@@ -132,6 +132,7 @@
|
||||
<!-- Main application -->
|
||||
<activity
|
||||
android:name=".ui.browsing.MainActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="landscape"
|
||||
android:windowSoftInputMode="adjustNothing" />
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package org.jellyfin.androidtv.auth.model
|
||||
sealed class QuickConnectState
|
||||
|
||||
/**
|
||||
* State unknown untill first poll completed.
|
||||
* State unknown until first poll completed.
|
||||
*/
|
||||
data object UnknownQuickConnectState : QuickConnectState()
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ interface ServerRepository {
|
||||
val minimumServerVersion = Jellyfin.minimumVersion.copy(build = null)
|
||||
val recommendedServerVersion = Jellyfin.apiVersion.copy(build = null)
|
||||
|
||||
val upcomingMinimumServerVersion = ServerVersion(10, 9, 0)
|
||||
val upcomingMinimumServerVersion = ServerVersion(10, 10, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ class ServerRepositoryImpl(
|
||||
|
||||
val goodRecommendations = mutableListOf<RecommendedServerInfo>()
|
||||
val badRecommendations = mutableListOf<RecommendedServerInfo>()
|
||||
val greatRecommendaton = jellyfin.discovery.getRecommendedServers(addressCandidates).firstOrNull { recommendedServer ->
|
||||
val greatRecommendation = jellyfin.discovery.getRecommendedServers(addressCandidates).firstOrNull { recommendedServer ->
|
||||
when (recommendedServer.score) {
|
||||
RecommendedServerInfoScore.GREAT -> true
|
||||
RecommendedServerInfoScore.GOOD -> {
|
||||
@@ -111,7 +111,7 @@ class ServerRepositoryImpl(
|
||||
|
||||
Timber.d(buildString {
|
||||
append("Recommendations: ")
|
||||
if (greatRecommendaton == null) append(0)
|
||||
if (greatRecommendation == null) append(0)
|
||||
else append(1)
|
||||
append(" great, ")
|
||||
append(goodRecommendations.size)
|
||||
@@ -120,7 +120,7 @@ class ServerRepositoryImpl(
|
||||
append(" bad")
|
||||
})
|
||||
|
||||
val chosenRecommendation = greatRecommendaton ?: goodRecommendations.firstOrNull()
|
||||
val chosenRecommendation = greatRecommendation ?: goodRecommendations.firstOrNull()
|
||||
if (chosenRecommendation != null && chosenRecommendation.systemInfo.isSuccess) {
|
||||
// Get system info
|
||||
val systemInfo = chosenRecommendation.systemInfo.getOrThrow()
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
import org.jellyfin.apiclient.model.dlna.DeviceProfile;
|
||||
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.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 org.jellyfin.sdk.model.api.SubtitleDeliveryMethod;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
@@ -36,14 +34,14 @@ public class StreamInfo {
|
||||
MediaUrl = value;
|
||||
}
|
||||
|
||||
private PlayMethod PlayMethod = getPlayMethod().values()[0];
|
||||
private PlayMethod playMethod = PlayMethod.DirectPlay;
|
||||
|
||||
public final PlayMethod getPlayMethod() {
|
||||
return PlayMethod;
|
||||
return playMethod;
|
||||
}
|
||||
|
||||
public final void setPlayMethod(PlayMethod value) {
|
||||
PlayMethod = value;
|
||||
playMethod = value;
|
||||
}
|
||||
|
||||
private EncodingContext Context = EncodingContext.values()[0];
|
||||
@@ -66,16 +64,6 @@ public class StreamInfo {
|
||||
Container = value;
|
||||
}
|
||||
|
||||
private long StartPositionTicks;
|
||||
|
||||
public final long getStartPositionTicks() {
|
||||
return StartPositionTicks;
|
||||
}
|
||||
|
||||
public final void setStartPositionTicks(long value) {
|
||||
StartPositionTicks = value;
|
||||
}
|
||||
|
||||
private DeviceProfile DeviceProfile;
|
||||
|
||||
public final DeviceProfile getDeviceProfile() {
|
||||
@@ -112,10 +100,10 @@ public class StreamInfo {
|
||||
MediaSource = value;
|
||||
}
|
||||
|
||||
private SubtitleDeliveryMethod SubtitleDeliveryMethod = getSubtitleDeliveryMethod().values()[0];
|
||||
|
||||
public final SubtitleDeliveryMethod getSubtitleDeliveryMethod() {
|
||||
return SubtitleDeliveryMethod;
|
||||
public final org.jellyfin.sdk.model.api.SubtitleDeliveryMethod getSubtitleDeliveryMethod() {
|
||||
Integer subtitleStreamIndex = MediaSource.getDefaultSubtitleStreamIndex();
|
||||
if (subtitleStreamIndex == null || subtitleStreamIndex == -1) return SubtitleDeliveryMethod.DROP;
|
||||
return MediaSource.getMediaStreams().get(subtitleStreamIndex).getDeliveryMethod();
|
||||
}
|
||||
|
||||
private String PlaySessionId;
|
||||
@@ -139,15 +127,13 @@ public class StreamInfo {
|
||||
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) {
|
||||
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);
|
||||
SubtitleStreamInfo info = getSubtitleStreamInfo(stream, getDeviceProfile().getSubtitleProfiles());
|
||||
list.add(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,43 +141,15 @@ public class StreamInfo {
|
||||
return list;
|
||||
}
|
||||
|
||||
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});
|
||||
|
||||
list.add(info);
|
||||
}
|
||||
} else {
|
||||
SubtitleStreamInfo info = getSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, getDeviceProfile().getSubtitleProfiles());
|
||||
|
||||
list.add(info);
|
||||
}
|
||||
}
|
||||
|
||||
private SubtitleStreamInfo getSubtitleStreamInfo(org.jellyfin.sdk.model.api.MediaStream stream, String baseUrl, String accessToken, long startPositionTicks, SubtitleProfile[] subtitleProfiles) {
|
||||
private SubtitleStreamInfo getSubtitleStreamInfo(org.jellyfin.sdk.model.api.MediaStream stream, SubtitleProfile[] subtitleProfiles) {
|
||||
SubtitleProfile subtitleProfile = StreamBuilder.getSubtitleProfile(stream, subtitleProfiles, getPlayMethod());
|
||||
SubtitleStreamInfo tempVar = new SubtitleStreamInfo();
|
||||
SubtitleStreamInfo info = new SubtitleStreamInfo();
|
||||
String tempVar2 = stream.getLanguage();
|
||||
tempVar.setName((tempVar2 != null) ? tempVar2 : "Unknown");
|
||||
tempVar.setFormat(subtitleProfile.getFormat());
|
||||
tempVar.setIndex(stream.getIndex());
|
||||
tempVar.setDeliveryMethod(subtitleProfile.getMethod());
|
||||
tempVar.setDisplayTitle(stream.getDisplayTitle());
|
||||
SubtitleStreamInfo info = tempVar;
|
||||
|
||||
if (info.getDeliveryMethod() == SubtitleDeliveryMethod.External) {
|
||||
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)) {
|
||||
info.setUrl(info.getUrl() + "?api_key=" + accessToken);
|
||||
}
|
||||
} else {
|
||||
info.setUrl(stream.getPath());
|
||||
}
|
||||
}
|
||||
|
||||
info.setName((tempVar2 != null) ? tempVar2 : "Unknown");
|
||||
info.setFormat(subtitleProfile.getFormat());
|
||||
info.setIndex(stream.getIndex());
|
||||
info.setDeliveryMethod(subtitleProfile.getMethod());
|
||||
info.setDisplayTitle(stream.getDisplayTitle());
|
||||
return info;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ 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.ui.playback.setSubtitleIndex
|
||||
import org.jellyfin.androidtv.util.PlaybackHelper
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
@@ -104,7 +105,7 @@ class SocketHandler(
|
||||
val index = message["index"]?.toIntOrNull() ?: return@onEach
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
playbackControllerContainer.playbackController?.switchSubtitleStream(index)
|
||||
playbackControllerContainer.playbackController?.setSubtitleIndex(index)
|
||||
}
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
@@ -21,7 +21,6 @@ class UserViewsRepositoryImpl(
|
||||
override val views = flow {
|
||||
val views by api.userViewsApi.getUserViews()
|
||||
val filteredViews = views.items
|
||||
.orEmpty()
|
||||
.filter { isSupported(it.collectionType) }
|
||||
emit(filteredViews)
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ package org.jellyfin.androidtv.data.service
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.toBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.MainScope
|
||||
@@ -124,7 +124,7 @@ class BackgroundService(
|
||||
_backgrounds = backdropUrls.mapNotNull { url ->
|
||||
imageLoader.execute(
|
||||
request = ImageRequest.Builder(context).data(url).build()
|
||||
).drawable?.toBitmap()?.asImageBitmap()
|
||||
).image?.toBitmap()?.asImageBitmap()
|
||||
}
|
||||
|
||||
// Go to first background
|
||||
|
||||
@@ -2,10 +2,12 @@ package org.jellyfin.androidtv.di
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import coil.ImageLoader
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import coil3.ImageLoader
|
||||
import coil3.gif.AnimatedImageDecoder
|
||||
import coil3.gif.GifDecoder
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.serviceLoaderEnabled
|
||||
import coil3.svg.SvgDecoder
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.auth.repository.ServerRepository
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
@@ -30,6 +32,8 @@ import org.jellyfin.androidtv.ui.navigation.NavigationRepositoryImpl
|
||||
import org.jellyfin.androidtv.ui.picture.PictureViewerViewModel
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
|
||||
import org.jellyfin.androidtv.ui.playback.nextup.NextUpViewModel
|
||||
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepository
|
||||
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepositoryImpl
|
||||
import org.jellyfin.androidtv.ui.search.SearchFragmentDelegate
|
||||
import org.jellyfin.androidtv.ui.search.SearchRepository
|
||||
import org.jellyfin.androidtv.ui.search.SearchRepositoryImpl
|
||||
@@ -52,7 +56,7 @@ import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import org.koin.android.ext.koin.androidApplication
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
import org.jellyfin.apiclient.Jellyfin as JellyfinApiClient
|
||||
@@ -101,8 +105,10 @@ val appModule = module {
|
||||
// Coil (images)
|
||||
single {
|
||||
ImageLoader.Builder(androidContext()).apply {
|
||||
serviceLoaderEnabled(false)
|
||||
components {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(ImageDecoderDecoder.Factory())
|
||||
add(OkHttpNetworkFetcherFactory())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(AnimatedImageDecoder.Factory())
|
||||
else add(GifDecoder.Factory())
|
||||
add(SvgDecoder.Factory())
|
||||
}
|
||||
@@ -120,6 +126,7 @@ val appModule = module {
|
||||
single<CustomMessageRepository> { CustomMessageRepositoryImpl() }
|
||||
single<NavigationRepository> { NavigationRepositoryImpl(Destinations.home) }
|
||||
single<SearchRepository> { SearchRepositoryImpl(get()) }
|
||||
single<MediaSegmentRepository> { MediaSegmentRepositoryImpl(get(), get()) }
|
||||
|
||||
viewModel { StartupViewModel(get(), get(), get(), get()) }
|
||||
viewModel { UserLoginViewModel(get(), get(), get(), get(defaultDeviceInfo)) }
|
||||
@@ -135,7 +142,7 @@ val appModule = module {
|
||||
single { MarkdownRenderer(get()) }
|
||||
single { ItemLauncher() }
|
||||
single { KeyProcessor() }
|
||||
single { ReportingHelper() }
|
||||
single { ReportingHelper(get(), get()) }
|
||||
single<PlaybackHelper> { SdkPlaybackHelper(get(), get(), get(), get(), get(), get(), get()) }
|
||||
|
||||
factory { (context: Context) -> SearchFragmentDelegate(context, get(), get()) }
|
||||
|
||||
@@ -59,10 +59,13 @@ fun Scope.createPlaybackManager() = playbackManager(androidContext()) {
|
||||
NotificationManagerCompat.from(get()).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
val userPreferences = get<UserPreferences>()
|
||||
val api = get<ApiClient>()
|
||||
val exoPlayerOptions = ExoPlayerOptions(
|
||||
httpConnectTimeout = api.httpClientOptions.connectTimeout,
|
||||
httpReadTimeout = api.httpClientOptions.requestTimeout
|
||||
httpReadTimeout = api.httpClientOptions.requestTimeout,
|
||||
preferFfmpeg = userPreferences[UserPreferences.preferExoPlayerFfmpeg],
|
||||
enableDebugLogging = userPreferences[UserPreferences.debuggingEnabled],
|
||||
)
|
||||
install(exoPlayerPlugin(get(), exoPlayerOptions))
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.jellyfin.androidtv.ui.startup.StartupActivity
|
||||
import org.jellyfin.androidtv.util.ImageHelper
|
||||
import org.jellyfin.androidtv.util.dp
|
||||
import org.jellyfin.androidtv.util.sdk.isUsable
|
||||
import org.jellyfin.androidtv.util.stripHtml
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.exception.TimeoutException
|
||||
@@ -220,7 +221,6 @@ class LeanbackChannelWorker(
|
||||
|
||||
// Add new items
|
||||
return response.items
|
||||
.orEmpty()
|
||||
.filter { userViewsRepository.isSupported(it.collectionType) }
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ class LeanbackChannelWorker(
|
||||
mediaTypes = listOf(MediaType.VIDEO),
|
||||
includeItemTypes = listOf(BaseItemKind.EPISODE, BaseItemKind.MOVIE),
|
||||
excludeActiveSessions = true,
|
||||
).content.items.orEmpty()
|
||||
).content.items
|
||||
}
|
||||
|
||||
val nextUp = async {
|
||||
@@ -285,7 +285,7 @@ class LeanbackChannelWorker(
|
||||
limit = 10,
|
||||
enableResumable = false,
|
||||
fields = listOf(ItemFields.DATE_CREATED),
|
||||
).content.items.orEmpty()
|
||||
).content.items
|
||||
}
|
||||
|
||||
// Concat
|
||||
@@ -363,7 +363,7 @@ class LeanbackChannelWorker(
|
||||
.setEpisodeTitle(if (item.type == BaseItemKind.EPISODE) item.name else null)
|
||||
.setSeasonNumber(seasonString, item.parentIndexNumber ?: 0)
|
||||
.setEpisodeNumber(episodeString, item.indexNumber ?: 0)
|
||||
.setDescription(item.overview)
|
||||
.setDescription(item.overview?.stripHtml())
|
||||
.setReleaseDate(
|
||||
if (item.premiereDate != null) DateTimeFormatter.ISO_DATE.format(item.premiereDate)
|
||||
else null
|
||||
|
||||
@@ -2,18 +2,17 @@ package org.jellyfin.androidtv.integration.dream
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.toBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flow
|
||||
@@ -22,14 +21,13 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.integration.dream.model.DreamContent
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.ui.playback.AudioEventListener
|
||||
import org.jellyfin.androidtv.ui.playback.MediaManager
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackController
|
||||
import org.jellyfin.playback.core.PlaybackManager
|
||||
import org.jellyfin.playback.core.queue.queue
|
||||
import org.jellyfin.playback.jellyfin.queue.baseItem
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageFormat
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
@@ -42,35 +40,17 @@ class DreamViewModel(
|
||||
private val api: ApiClient,
|
||||
private val imageLoader: ImageLoader,
|
||||
private val context: Context,
|
||||
private val mediaManager: MediaManager,
|
||||
playbackManager: PlaybackManager,
|
||||
private val userPreferences: UserPreferences,
|
||||
) : ViewModel() {
|
||||
private val _mediaContent = callbackFlow {
|
||||
trySend(mediaManager.currentAudioItem)
|
||||
|
||||
val listener = object : AudioEventListener {
|
||||
override fun onPlaybackStateChange(
|
||||
newState: PlaybackController.PlaybackState,
|
||||
currentItem: BaseItemDto?
|
||||
) {
|
||||
trySend(currentItem)
|
||||
}
|
||||
|
||||
override fun onQueueStatusChanged(hasQueue: Boolean) {
|
||||
trySend(mediaManager.currentAudioItem)
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val _mediaContent = playbackManager.queue.entry
|
||||
.map { entry ->
|
||||
entry?.baseItem?.let { baseItem ->
|
||||
DreamContent.NowPlaying(entry, baseItem)
|
||||
}
|
||||
}
|
||||
|
||||
mediaManager.addAudioEventListener(listener)
|
||||
awaitClose { mediaManager.removeAudioEventListener(listener) }
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.map { it?.let(DreamContent::NowPlaying) }
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(),
|
||||
DreamContent.NowPlaying(mediaManager.currentAudioItem)
|
||||
)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
|
||||
|
||||
private val _libraryContent = flow {
|
||||
// Load first library item after 2 seconds
|
||||
@@ -114,7 +94,7 @@ class DreamViewModel(
|
||||
hasParentalRating = if (requireParentalRating) true else null,
|
||||
)
|
||||
|
||||
val item = response.items?.firstOrNull { item ->
|
||||
val item = response.items.firstOrNull { item ->
|
||||
!item.backdropImageTags.isNullOrEmpty()
|
||||
} ?: return null
|
||||
|
||||
@@ -143,13 +123,13 @@ class DreamViewModel(
|
||||
val logoDeferred = async {
|
||||
imageLoader.execute(
|
||||
request = ImageRequest.Builder(context).data(logoUrl).build()
|
||||
).drawable?.toBitmap()
|
||||
).image?.toBitmap()
|
||||
}
|
||||
|
||||
val backdropDeferred = async {
|
||||
imageLoader.execute(
|
||||
request = ImageRequest.Builder(context).data(backdropUrl).build()
|
||||
).drawable?.toBitmap()
|
||||
).image?.toBitmap()
|
||||
}
|
||||
|
||||
awaitAll(logoDeferred, backdropDeferred)
|
||||
|
||||
@@ -17,7 +17,7 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import org.jellyfin.androidtv.integration.dream.model.DreamContent
|
||||
import org.jellyfin.androidtv.ui.composable.ZoomBox
|
||||
import org.jellyfin.androidtv.ui.composable.overscan
|
||||
import org.jellyfin.androidtv.ui.composable.modifier.overscan
|
||||
|
||||
@Composable
|
||||
fun DreamContentLibraryShowcase(
|
||||
|
||||
@@ -14,11 +14,8 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -32,18 +29,20 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import org.jellyfin.androidtv.integration.dream.model.DreamContent
|
||||
import org.jellyfin.androidtv.ui.composable.AsyncImage
|
||||
import org.jellyfin.androidtv.ui.composable.LyricsDtoBox
|
||||
import org.jellyfin.androidtv.ui.composable.blurHashPainter
|
||||
import org.jellyfin.androidtv.ui.composable.overscan
|
||||
import org.jellyfin.androidtv.ui.playback.AudioEventListener
|
||||
import org.jellyfin.androidtv.ui.playback.MediaManager
|
||||
import org.jellyfin.androidtv.ui.composable.modifier.fadingEdges
|
||||
import org.jellyfin.androidtv.ui.composable.modifier.overscan
|
||||
import org.jellyfin.androidtv.ui.composable.rememberPlayerProgress
|
||||
import org.jellyfin.playback.core.PlaybackManager
|
||||
import org.jellyfin.playback.core.model.PlayState
|
||||
import org.jellyfin.playback.jellyfin.lyrics
|
||||
import org.jellyfin.playback.jellyfin.lyricsFlow
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.model.api.ImageFormat
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import org.koin.compose.koinInject
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
fun DreamContentNowPlaying(
|
||||
@@ -52,18 +51,21 @@ fun DreamContentNowPlaying(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
val api = koinInject<ApiClient>()
|
||||
val mediaManager = koinInject<MediaManager>()
|
||||
val item = content.item ?: return@Box
|
||||
val playbackManager = koinInject<PlaybackManager>()
|
||||
val lyrics = content.entry.run { lyricsFlow.collectAsState(lyrics) }.value
|
||||
val progress = rememberPlayerProgress(playbackManager)
|
||||
|
||||
val primaryImageTag = item.imageTags?.get(ImageType.PRIMARY)
|
||||
val primaryImageTag = content.item.imageTags?.get(ImageType.PRIMARY)
|
||||
val (imageItemId, imageTag) = when {
|
||||
primaryImageTag != null -> item.id to primaryImageTag
|
||||
(item.albumId != null && item.albumPrimaryImageTag != null) -> item.albumId to item.albumPrimaryImageTag
|
||||
primaryImageTag != null -> content.item.id to primaryImageTag
|
||||
(content.item.albumId != null && content.item.albumPrimaryImageTag != null) -> content.item.albumId to content.item.albumPrimaryImageTag
|
||||
else -> null to null
|
||||
}
|
||||
|
||||
val imageBlurHash =
|
||||
imageTag?.let { tag -> item.imageBlurHashes?.get(ImageType.PRIMARY)?.get(tag) }
|
||||
// Background
|
||||
val imageBlurHash = imageTag?.let { tag ->
|
||||
content.item.imageBlurHashes?.get(ImageType.PRIMARY)?.get(tag)
|
||||
}
|
||||
if (imageBlurHash != null) {
|
||||
Image(
|
||||
painter = blurHashPainter(imageBlurHash, IntSize(32, 32)),
|
||||
@@ -76,7 +78,24 @@ fun DreamContentNowPlaying(
|
||||
DreamContentVignette()
|
||||
}
|
||||
|
||||
// Overlay
|
||||
// Lyrics overlay (on top of background)
|
||||
if (lyrics != null) {
|
||||
val playState by playbackManager.state.playState.collectAsState()
|
||||
LyricsDtoBox(
|
||||
lyricDto = lyrics,
|
||||
currentTimestamp = playbackManager.state.positionInfo.active,
|
||||
duration = playbackManager.state.positionInfo.duration,
|
||||
paused = playState != PlayState.PLAYING,
|
||||
fontSize = 22.sp,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.fadingEdges(vertical = 250.dp)
|
||||
.padding(horizontal = 50.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Metadata overlay (includes title / progress)
|
||||
Row(
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||
@@ -105,7 +124,7 @@ fun DreamContentNowPlaying(
|
||||
.padding(bottom = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = item.name.orEmpty(),
|
||||
text = content.item.name.orEmpty(),
|
||||
style = TextStyle(
|
||||
color = Color.White,
|
||||
fontSize = 26.sp,
|
||||
@@ -113,7 +132,7 @@ fun DreamContentNowPlaying(
|
||||
)
|
||||
|
||||
Text(
|
||||
text = item.run {
|
||||
text = content.item.run {
|
||||
val artistNames = artists.orEmpty()
|
||||
val albumArtistNames = albumArtists?.mapNotNull { it.name }.orEmpty()
|
||||
|
||||
@@ -131,22 +150,6 @@ fun DreamContentNowPlaying(
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
var progress by remember { mutableFloatStateOf(0f) }
|
||||
DisposableEffect(Unit) {
|
||||
val listener = object : AudioEventListener {
|
||||
override fun onProgress(pos: Long) {
|
||||
val duration = item.runTimeTicks?.ticks ?: Duration.ZERO
|
||||
progress = (pos.milliseconds / duration).toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
mediaManager.addAudioEventListener(listener)
|
||||
|
||||
onDispose {
|
||||
mediaManager.removeAudioEventListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -156,7 +159,10 @@ fun DreamContentNowPlaying(
|
||||
// Background
|
||||
drawRect(Color.White, alpha = 0.2f)
|
||||
// Foreground
|
||||
drawRect(Color.White, size = size.copy(width = size.width * progress))
|
||||
drawRect(
|
||||
Color.White,
|
||||
size = size.copy(width = progress * size.width)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.composable.overscan
|
||||
import org.jellyfin.androidtv.ui.composable.modifier.overscan
|
||||
import org.jellyfin.androidtv.ui.composable.rememberCurrentTime
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package org.jellyfin.androidtv.integration.dream.model
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import org.jellyfin.playback.core.queue.QueueEntry
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
|
||||
sealed interface DreamContent {
|
||||
data object Logo : DreamContent
|
||||
data class LibraryShowcase(val item: BaseItemDto, val backdrop: Bitmap, val logo: Bitmap?) : DreamContent
|
||||
data class NowPlaying(val item: BaseItemDto?) : DreamContent
|
||||
data class NowPlaying(val entry: QueueEntry, val item: BaseItemDto) : DreamContent
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.net.toUri
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil3.ImageLoader
|
||||
import coil3.asDrawable
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.error
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.koin.android.ext.android.inject
|
||||
@@ -37,8 +39,8 @@ class ImageProvider : ContentProvider() {
|
||||
data(src)
|
||||
error(R.drawable.placeholder_icon)
|
||||
target(
|
||||
onSuccess = { drawable -> writeDrawable(drawable, outputStream) },
|
||||
onError = { drawable -> writeDrawable(requireNotNull(drawable), outputStream) }
|
||||
onSuccess = { image -> writeDrawable(image.asDrawable(context!!.resources), outputStream) },
|
||||
onError = { image -> writeDrawable(requireNotNull(image?.asDrawable(context!!.resources)), outputStream) }
|
||||
)
|
||||
}.build())
|
||||
|
||||
|
||||
@@ -10,12 +10,17 @@ import org.jellyfin.androidtv.preference.constant.NextUpBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.RatingType
|
||||
import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.WatchedIndicatorBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.ZoomMode
|
||||
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentAction
|
||||
import org.jellyfin.androidtv.ui.playback.segment.toMediaSegmentActionsString
|
||||
import org.jellyfin.preference.booleanPreference
|
||||
import org.jellyfin.preference.enumPreference
|
||||
import org.jellyfin.preference.floatPreference
|
||||
import org.jellyfin.preference.intPreference
|
||||
import org.jellyfin.preference.longPreference
|
||||
import org.jellyfin.preference.store.SharedPreferenceStore
|
||||
import org.jellyfin.preference.stringPreference
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
@@ -45,7 +50,7 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
var premieresEnabled = booleanPreference("pref_enable_premieres", false)
|
||||
|
||||
/**
|
||||
* Enable management of media like deleting items when the user has sufficient permisisons.
|
||||
* Enable management of media like deleting items when the user has sufficient permissions.
|
||||
*/
|
||||
var mediaManagementEnabled = booleanPreference("enable_media_management", false)
|
||||
|
||||
@@ -92,11 +97,16 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
*/
|
||||
var refreshRateSwitchingBehavior = enumPreference("refresh_rate_switching_behavior", RefreshRateSwitchingBehavior.DISABLED)
|
||||
|
||||
/**
|
||||
* Whether ExoPlayer should prefer FFmpeg renderers to core ones.
|
||||
*/
|
||||
var preferExoPlayerFfmpeg = booleanPreference("exoplayer_prefer_ffmpeg", defaultValue = false)
|
||||
|
||||
/* Playback - Audio related */
|
||||
/**
|
||||
* Preferred behavior for audio streaming.
|
||||
*/
|
||||
var audioBehaviour = enumPreference("audio_behavior", AudioBehavior.DOWNMIX_TO_STEREO)
|
||||
var audioBehaviour = enumPreference("audio_behavior", AudioBehavior.DIRECT_STREAM)
|
||||
|
||||
/**
|
||||
* Preferred behavior for audio streaming.
|
||||
@@ -156,30 +166,25 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
var seriesThumbnailsEnabled = booleanPreference("pref_enable_series_thumbnails", true)
|
||||
|
||||
/**
|
||||
* Enable subtitles background
|
||||
* Subtitles foreground color
|
||||
*/
|
||||
var subtitlesBackgroundEnabled = booleanPreference("subtitles_background_enabled", true)
|
||||
|
||||
/**
|
||||
* Subtitles font size
|
||||
*/
|
||||
var subtitlesSize = intPreference("subtitles_size", 28)
|
||||
|
||||
/**
|
||||
* Subtitles stroke size
|
||||
*/
|
||||
var subtitleStrokeSize = intPreference("subtitles_stroke_size", 0)
|
||||
|
||||
/**
|
||||
* Subtitles position
|
||||
*/
|
||||
var subtitlePosition = intPreference("subtitles_position", 40)
|
||||
var subtitlesBackgroundColor = longPreference("subtitles_background_color", 0x00FFFFFF)
|
||||
|
||||
/**
|
||||
* Subtitles foreground color
|
||||
*/
|
||||
var subtitlesTextColor = longPreference("subtitles_text_color", 0xFFFFFFFF)
|
||||
|
||||
/**
|
||||
* Subtitles stroke color
|
||||
*/
|
||||
var subtitleTextStrokeColor = longPreference("subtitles_text_stroke_color", 0xFF000000)
|
||||
|
||||
/**
|
||||
* Subtitles font size
|
||||
*/
|
||||
var subtitlesTextSize = floatPreference("subtitles_text_size", 1f)
|
||||
|
||||
/**
|
||||
* Show screensaver in app
|
||||
*/
|
||||
@@ -201,9 +206,30 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
var screensaverAgeRatingRequired = booleanPreference("screensaver_agerating_required", true)
|
||||
|
||||
/**
|
||||
* Enable reactive homepage
|
||||
* Delay when starting video playback after loading the video player.
|
||||
*/
|
||||
var homeReactive = booleanPreference("home_reactive", false)
|
||||
var videoStartDelay = longPreference("video_start_delay", 0)
|
||||
|
||||
/**
|
||||
* The actions to take for each media segment type. Managed by the [MediaSegmentRepository].
|
||||
*/
|
||||
var mediaSegmentActions = stringPreference(
|
||||
key = "media_segment_actions",
|
||||
defaultValue = mapOf(
|
||||
MediaSegmentType.INTRO to MediaSegmentAction.ASK_TO_SKIP,
|
||||
MediaSegmentType.OUTRO to MediaSegmentAction.ASK_TO_SKIP,
|
||||
).toMediaSegmentActionsString()
|
||||
)
|
||||
|
||||
/**
|
||||
* Preferred behavior for player aspect ratio (zoom mode).
|
||||
*/
|
||||
var playerZoomMode = enumPreference("player_zoom_mode", ZoomMode.FIT)
|
||||
|
||||
/**
|
||||
* Enable TrickPlay in legacy player user interface while seeking.
|
||||
*/
|
||||
var trickPlayEnabled = booleanPreference("trick_play_enabled", false)
|
||||
}
|
||||
|
||||
init {
|
||||
@@ -216,6 +242,17 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
// Enable playback rewrite for music
|
||||
putBoolean("playback_new_audio", true)
|
||||
}
|
||||
|
||||
// v0.17.z to v0.18.0
|
||||
migration(toVersion = 8) {
|
||||
// Set subtitle background color to black if it was enabled in a previous version
|
||||
val subtitlesBackgroundEnabled = it.getBoolean("subtitles_background_enabled", true)
|
||||
putLong("subtitles_background_color", if (subtitlesBackgroundEnabled) 0XFF000000L else 0X00FFFFFFL)
|
||||
|
||||
// Set subtitle text stroke color to black if it was enabled in a previous version
|
||||
val subtitleStrokeSize = it.getInt("subtitles_stroke_size", 0)
|
||||
putLong("subtitles_text_stroke_color", if (subtitleStrokeSize > 0) 0XFF000000L else 0X00FFFFFFL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,26 @@ class UserSettingPreferences(
|
||||
val homesection4 = enumPreference("homesection4", HomeSectionType.LIVE_TV)
|
||||
val homesection5 = enumPreference("homesection5", HomeSectionType.NEXT_UP)
|
||||
val homesection6 = enumPreference("homesection6", HomeSectionType.LATEST_MEDIA)
|
||||
val homesection7 = enumPreference("homesection7", HomeSectionType.NONE)
|
||||
val homesection8 = enumPreference("homesection8", HomeSectionType.NONE)
|
||||
val homesection9 = enumPreference("homesection9", HomeSectionType.NONE)
|
||||
}
|
||||
|
||||
val homesections
|
||||
get() = listOf(homesection0, homesection1, homesection2, homesection3, homesection4, homesection5, homesection6)
|
||||
val homesections = listOf(
|
||||
homesection0,
|
||||
homesection1,
|
||||
homesection2,
|
||||
homesection3,
|
||||
homesection4,
|
||||
homesection5,
|
||||
homesection6,
|
||||
homesection7,
|
||||
homesection8,
|
||||
homesection9,
|
||||
)
|
||||
|
||||
val activeHomesections
|
||||
get() = homesections
|
||||
.map(::get)
|
||||
.filterNot { it == HomeSectionType.NONE }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.jellyfin.androidtv.preference.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
|
||||
enum class ZoomMode(
|
||||
override val nameRes: Int,
|
||||
) : PreferenceEnum {
|
||||
/**
|
||||
* Sets the zoom mode to normal (fit).
|
||||
*/
|
||||
FIT(R.string.lbl_fit),
|
||||
|
||||
/**
|
||||
* Sets the zoom mode to auto crop.
|
||||
*/
|
||||
AUTO_CROP(R.string.lbl_auto_crop),
|
||||
|
||||
/**
|
||||
* Sets the zoom mode to stretch.
|
||||
*/
|
||||
STRETCH(R.string.lbl_stretch),
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@ abstract class DisplayPreferencesStore(
|
||||
override fun getLong(key: String, defaultValue: Long) =
|
||||
cachedPreferences[key]?.toLongOrNull() ?: defaultValue
|
||||
|
||||
override fun getFloat(key: String, defaultValue: Float) =
|
||||
cachedPreferences[key]?.toFloatOrNull() ?: defaultValue
|
||||
|
||||
override fun getBool(key: String, defaultValue: Boolean) =
|
||||
cachedPreferences[key]?.toBooleanStrictOrNull() ?: defaultValue
|
||||
|
||||
@@ -96,6 +99,10 @@ abstract class DisplayPreferencesStore(
|
||||
cachedPreferences[key] = value.toString()
|
||||
}
|
||||
|
||||
override fun setFloat(key: String, value: Float) {
|
||||
cachedPreferences[key] = value.toString()
|
||||
}
|
||||
|
||||
override fun setBool(key: String, value: Boolean) {
|
||||
cachedPreferences[key] = value.toString()
|
||||
}
|
||||
|
||||
@@ -9,9 +9,13 @@ import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.core.view.doOnAttach
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.transform.CircleCropTransformation
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import coil3.request.target
|
||||
import coil3.request.transformations
|
||||
import coil3.transform.CircleCropTransformation
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -88,9 +92,9 @@ class AsyncImageView @JvmOverloads constructor(
|
||||
|
||||
target(this@AsyncImageView)
|
||||
data(url)
|
||||
placeholder(placeholderOrBlurHash)
|
||||
placeholder(placeholderOrBlurHash?.asImage())
|
||||
if (circleCrop) transformations(CircleCropTransformation())
|
||||
error(placeholder)
|
||||
error(placeholder?.asImage())
|
||||
}.build())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,103 +2,156 @@ package org.jellyfin.androidtv.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.AbstractComposeView
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.setPadding
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ProvideTextStyle
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.databinding.ViewNowPlayingBinding
|
||||
import org.jellyfin.androidtv.ui.composable.AsyncImage
|
||||
import org.jellyfin.androidtv.ui.composable.rememberPlayerProgress
|
||||
import org.jellyfin.androidtv.ui.composable.rememberQueueEntry
|
||||
import org.jellyfin.androidtv.ui.navigation.Destinations
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
|
||||
import org.jellyfin.androidtv.ui.playback.AudioEventListener
|
||||
import org.jellyfin.androidtv.ui.playback.MediaManager
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackController
|
||||
import org.jellyfin.androidtv.util.ImageHelper
|
||||
import org.jellyfin.androidtv.util.TimeUtils
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.jellyfin.playback.core.PlaybackManager
|
||||
import org.jellyfin.playback.jellyfin.queue.baseItem
|
||||
import org.jellyfin.playback.jellyfin.queue.baseItemFlow
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
class NowPlayingView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
defStyleRes: Int = R.style.Button_Default,
|
||||
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes), KoinComponent {
|
||||
val binding = ViewNowPlayingBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
@Composable
|
||||
fun NowPlayingComposable() {
|
||||
val playbackManager = koinInject<PlaybackManager>()
|
||||
val navigationRepository = koinInject<NavigationRepository>()
|
||||
val imageHelper = koinInject<ImageHelper>()
|
||||
|
||||
private val mediaManager by inject<MediaManager>()
|
||||
private val navigationRepository by inject<NavigationRepository>()
|
||||
private val imageHelper by inject<ImageHelper>()
|
||||
private var currentDuration: String = ""
|
||||
val entry by rememberQueueEntry(playbackManager)
|
||||
val item = entry?.run { baseItemFlow.collectAsState(baseItem) }?.value
|
||||
val progress = rememberPlayerProgress(playbackManager)
|
||||
|
||||
init {
|
||||
setPadding(0)
|
||||
AnimatedVisibility(
|
||||
visible = item != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Surface(
|
||||
onClick = { navigationRepository.navigate(Destinations.nowPlaying) },
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colorResource(id = R.color.button_default_normal_background),
|
||||
focusedContainerColor = colorResource(id = R.color.button_default_highlight_background),
|
||||
contentColor = colorResource(id = R.color.button_default_normal_text),
|
||||
focusedContentColor = colorResource(id = R.color.button_default_highlight_text),
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||
shape = ClickableSurfaceDefaults.shape(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
),
|
||||
modifier = Modifier
|
||||
.widthIn(0.dp, 250.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.drawWithContent {
|
||||
// Background
|
||||
drawRect(Color.White, alpha = 0.4f)
|
||||
// Foreground
|
||||
drawRect(Color.White, size = size.copy(width = progress * size.width))
|
||||
}
|
||||
)
|
||||
|
||||
if (!isInEditMode) setOnClickListener {
|
||||
navigationRepository.navigate(Destinations.nowPlaying)
|
||||
}
|
||||
}
|
||||
ProvideTextStyle(
|
||||
value = TextStyle.Default.copy(
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(5.dp)
|
||||
) {
|
||||
val primaryImageTag = item?.imageTags?.get(ImageType.PRIMARY)
|
||||
val (imageItemId, imageTag) = when {
|
||||
primaryImageTag != null -> item.id to primaryImageTag
|
||||
(item?.albumId != null && item.albumPrimaryImageTag != null) -> item.albumId to item.albumPrimaryImageTag
|
||||
else -> null to null
|
||||
}
|
||||
val imageUrl = when {
|
||||
imageItemId != null && imageTag != null -> imageHelper.getImageUrl(
|
||||
itemId = imageItemId,
|
||||
imageType = ImageType.PRIMARY,
|
||||
imageTag = imageTag
|
||||
)
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
else -> null
|
||||
}
|
||||
val imageBlurHash = imageTag?.let { tag ->
|
||||
item?.imageBlurHashes?.get(ImageType.PRIMARY)?.get(tag)
|
||||
}
|
||||
|
||||
if (!isInEditMode) {
|
||||
// hook our events
|
||||
mediaManager.addAudioEventListener(audioEventListener)
|
||||
AsyncImage(
|
||||
url = imageUrl,
|
||||
blurHash = imageBlurHash,
|
||||
placeholder = ContextCompat.getDrawable(LocalContext.current, R.drawable.ic_album),
|
||||
aspectRatio = item?.primaryImageAspectRatio ?: 1.0,
|
||||
modifier = Modifier
|
||||
.size(35.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP,
|
||||
)
|
||||
|
||||
if (mediaManager.hasAudioQueueItems()) {
|
||||
isVisible = true
|
||||
setInfo(mediaManager.currentAudioItem!!)
|
||||
setStatus(mediaManager.currentAudioPosition)
|
||||
} else isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
|
||||
if (!isInEditMode) mediaManager.removeAudioEventListener(audioEventListener)
|
||||
}
|
||||
|
||||
private fun setInfo(item: org.jellyfin.sdk.model.api.BaseItemDto) {
|
||||
val placeholder = ContextCompat.getDrawable(context, R.drawable.ic_album)
|
||||
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(imageHelper.getPrimaryImageUrl(item), blurHash, placeholder, item.primaryImageAspectRatio ?: 1.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
|
||||
}
|
||||
|
||||
private fun setStatus(pos: Long) {
|
||||
binding.npStatus.text = resources.getString(R.string.lbl_status, TimeUtils.formatMillis(pos), currentDuration)
|
||||
}
|
||||
|
||||
fun showDescription(show: Boolean) {
|
||||
binding.npDesc.isVisible = show
|
||||
}
|
||||
|
||||
private var audioEventListener: AudioEventListener = object : AudioEventListener {
|
||||
override fun onPlaybackStateChange(newState: PlaybackController.PlaybackState, currentItem: org.jellyfin.sdk.model.api.BaseItemDto?) {
|
||||
when {
|
||||
currentItem == null -> Unit
|
||||
newState == PlaybackController.PlaybackState.PLAYING -> setInfo(currentItem)
|
||||
newState == PlaybackController.PlaybackState.IDLE && isShown -> setStatus(mediaManager.currentAudioPosition)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onProgress(pos: Long) {
|
||||
if (isShown) setStatus(pos)
|
||||
}
|
||||
|
||||
override fun onQueueStatusChanged(hasQueue: Boolean) {
|
||||
isVisible = hasQueue
|
||||
|
||||
if (hasQueue) {
|
||||
// may have just added one so update display
|
||||
setInfo(mediaManager.currentAudioItem!!)
|
||||
setStatus(mediaManager.currentAudioPosition)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
) {
|
||||
// Name
|
||||
Text(text = item?.name.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
val artists = item?.artists ?: item?.albumArtists ?: item?.albumArtist?.let(::listOf)
|
||||
Text(text = artists?.joinToString(", ").orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NowPlayingView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyle: Int = 0
|
||||
) : AbstractComposeView(context, attrs, defStyle) {
|
||||
@Composable
|
||||
override fun Content() = NowPlayingComposable()
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ class ScreensaverViewModel(
|
||||
// Cancel pending timer (if any)
|
||||
timer?.cancel()
|
||||
|
||||
// Hide when interacted with allowed cancelation or when disabled
|
||||
// Hide when interacted with allowed cancellation or when disabled
|
||||
if (_visible.value && (canCancel || !inAppEnabled || activityPaused)) {
|
||||
_visible.value = false
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ 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;
|
||||
@@ -117,7 +116,6 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
private final Lazy<BackgroundService> backgroundService = inject(BackgroundService.class);
|
||||
private final Lazy<PreferencesRepository> preferencesRepository = inject(PreferencesRepository.class);
|
||||
private final Lazy<UserViewsRepository> userViewsRepository = inject(UserViewsRepository.class);
|
||||
private final Lazy<UserRepository> userRepository = inject(UserRepository.class);
|
||||
private final Lazy<CustomMessageRepository> customMessageRepository = inject(CustomMessageRepository.class);
|
||||
private final Lazy<NavigationRepository> navigationRepository = inject(NavigationRepository.class);
|
||||
private final Lazy<ItemLauncher> itemLauncher = inject(ItemLauncher.class);
|
||||
@@ -169,7 +167,12 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
sortOptions.put(3, new SortOption(getString(R.string.lbl_rating), ItemSortBy.OFFICIAL_RATING, SortOrder.ASCENDING));
|
||||
sortOptions.put(4, new SortOption(getString(R.string.lbl_community_rating), ItemSortBy.COMMUNITY_RATING, SortOrder.DESCENDING));
|
||||
sortOptions.put(5, new SortOption(getString(R.string.lbl_critic_rating), ItemSortBy.CRITIC_RATING, SortOrder.DESCENDING));
|
||||
sortOptions.put(6, new SortOption(getString(R.string.lbl_last_played), ItemSortBy.DATE_PLAYED, SortOrder.DESCENDING));
|
||||
|
||||
if (mFolder.getCollectionType() == CollectionType.TVSHOWS) {
|
||||
sortOptions.put(6, new SortOption(getString(R.string.lbl_last_played), ItemSortBy.SERIES_DATE_PLAYED, SortOrder.DESCENDING));
|
||||
} else {
|
||||
sortOptions.put(6, new SortOption(getString(R.string.lbl_last_played), ItemSortBy.DATE_PLAYED, SortOrder.DESCENDING));
|
||||
}
|
||||
|
||||
if (mFolder.getCollectionType() != null && mFolder.getCollectionType() == CollectionType.MOVIES) {
|
||||
sortOptions.put(7, new SortOption(getString(R.string.lbl_runtime), ItemSortBy.RUNTIME, SortOrder.ASCENDING));
|
||||
@@ -185,12 +188,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
@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();
|
||||
}
|
||||
|
||||
@@ -473,11 +471,11 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
|
||||
if (numRows > 0) {
|
||||
double paddingPct = cardScaling / numRows;
|
||||
double spaceingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numRows - 1);
|
||||
double spacingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numRows - 1);
|
||||
|
||||
double wastedSpacePct = paddingPct + spaceingPct;
|
||||
double useableCardSpace = mGridHeight / (1.0 + wastedSpacePct); // decrease size
|
||||
double cardHeight = useableCardSpace / numRows;
|
||||
double wastedSpacePct = paddingPct + spacingPct;
|
||||
double usableCardSpace = mGridHeight / (1.0 + wastedSpacePct); // decrease size
|
||||
double cardHeight = usableCardSpace / numRows;
|
||||
|
||||
// fix any rounding errors and make pixel perfect
|
||||
cardHeightInt = (int) Math.round(cardHeight);
|
||||
@@ -501,14 +499,14 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
mCardsScreenStride = numRows;
|
||||
} else if (numCols > 0) {
|
||||
double paddingPct = cardScaling / numCols;
|
||||
double spaceingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numCols - 1);
|
||||
double spacingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numCols - 1);
|
||||
if (mImageType == ImageType.BANNER) {
|
||||
spaceingPct = ((paddingPct / 2.0) * CARD_SPACING_HORIZONTAL_BANNER_PCT) * (numCols - 1);
|
||||
spacingPct = ((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;
|
||||
double wastedSpacePct = paddingPct + spacingPct;
|
||||
double usableCardSpace = mGridWidth / (1.0 + wastedSpacePct); // decrease size
|
||||
double cardWidth = usableCardSpace / numCols;
|
||||
|
||||
// fix any rounding errors and make pixel perfect
|
||||
cardHeightInt = (int) Math.round(getCardHeightBy(cardWidth, mImageType, mFolder));
|
||||
@@ -621,6 +619,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
chunkSize = Math.min(mCardsScreenEst + mCardsScreenStride, 150); // cap at 150
|
||||
Timber.d("buildAdapter adjusting chunkSize to <%s> screenEst <%s>", chunkSize, mCardsScreenEst);
|
||||
}
|
||||
chunkSize=100;
|
||||
|
||||
switch (mRowDef.getQueryType()) {
|
||||
case NextUp:
|
||||
|
||||
@@ -4,7 +4,6 @@ import org.jellyfin.androidtv.constant.ChangeTriggerType;
|
||||
import org.jellyfin.androidtv.constant.QueryType;
|
||||
import org.jellyfin.androidtv.data.querying.GetSeriesTimersRequest;
|
||||
import org.jellyfin.androidtv.data.querying.GetSpecialsRequest;
|
||||
import org.jellyfin.androidtv.data.querying.GetUserViewsRequest;
|
||||
import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest;
|
||||
import org.jellyfin.sdk.model.api.request.GetArtistsRequest;
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest;
|
||||
@@ -138,12 +137,6 @@ public class BrowseRowDef {
|
||||
this.queryType = type;
|
||||
}
|
||||
|
||||
public BrowseRowDef(String header, GetUserViewsRequest query) {
|
||||
headerText = header;
|
||||
this.staticHeight = true;
|
||||
this.queryType = QueryType.Views;
|
||||
}
|
||||
|
||||
public BrowseRowDef(String header, GetResumeItemsRequest query, int chunkSize, boolean preferParentThumb, boolean staticHeight, ChangeTriggerType[] changeTriggers) {
|
||||
headerText = header;
|
||||
this.resumeQuery = query;
|
||||
|
||||
@@ -345,6 +345,7 @@ object BrowsingUtils {
|
||||
),
|
||||
includeItemTypes = setOf(BaseItemKind.EPISODE),
|
||||
parentId = parentId,
|
||||
indexNumber = 1,
|
||||
recursive = true,
|
||||
isMissing = false,
|
||||
imageTypeLimit = 1,
|
||||
|
||||
@@ -21,7 +21,7 @@ class ByGenreFragment : BrowseFolderFragment() {
|
||||
sortBy = setOf(ItemSortBy.SORT_NAME),
|
||||
)
|
||||
|
||||
for (genre in genresResponse.items.orEmpty()) {
|
||||
for (genre in genresResponse.items) {
|
||||
val itemsRequest = GetItemsRequest(
|
||||
parentId = folder?.id,
|
||||
sortBy = setOf(ItemSortBy.SORT_NAME),
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package org.jellyfin.androidtv.ui.browsing
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.util.AttributeSet
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.core.os.ParcelCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentContainerView
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.FragmentTransaction
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationAction
|
||||
import java.util.Stack
|
||||
|
||||
private class HistoryEntry(
|
||||
val name: Class<out Fragment>,
|
||||
val arguments: Bundle = bundleOf(),
|
||||
|
||||
var fragment: Fragment? = null,
|
||||
var savedState: Fragment.SavedState? = null,
|
||||
) : Parcelable {
|
||||
override fun describeContents(): Int = 0
|
||||
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
dest.writeString(name.name)
|
||||
dest.writeBundle(arguments)
|
||||
dest.writeParcelable(savedState, 0)
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<HistoryEntry> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun createFromParcel(parcel: Parcel): HistoryEntry = HistoryEntry(
|
||||
name = Class.forName(parcel.readString()!!) as Class<out Fragment>,
|
||||
arguments = parcel.readBundle(this::class.java.classLoader)!!,
|
||||
fragment = null,
|
||||
savedState = ParcelCompat.readParcelable(parcel, this::class.java.classLoader, Fragment.SavedState::class.java)!!,
|
||||
)
|
||||
|
||||
override fun newArray(size: Int): Array<HistoryEntry?> = arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
|
||||
class DestinationFragmentView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : FrameLayout(context, attrs) {
|
||||
private companion object {
|
||||
private const val FRAGMENT_TAG_CONTENT = "content"
|
||||
private const val BUNDLE_SUPER = "super"
|
||||
private const val BUNDLE_HISTORY = "history"
|
||||
}
|
||||
|
||||
private val fragmentManager by lazy {
|
||||
FragmentManager.findFragmentManager(this)
|
||||
}
|
||||
|
||||
private val container by lazy {
|
||||
FragmentContainerView(context).also { view ->
|
||||
view.id = R.id.container
|
||||
addView(view)
|
||||
}
|
||||
}
|
||||
|
||||
private val history = Stack<HistoryEntry>()
|
||||
|
||||
fun navigate(action: NavigationAction.NavigateFragment) {
|
||||
val entry = HistoryEntry(action.destination.fragment.java, action.destination.arguments)
|
||||
|
||||
// Create the base transaction so we can mutate everything at once
|
||||
val transaction = fragmentManager.beginTransaction()
|
||||
|
||||
if (action.clear) {
|
||||
// Clear all current fragments from the history before adding the new entry
|
||||
history.mapNotNull { it.fragment }.forEach { transaction.remove(it) }
|
||||
history.clear()
|
||||
history.push(entry)
|
||||
} else if (action.replace && action.addToBackStack && history.isNotEmpty()) {
|
||||
// Remove the top-most entry before replacing it with the next
|
||||
val currentFragment = history[history.size - 1].fragment
|
||||
if (currentFragment != null) transaction.remove(currentFragment)
|
||||
history[history.size - 1] = entry
|
||||
} else {
|
||||
// Add to the end of the history
|
||||
saveCurrentFragmentState()
|
||||
history.push(entry)
|
||||
}
|
||||
|
||||
activateHistoryEntry(entry, transaction)
|
||||
}
|
||||
|
||||
fun goBack(): Boolean {
|
||||
// Require at least 2 items (current & previous) to go back
|
||||
if (history.size < 2) return false
|
||||
|
||||
// Create the base transaction so we can mutate everything at once
|
||||
val transaction = fragmentManager.beginTransaction()
|
||||
|
||||
// Remove current entry
|
||||
val currentEntry = history.pop()
|
||||
|
||||
// Make sure to remove the associated fragment
|
||||
val currentFragment = currentEntry.fragment
|
||||
if (currentFragment != null) transaction.remove(currentFragment)
|
||||
|
||||
// Read & set previous entry
|
||||
val entry = history.last()
|
||||
activateHistoryEntry(entry, transaction)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun saveCurrentFragmentState() {
|
||||
if (history.isEmpty()) return
|
||||
|
||||
// Update the top-most history entry with state from current fragment
|
||||
val fragment = requireNotNull(fragmentManager.findFragmentByTag(FRAGMENT_TAG_CONTENT))
|
||||
history[history.size - 1].savedState = fragmentManager.saveFragmentInstanceState(fragment)
|
||||
}
|
||||
|
||||
@SuppressLint("CommitTransaction")
|
||||
private fun activateHistoryEntry(
|
||||
entry: HistoryEntry,
|
||||
transaction: FragmentTransaction,
|
||||
) {
|
||||
var fragment = entry.fragment
|
||||
|
||||
// Create if there is no existing fragment
|
||||
if (fragment == null) {
|
||||
fragment = fragmentManager.fragmentFactory.instantiate(context.classLoader, entry.name.name).apply {
|
||||
setInitialSavedState(entry.savedState)
|
||||
}
|
||||
entry.fragment = fragment
|
||||
}
|
||||
|
||||
// Update arguments
|
||||
fragment.arguments = entry.arguments
|
||||
|
||||
transaction.apply {
|
||||
// Set options
|
||||
setReorderingAllowed(true)
|
||||
setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out)
|
||||
|
||||
// Detach current fragment
|
||||
fragmentManager.findFragmentByTag(FRAGMENT_TAG_CONTENT)?.let(::detach)
|
||||
|
||||
// Attach or add next fragment
|
||||
if (fragment.isDetached) attach(fragment)
|
||||
else add(container.id, fragment, FRAGMENT_TAG_CONTENT)
|
||||
}
|
||||
|
||||
// Commit
|
||||
if (fragmentManager.isStateSaved) transaction.commitNowAllowingStateLoss()
|
||||
else transaction.commitNow()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable {
|
||||
// Always retrieve current state before writing
|
||||
saveCurrentFragmentState()
|
||||
|
||||
// Save state
|
||||
return bundleOf(
|
||||
BUNDLE_SUPER to super.onSaveInstanceState(),
|
||||
BUNDLE_HISTORY to ArrayList(history),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(state: Parcelable?) {
|
||||
// Ignore if not a bundle
|
||||
if (state !is Bundle) return super.onRestoreInstanceState(state)
|
||||
|
||||
// Call parent
|
||||
@Suppress("DEPRECATION")
|
||||
val parent = state.getParcelable<Parcelable>(BUNDLE_SUPER)
|
||||
super.onRestoreInstanceState(parent)
|
||||
|
||||
// Restore history
|
||||
val savedHistory = BundleCompat.getParcelableArrayList(state, BUNDLE_HISTORY, HistoryEntry::class.java)
|
||||
if (savedHistory != null) {
|
||||
history.clear()
|
||||
history.addAll(savedHistory)
|
||||
if (history.isNotEmpty()) activateHistoryEntry(history.last(), fragmentManager.beginTransaction())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,12 @@ import android.view.WindowManager
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.flowWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.repository.SessionRepository
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
import org.jellyfin.androidtv.databinding.ActivityMainBinding
|
||||
@@ -34,15 +31,10 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import timber.log.Timber
|
||||
|
||||
class MainActivity : FragmentActivity() {
|
||||
companion object {
|
||||
private const val FRAGMENT_TAG_CONTENT = "content"
|
||||
}
|
||||
|
||||
private val navigationRepository by inject<NavigationRepository>()
|
||||
private val sessionRepository by inject<SessionRepository>()
|
||||
private val userRepository by inject<UserRepository>()
|
||||
private val screensaverViewModel by viewModel<ScreensaverViewModel>()
|
||||
private var inTransaction = false
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
|
||||
@@ -66,16 +58,10 @@ class MainActivity : FragmentActivity() {
|
||||
}.launchIn(lifecycleScope)
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, backPressedCallback)
|
||||
|
||||
supportFragmentManager.addOnBackStackChangedListener {
|
||||
if (!inTransaction && supportFragmentManager.backStackEntryCount == 0)
|
||||
navigationRepository.reset()
|
||||
}
|
||||
|
||||
if (savedInstanceState == null && navigationRepository.canGoBack) navigationRepository.reset()
|
||||
if (savedInstanceState == null && navigationRepository.canGoBack) navigationRepository.reset(clearHistory = true)
|
||||
|
||||
navigationRepository.currentAction
|
||||
.flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
|
||||
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
|
||||
.onEach { action ->
|
||||
handleNavigationAction(action)
|
||||
backPressedCallback.isEnabled = navigationRepository.canGoBack
|
||||
@@ -124,48 +110,23 @@ class MainActivity : FragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNavigationAction(action: NavigationAction) = when (action) {
|
||||
is NavigationAction.NavigateFragment -> {
|
||||
if (action.clear) {
|
||||
// Clear the current back stack
|
||||
val firstBackStackEntry = supportFragmentManager.getBackStackEntryAt(0)
|
||||
supportFragmentManager.popBackStack(firstBackStackEntry.id, FragmentManager.POP_BACK_STACK_INCLUSIVE)
|
||||
} else if (action.replace) {
|
||||
// Prevent back stack changed listener from resetting when popping to
|
||||
// the initial destination
|
||||
inTransaction = true
|
||||
supportFragmentManager.popBackStack()
|
||||
inTransaction = false
|
||||
}
|
||||
private fun handleNavigationAction(action: NavigationAction) {
|
||||
when (action) {
|
||||
// DestinationFragmentView actions
|
||||
is NavigationAction.NavigateFragment -> binding.contentView.navigate(action)
|
||||
NavigationAction.GoBack -> binding.contentView.goBack()
|
||||
|
||||
supportFragmentManager.commit {
|
||||
// Others
|
||||
is NavigationAction.NavigateActivity -> {
|
||||
val destination = action.destination
|
||||
val currentFragment = supportFragmentManager.findFragmentByTag(FRAGMENT_TAG_CONTENT)
|
||||
val isSameFragment = currentFragment != null &&
|
||||
destination.fragment.isInstance(currentFragment) &&
|
||||
currentFragment.arguments == destination.arguments
|
||||
|
||||
if (!isSameFragment) {
|
||||
setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out)
|
||||
|
||||
replace(R.id.content_view, destination.fragment.java, destination.arguments, FRAGMENT_TAG_CONTENT)
|
||||
}
|
||||
|
||||
if (action.addToBackStack) addToBackStack(null)
|
||||
val intent = Intent(this@MainActivity, destination.activity.java)
|
||||
intent.putExtras(destination.extras)
|
||||
startActivity(intent)
|
||||
action.onOpened()
|
||||
}
|
||||
|
||||
NavigationAction.Nothing -> Unit
|
||||
}
|
||||
|
||||
is NavigationAction.NavigateActivity -> {
|
||||
val destination = action.destination
|
||||
val intent = Intent(this@MainActivity, destination.activity.java)
|
||||
intent.putExtras(destination.extras)
|
||||
startActivity(intent)
|
||||
action.onOpened()
|
||||
}
|
||||
|
||||
NavigationAction.GoBack -> supportFragmentManager.popBackStack()
|
||||
|
||||
NavigationAction.Nothing -> Unit
|
||||
}
|
||||
|
||||
// Forward key events to fragments
|
||||
|
||||
@@ -31,7 +31,7 @@ class SuggestedMoviesFragment : EnhancedBrowseFragment() {
|
||||
recursive = true,
|
||||
)
|
||||
|
||||
for (item in response.items.orEmpty()) {
|
||||
for (item in response.items) {
|
||||
val similar = GetSimilarItemsRequest(
|
||||
itemId = item.id,
|
||||
fields = setOf(
|
||||
|
||||
@@ -26,6 +26,8 @@ import org.jellyfin.androidtv.util.sdk.getSeasonEpisodeName
|
||||
import org.jellyfin.androidtv.util.sdk.isNew
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.SeriesStatus
|
||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||
@@ -135,13 +137,23 @@ fun InfoRowSeasonEpisode(item: BaseItemDto) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<MediaStream>.getDefault(type: MediaStreamType, defaultIndex: Int? = null): MediaStream? {
|
||||
if (defaultIndex != null) {
|
||||
val byIndex = getOrNull(defaultIndex)
|
||||
if (byIndex?.type == type) return byIndex
|
||||
}
|
||||
|
||||
return firstOrNull { it.type == type }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InfoRowMediaDetails(item: BaseItemDto) {
|
||||
val videoStream = item.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }
|
||||
val audioStream = item.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO }
|
||||
fun InfoRowMediaDetails(mediaSource: MediaSourceInfo) {
|
||||
val videoStream = mediaSource.mediaStreams?.getDefault(MediaStreamType.VIDEO)
|
||||
val audioStream = mediaSource.mediaStreams?.getDefault(MediaStreamType.AUDIO, mediaSource.defaultAudioStreamIndex)
|
||||
val subtitleStream = mediaSource.mediaStreams?.getDefault(MediaStreamType.SUBTITLE, mediaSource.defaultSubtitleStreamIndex)
|
||||
|
||||
// Subtitles
|
||||
if (item.hasSubtitles == true) {
|
||||
if (subtitleStream != null) {
|
||||
InfoRowItem(
|
||||
contentDescription = null,
|
||||
colors = InfoRowColors.Default,
|
||||
@@ -219,6 +231,7 @@ fun InfoRowMediaDetails(item: BaseItemDto) {
|
||||
@Composable
|
||||
fun BaseItemInfoRow(
|
||||
item: BaseItemDto,
|
||||
mediaSource: MediaSourceInfo?,
|
||||
includeRuntime: Boolean,
|
||||
) {
|
||||
val userPreferences = koinInject<UserPreferences>()
|
||||
@@ -239,7 +252,7 @@ fun BaseItemInfoRow(
|
||||
InfoRowDate(item)
|
||||
if (includeRuntime) item.runTimeTicks?.ticks?.let { BaseItemInfoRowRuntime(it) }
|
||||
item.officialRating?.let { InfoRowParentalRating(it) }
|
||||
InfoRowMediaDetails(item)
|
||||
mediaSource?.let { InfoRowMediaDetails(it) }
|
||||
}
|
||||
|
||||
BaseItemKind.BOX_SET -> {
|
||||
@@ -257,7 +270,7 @@ fun BaseItemInfoRow(
|
||||
val runtime = item.cumulativeRunTimeTicks ?: item.runTimeTicks
|
||||
if (includeRuntime) runtime?.ticks?.let { BaseItemInfoRowRuntime(it) }
|
||||
item.officialRating?.let { InfoRowParentalRating(it) }
|
||||
InfoRowMediaDetails(item)
|
||||
mediaSource?.let { InfoRowMediaDetails(it) }
|
||||
}
|
||||
|
||||
BaseItemKind.SERIES -> {
|
||||
@@ -265,7 +278,7 @@ fun BaseItemInfoRow(
|
||||
if (includeRuntime) item.runTimeTicks?.ticks?.let { BaseItemInfoRowRuntime(it) }
|
||||
InfoRowSeriesStatus(item)
|
||||
item.officialRating?.let { InfoRowParentalRating(it) }
|
||||
InfoRowMediaDetails(item)
|
||||
mediaSource?.let { InfoRowMediaDetails(it) }
|
||||
}
|
||||
|
||||
BaseItemKind.PROGRAM -> {
|
||||
@@ -303,7 +316,7 @@ fun BaseItemInfoRow(
|
||||
|
||||
if (includeRuntime) item.runTimeTicks?.ticks?.let { BaseItemInfoRowRuntime(it) }
|
||||
item.officialRating?.let { InfoRowParentalRating(it) }
|
||||
InfoRowMediaDetails(item)
|
||||
mediaSource?.let { InfoRowMediaDetails(it) }
|
||||
}
|
||||
|
||||
BaseItemKind.MUSIC_ARTIST -> {
|
||||
@@ -343,14 +356,14 @@ fun BaseItemInfoRow(
|
||||
val runtime = item.cumulativeRunTimeTicks ?: item.runTimeTicks
|
||||
if (includeRuntime) runtime?.ticks?.let { BaseItemInfoRowRuntime(it) }
|
||||
item.officialRating?.let { InfoRowParentalRating(it) }
|
||||
InfoRowMediaDetails(item)
|
||||
mediaSource?.let { InfoRowMediaDetails(it) }
|
||||
}
|
||||
|
||||
else -> {
|
||||
InfoRowDate(item)
|
||||
if (includeRuntime) item.runTimeTicks?.ticks?.let { BaseItemInfoRowRuntime(it) }
|
||||
item.officialRating?.let { InfoRowParentalRating(it) }
|
||||
InfoRowMediaDetails(item)
|
||||
mediaSource?.let { InfoRowMediaDetails(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,6 +377,7 @@ class BaseItemInfoRowView @JvmOverloads constructor(
|
||||
attrs: AttributeSet? = null,
|
||||
) : AbstractComposeView(context, attrs) {
|
||||
private val _item = MutableStateFlow<BaseItemDto?>(null)
|
||||
private val _mediaSource = MutableStateFlow<MediaSourceInfo?>(null)
|
||||
private val _includeRuntime = MutableStateFlow(false)
|
||||
|
||||
var item: BaseItemDto?
|
||||
@@ -372,6 +386,12 @@ class BaseItemInfoRowView @JvmOverloads constructor(
|
||||
_item.value = value
|
||||
}
|
||||
|
||||
var mediaSource: MediaSourceInfo?
|
||||
get() = _mediaSource.value
|
||||
set(value) {
|
||||
_mediaSource.value = value
|
||||
}
|
||||
|
||||
var includeRuntime: Boolean
|
||||
get() = _includeRuntime.value
|
||||
set(value) {
|
||||
@@ -386,8 +406,9 @@ class BaseItemInfoRowView @JvmOverloads constructor(
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val item by _item.collectAsState()
|
||||
val mediaSource by _mediaSource.collectAsState()
|
||||
val includeRuntime by _includeRuntime.collectAsState()
|
||||
|
||||
item?.let { BaseItemInfoRow(it, includeRuntime) }
|
||||
item?.let { BaseItemInfoRow(it, mediaSource, includeRuntime) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import androidx.compose.ui.graphics.Color
|
||||
*/
|
||||
object InfoRowColors {
|
||||
val Transparent = Color.Transparent to Color.White
|
||||
val Default = Color(0x80FFFFFF) to Color.Black
|
||||
val Green = Color(0x8034D97C) to Color.Black
|
||||
val Red = Color(0x80F2364D) to Color.Black
|
||||
val Default = Color(0xB3FFFFFF) to Color.Black
|
||||
val Green = Color(0xB3089562) to Color.White
|
||||
val Red = Color(0xB3F2364D) to Color.White
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.ProvideTextStyle
|
||||
@@ -46,6 +47,7 @@ fun InfoRowItem(
|
||||
value = TextStyle(
|
||||
color = foregroundColor,
|
||||
fontSize = if (backgroundColor.alpha > 0f) 12.sp else 16.sp,
|
||||
fontWeight = if (backgroundColor.alpha > 0f) FontWeight.W600 else FontWeight.W500,
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
|
||||
@@ -158,7 +158,7 @@ public class LegacyImageCardView extends BaseCardView {
|
||||
}
|
||||
}
|
||||
|
||||
public CharSequence gettitle() {
|
||||
public CharSequence getTitle() {
|
||||
if (binding.title == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -212,7 +212,7 @@ public class LegacyImageCardView extends BaseCardView {
|
||||
}
|
||||
|
||||
private void setTextMaxLines() {
|
||||
if (TextUtils.isEmpty(gettitle())) {
|
||||
if (TextUtils.isEmpty(getTitle())) {
|
||||
binding.contentText.setMaxLines(2);
|
||||
} else {
|
||||
binding.contentText.setMaxLines(1);
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package org.jellyfin.androidtv.ui.composable
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.Measured
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.LocalTextStyle
|
||||
import androidx.tv.material3.Text
|
||||
import org.jellyfin.sdk.model.api.LyricDto
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class TimedLine(
|
||||
val timestamp: Duration,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
private data class LyricsBoxContentMeasurements(
|
||||
val size: Size,
|
||||
val items: List<Measured>,
|
||||
)
|
||||
|
||||
private fun Collection<TimedLine>.indexAtTimestamp(timestamp: Duration) = indexOfLast { it.timestamp <= timestamp }.takeIf { it != -1 }
|
||||
|
||||
@Composable
|
||||
private fun LyricsLine(
|
||||
text: String,
|
||||
fontSize: TextUnit,
|
||||
color: Color,
|
||||
active: Boolean = false,
|
||||
gap: Dp = 15.dp,
|
||||
) {
|
||||
val color by animateColorAsState(
|
||||
targetValue = if (active) color else color.copy(alpha = 0.5f),
|
||||
animationSpec = tween(),
|
||||
label = "LyricsLineColor",
|
||||
)
|
||||
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (active) 1.1f else 1f,
|
||||
animationSpec = tween(),
|
||||
label = "LyricsLineScale",
|
||||
)
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = fontSize,
|
||||
color = color,
|
||||
modifier = Modifier
|
||||
.padding(bottom = gap)
|
||||
.scale(scale)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> LyricsBoxContent(
|
||||
items: Collection<T>,
|
||||
modifier: Modifier = Modifier,
|
||||
onMeasured: ((measurements: LyricsBoxContentMeasurements) -> Unit)? = null,
|
||||
itemContent: @Composable (item: T, index: Int) -> Unit,
|
||||
) = Layout(
|
||||
modifier = modifier,
|
||||
measurePolicy = { measurables, constraints ->
|
||||
val placeables = measurables.map { measurable ->
|
||||
measurable.measure(constraints.copy(minWidth = constraints.maxWidth))
|
||||
}
|
||||
if (onMeasured != null) {
|
||||
val totalHeight = placeables.sumOf { it.height }.toFloat()
|
||||
onMeasured(LyricsBoxContentMeasurements(Size(1f, totalHeight), placeables))
|
||||
}
|
||||
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
// Start at the offset until the active line and half the height of this container to vertically center the item
|
||||
var yOffset = constraints.maxHeight / 2
|
||||
|
||||
for (placeable in placeables) {
|
||||
placeable.placeRelative(
|
||||
x = 0,
|
||||
y = yOffset,
|
||||
)
|
||||
|
||||
yOffset += placeable.height
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
content = {
|
||||
items.forEachIndexed { index, item ->
|
||||
itemContent(item, index)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LyricsBox(
|
||||
lines: List<TimedLine>,
|
||||
currentTimestamp: Duration = Duration.ZERO,
|
||||
fontSize: TextUnit = LocalTextStyle.current.fontSize,
|
||||
color: Color = LocalTextStyle.current.color,
|
||||
) {
|
||||
var lineMeasurements by remember { mutableStateOf<List<Measured>>(emptyList()) }
|
||||
val activeLine = lines.indexAtTimestamp(currentTimestamp)
|
||||
val activeLineOffsetAnimation = remember { Animatable(0f) }
|
||||
|
||||
LaunchedEffect(activeLine) {
|
||||
var offset = 0f
|
||||
|
||||
if (activeLine != null) {
|
||||
// Add offset for all previous items
|
||||
offset += lineMeasurements.take(activeLine).sumOf { it.measuredHeight }
|
||||
// Add offset for half-height to center item
|
||||
offset += lineMeasurements.getOrNull(activeLine)?.measuredHeight?.div(2) ?: 0
|
||||
}
|
||||
|
||||
activeLineOffsetAnimation.animateTo(offset)
|
||||
}
|
||||
|
||||
LyricsBoxContent(
|
||||
items = lines,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
translationY = -activeLineOffsetAnimation.value
|
||||
},
|
||||
onMeasured = { measurements -> lineMeasurements = measurements.items }
|
||||
) { line, index ->
|
||||
LyricsLine(
|
||||
text = line.text,
|
||||
active = index == activeLine,
|
||||
fontSize = fontSize,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LyricsBox(
|
||||
lines: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
currentTimestamp: Duration = Duration.ZERO,
|
||||
duration: Duration = Duration.ZERO,
|
||||
paused: Boolean = false,
|
||||
fontSize: TextUnit = LocalTextStyle.current.fontSize,
|
||||
color: Color = LocalTextStyle.current.color,
|
||||
) = Box(modifier) {
|
||||
var totalHeight by remember { mutableFloatStateOf(0f) }
|
||||
val progress = rememberPlayerProgress(!paused, currentTimestamp, duration)
|
||||
|
||||
LyricsBoxContent(
|
||||
items = lines,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
translationY = -(progress * totalHeight)
|
||||
},
|
||||
onMeasured = { measurements -> totalHeight = measurements.size.height }
|
||||
) { line, index ->
|
||||
LyricsLine(
|
||||
text = line,
|
||||
fontSize = fontSize,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LyricsDtoBox(
|
||||
lyricDto: LyricDto,
|
||||
modifier: Modifier = Modifier,
|
||||
currentTimestamp: Duration = Duration.ZERO,
|
||||
duration: Duration = Duration.ZERO,
|
||||
paused: Boolean = false,
|
||||
fontSize: TextUnit = LocalTextStyle.current.fontSize,
|
||||
color: Color = LocalTextStyle.current.color,
|
||||
) = Box(modifier) {
|
||||
val lyrics = lyricDto.lyrics
|
||||
val isTimed = lyrics.firstOrNull()?.start != null
|
||||
if (isTimed) {
|
||||
LyricsBox(
|
||||
lines = lyrics.map {
|
||||
TimedLine(it.start?.ticks ?: Duration.ZERO, it.text)
|
||||
},
|
||||
currentTimestamp = currentTimestamp,
|
||||
fontSize = fontSize,
|
||||
color = color,
|
||||
)
|
||||
} else {
|
||||
LyricsBox(
|
||||
lines = lyrics.map { it.text },
|
||||
currentTimestamp = currentTimestamp,
|
||||
duration = duration,
|
||||
paused = paused,
|
||||
fontSize = fontSize,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package org.jellyfin.androidtv.ui.composable
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import org.jellyfin.androidtv.ui.playback.AudioEventListener
|
||||
import org.jellyfin.androidtv.ui.playback.MediaManager
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackController
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@Composable
|
||||
fun rememberMediaItem(
|
||||
mediaManager: MediaManager = koinInject(),
|
||||
): State<BaseItemDto?> {
|
||||
val item = remember { mutableStateOf(mediaManager.currentAudioItem) }
|
||||
|
||||
DisposableEffect(mediaManager) {
|
||||
val listener = object : AudioEventListener {
|
||||
override fun onPlaybackStateChange(newState: PlaybackController.PlaybackState, currentItem: BaseItemDto?) {
|
||||
item.value = currentItem
|
||||
}
|
||||
|
||||
override fun onQueueStatusChanged(hasQueue: Boolean) {
|
||||
super.onQueueStatusChanged(hasQueue)
|
||||
|
||||
item.value = mediaManager.currentAudioItem
|
||||
}
|
||||
}
|
||||
mediaManager.addAudioEventListener(listener)
|
||||
onDispose { mediaManager.removeAudioEventListener(listener) }
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package org.jellyfin.androidtv.ui.composable.modifier
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
fun Modifier.fadingEdges(
|
||||
all: Dp
|
||||
) = fadingEdges(
|
||||
start = all,
|
||||
top = all,
|
||||
end = all,
|
||||
bottom = all,
|
||||
)
|
||||
|
||||
fun Modifier.fadingEdges(
|
||||
vertical: Dp = 0.dp,
|
||||
horizontal: Dp = 0.dp,
|
||||
) = fadingEdges(
|
||||
start = horizontal,
|
||||
top = vertical,
|
||||
end = horizontal,
|
||||
bottom = vertical,
|
||||
)
|
||||
|
||||
fun Modifier.fadingEdges(
|
||||
start: Dp = 0.dp,
|
||||
top: Dp = 0.dp,
|
||||
end: Dp = 0.dp,
|
||||
bottom: Dp = 0.dp,
|
||||
) = then(
|
||||
Modifier
|
||||
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
|
||||
// Start edge
|
||||
if (start.value > 0) {
|
||||
drawRect(
|
||||
brush = Brush.horizontalGradient(
|
||||
0f to Color.Transparent,
|
||||
1f to Color.Black,
|
||||
endX = start.toPx(),
|
||||
),
|
||||
blendMode = BlendMode.DstIn,
|
||||
)
|
||||
}
|
||||
|
||||
// Top edge
|
||||
if (top.value > 0) {
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
0f to Color.Transparent,
|
||||
1f to Color.Black,
|
||||
endY = top.toPx(),
|
||||
),
|
||||
blendMode = BlendMode.DstIn,
|
||||
)
|
||||
}
|
||||
|
||||
// End edge
|
||||
if (end.value > 0) {
|
||||
drawRect(
|
||||
brush = Brush.horizontalGradient(
|
||||
0f to Color.Black,
|
||||
1f to Color.Transparent,
|
||||
startX = size.width - end.toPx(),
|
||||
),
|
||||
blendMode = BlendMode.DstIn,
|
||||
)
|
||||
}
|
||||
|
||||
// Bottom edge
|
||||
if (bottom.value > 0) {
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
0f to Color.Black,
|
||||
1f to Color.Transparent,
|
||||
startY = size.height - bottom.toPx(),
|
||||
),
|
||||
blendMode = BlendMode.DstIn
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun FadingEdgePreview() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(100.dp)
|
||||
.background(Color.Red)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(50.dp)
|
||||
.fadingEdges(10.dp, 0.dp, 25.dp, 3.dp)
|
||||
.background(Color.Blue)
|
||||
.align(Alignment.Center)
|
||||
) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.jellyfin.androidtv.ui.composable.modifier
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Default overscan values of 48 horizontal and 27 vertical display pixels.
|
||||
*/
|
||||
val overscanPaddingValues = PaddingValues(48.dp, 27.dp)
|
||||
|
||||
/**
|
||||
* Apply a [padding] with [overscanPaddingValues].
|
||||
*/
|
||||
fun Modifier.overscan(): Modifier = padding(overscanPaddingValues)
|
||||
@@ -1,10 +0,0 @@
|
||||
package org.jellyfin.androidtv.ui.composable
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Apply a [padding] with the default overscan values of 48 horizontal and 27 vertical display pixels.
|
||||
*/
|
||||
fun Modifier.overscan(): Modifier = then(padding(48.dp, 27.dp))
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.jellyfin.androidtv.ui.composable
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import org.jellyfin.playback.core.PlaybackManager
|
||||
import org.jellyfin.playback.core.model.PlayState
|
||||
import org.jellyfin.playback.core.queue.queue
|
||||
import org.koin.compose.koinInject
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Duration
|
||||
|
||||
@Composable
|
||||
fun rememberQueueEntry(
|
||||
playbackManager: PlaybackManager = koinInject(),
|
||||
) = remember(playbackManager) {
|
||||
playbackManager.queue.entry
|
||||
}.collectAsState()
|
||||
|
||||
@Composable
|
||||
fun rememberPlayerProgress(
|
||||
playbackManager: PlaybackManager = koinInject(),
|
||||
): Float {
|
||||
val playState by playbackManager.state.playState.collectAsState()
|
||||
val active = playbackManager.state.positionInfo.active
|
||||
val duration = playbackManager.state.positionInfo.duration
|
||||
|
||||
return rememberPlayerProgress(
|
||||
playing = playState == PlayState.PLAYING,
|
||||
active = active,
|
||||
duration = duration,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberPlayerProgress(
|
||||
playing: Boolean,
|
||||
active: Duration,
|
||||
duration: Duration,
|
||||
): Float {
|
||||
val animatable = remember { Animatable(0f) }
|
||||
|
||||
LaunchedEffect(playing, duration) {
|
||||
val activeMs = active.inWholeMilliseconds.toFloat()
|
||||
val durationMs = duration.inWholeMilliseconds.toFloat()
|
||||
|
||||
if (active == Duration.ZERO) animatable.snapTo(0f)
|
||||
else animatable.snapTo((activeMs / durationMs).coerceIn(0f, 1f))
|
||||
|
||||
if (playing) {
|
||||
animatable.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(
|
||||
durationMillis = (durationMs - activeMs).roundToInt(),
|
||||
easing = LinearEasing,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return animatable.value
|
||||
}
|
||||
@@ -4,9 +4,8 @@ import android.content.Context
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
import org.jellyfin.androidtv.constant.ChangeTriggerType
|
||||
import org.jellyfin.androidtv.data.querying.GetUserViewsRequest
|
||||
import org.jellyfin.androidtv.data.repository.UserViewsRepository
|
||||
import org.jellyfin.androidtv.ui.browsing.BrowseRowDef
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
@@ -19,14 +18,9 @@ import org.jellyfin.sdk.model.api.ItemFields as SdkItemFields
|
||||
class HomeFragmentHelper(
|
||||
private val context: Context,
|
||||
private val userRepository: UserRepository,
|
||||
private val userViewsRepository: UserViewsRepository,
|
||||
) {
|
||||
fun loadRecentlyAdded(): HomeFragmentRow {
|
||||
return HomeFragmentLatestRow(userRepository, userViewsRepository)
|
||||
}
|
||||
|
||||
fun loadLibraryTiles(): HomeFragmentRow {
|
||||
return HomeFragmentBrowseRowDefRow(BrowseRowDef(context.getString(R.string.lbl_my_media), GetUserViewsRequest))
|
||||
fun loadRecentlyAdded(userViews: Collection<BaseItemDto>): HomeFragmentRow {
|
||||
return HomeFragmentLatestRow(userRepository, userViews)
|
||||
}
|
||||
|
||||
fun loadResume(title: String, includeMediaTypes: Collection<MediaType>): HomeFragmentRow {
|
||||
|
||||
@@ -2,22 +2,20 @@ package org.jellyfin.androidtv.ui.home
|
||||
|
||||
import android.content.Context
|
||||
import androidx.leanback.widget.Row
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
import org.jellyfin.androidtv.constant.ChangeTriggerType
|
||||
import org.jellyfin.androidtv.data.repository.UserViewsRepository
|
||||
import org.jellyfin.androidtv.ui.browsing.BrowseRowDef
|
||||
import org.jellyfin.androidtv.ui.presentation.CardPresenter
|
||||
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||
|
||||
class HomeFragmentLatestRow(
|
||||
private val userRepository: UserRepository,
|
||||
private val userViewsRepository: UserViewsRepository,
|
||||
private val userViews: Collection<BaseItemDto>,
|
||||
) : HomeFragmentRow {
|
||||
override fun addToRowsAdapter(context: Context, cardPresenter: CardPresenter, rowsAdapter: MutableObjectAdapter<Row>) {
|
||||
// Get configuration (to find excluded items)
|
||||
@@ -25,8 +23,7 @@ class HomeFragmentLatestRow(
|
||||
|
||||
// Create a list of views to include
|
||||
val latestItemsExcludes = configuration?.latestItemsExcludes.orEmpty()
|
||||
val views = runBlocking { userViewsRepository.views.first() }
|
||||
views
|
||||
userViews
|
||||
.filterNot { item -> item.collectionType in EXCLUDED_COLLECTION_TYPES || item.id in latestItemsExcludes }
|
||||
.map { item ->
|
||||
// Create query and add it to a new row
|
||||
@@ -52,7 +49,7 @@ class HomeFragmentLatestRow(
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Collections exclused from latest row based on app support and common sense
|
||||
// Collections excluded from latest row based on app support and common sense
|
||||
private val EXCLUDED_COLLECTION_TYPES = arrayOf(
|
||||
CollectionType.PLAYLISTS,
|
||||
CollectionType.LIVETV,
|
||||
@@ -60,7 +57,7 @@ class HomeFragmentLatestRow(
|
||||
CollectionType.BOOKS,
|
||||
)
|
||||
|
||||
// Maximum ammount of items loaded for a row
|
||||
// Maximum amount of items loaded for a row
|
||||
private const val ITEM_LIMIT = 50
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.jellyfin.androidtv.ui.home
|
||||
|
||||
import android.content.Context
|
||||
import androidx.leanback.widget.HeaderItem
|
||||
import androidx.leanback.widget.ListRow
|
||||
import androidx.leanback.widget.Row
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.data.querying.GetUserViewsRequest
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter
|
||||
import org.jellyfin.androidtv.ui.presentation.CardPresenter
|
||||
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter
|
||||
import org.jellyfin.androidtv.ui.presentation.UserViewCardPresenter
|
||||
|
||||
class HomeFragmentViewsRow(
|
||||
val small: Boolean,
|
||||
) : HomeFragmentRow {
|
||||
private companion object {
|
||||
val smallCardPresenter = UserViewCardPresenter(true)
|
||||
val largeCardPresenter = UserViewCardPresenter(false)
|
||||
}
|
||||
|
||||
override fun addToRowsAdapter(context: Context, cardPresenter: CardPresenter, rowsAdapter: MutableObjectAdapter<Row>) {
|
||||
val presenter = if (small) smallCardPresenter else largeCardPresenter
|
||||
val rowAdapter = ItemRowAdapter(context, GetUserViewsRequest, presenter, rowsAdapter)
|
||||
|
||||
val header = HeaderItem(context.getString(R.string.lbl_my_media))
|
||||
val row = ListRow(header, rowAdapter)
|
||||
rowAdapter.setRow(row)
|
||||
rowAdapter.Retrieve()
|
||||
rowsAdapter.add(row)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import org.jellyfin.androidtv.data.repository.CustomMessageRepository
|
||||
import org.jellyfin.androidtv.data.repository.NotificationsRepository
|
||||
import org.jellyfin.androidtv.data.repository.UserViewsRepository
|
||||
import org.jellyfin.androidtv.data.service.BackgroundService
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.preference.UserSettingPreferences
|
||||
import org.jellyfin.androidtv.ui.browsing.CompositeClickedListener
|
||||
import org.jellyfin.androidtv.ui.browsing.CompositeSelectedListener
|
||||
@@ -62,7 +61,6 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
|
||||
private val mediaManager by inject<MediaManager>()
|
||||
private val notificationsRepository by inject<NotificationsRepository>()
|
||||
private val userRepository by inject<UserRepository>()
|
||||
private val userPreferences by inject<UserPreferences>()
|
||||
private val userSettingPreferences by inject<UserSettingPreferences>()
|
||||
private val userViewsRepository by inject<UserViewsRepository>()
|
||||
private val dataRefreshService by inject<DataRefreshService>()
|
||||
@@ -71,7 +69,7 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
|
||||
private val itemLauncher by inject<ItemLauncher>()
|
||||
private val keyProcessor by inject<KeyProcessor>()
|
||||
|
||||
private val helper by lazy { HomeFragmentHelper(requireContext(), userRepository, userViewsRepository) }
|
||||
private val helper by lazy { HomeFragmentHelper(requireContext(), userRepository) }
|
||||
|
||||
// Data
|
||||
private var currentItem: BaseRowItem? = null
|
||||
@@ -94,7 +92,7 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
|
||||
}
|
||||
|
||||
// Start out with default sections
|
||||
val homesections = userSettingPreferences.homesections
|
||||
val homesections = userSettingPreferences.activeHomesections
|
||||
var includeLiveTvRows = false
|
||||
|
||||
// Check for live TV support
|
||||
@@ -107,7 +105,7 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
|
||||
isAiring = true,
|
||||
limit = 1,
|
||||
)
|
||||
includeLiveTvRows = !recommendedPrograms.items.isNullOrEmpty()
|
||||
includeLiveTvRows = recommendedPrograms.items.isNotEmpty()
|
||||
}
|
||||
|
||||
// Make sure the rows are empty
|
||||
@@ -118,9 +116,9 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
|
||||
|
||||
// Actually add the sections
|
||||
for (section in homesections) when (section) {
|
||||
HomeSectionType.LATEST_MEDIA -> rows.add(helper.loadRecentlyAdded())
|
||||
HomeSectionType.LIBRARY_TILES_SMALL -> rows.add(helper.loadLibraryTiles())
|
||||
HomeSectionType.LIBRARY_BUTTONS -> rows.add(helper.loadLibraryTiles())
|
||||
HomeSectionType.LATEST_MEDIA -> rows.add(helper.loadRecentlyAdded(userViewsRepository.views.first()))
|
||||
HomeSectionType.LIBRARY_TILES_SMALL -> rows.add(HomeFragmentViewsRow(small = false))
|
||||
HomeSectionType.LIBRARY_BUTTONS -> rows.add(HomeFragmentViewsRow(small = true))
|
||||
HomeSectionType.RESUME -> rows.add(helper.loadResumeVideo())
|
||||
HomeSectionType.RESUME_AUDIO -> rows.add(helper.loadResumeAudio())
|
||||
HomeSectionType.RESUME_BOOK -> Unit // Books are not (yet) supported
|
||||
@@ -166,15 +164,13 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
|
||||
|
||||
lifecycleScope.launch {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
if (userPreferences[UserPreferences.homeReactive]) {
|
||||
api.webSocket.subscribe<UserDataChangedMessage>()
|
||||
.onEach { refreshRows(force = true, delayed = false) }
|
||||
.launchIn(this)
|
||||
api.webSocket.subscribe<UserDataChangedMessage>()
|
||||
.onEach { refreshRows(force = true, delayed = false) }
|
||||
.launchIn(this)
|
||||
|
||||
api.webSocket.subscribe<LibraryChangedMessage>()
|
||||
.onEach { refreshRows(force = true, delayed = false) }
|
||||
.launchIn(this)
|
||||
}
|
||||
api.webSocket.subscribe<LibraryChangedMessage>()
|
||||
.onEach { refreshRows(force = true, delayed = false) }
|
||||
.launchIn(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -709,7 +709,7 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
}
|
||||
});
|
||||
} else {
|
||||
mediaManager.getValue().addToAudioQueue(Arrays.asList(baseItem));
|
||||
mediaManager.getValue().queueAudioItem(baseItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1186,6 +1186,10 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
@Override
|
||||
public void onResponse(List<BaseItemDto> response) {
|
||||
if (!getActive()) return;
|
||||
if (response.isEmpty()) {
|
||||
Timber.e("No items to play - ignoring play request.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.getType() == BaseItemKind.MUSIC_ARTIST) {
|
||||
mediaManager.getValue().playNow(requireContext(), response, 0, shuffle);
|
||||
@@ -1196,7 +1200,6 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
void play(final List<BaseItemDto> items, final int pos, final boolean shuffle) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.ItemFilter
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
@@ -241,6 +242,12 @@ fun FullDetailsFragment.resumePlayback() {
|
||||
includeItemTypes = setOf(BaseItemKind.EPISODE),
|
||||
recursive = true,
|
||||
filters = setOf(ItemFilter.IS_UNPLAYED),
|
||||
fields = setOf(
|
||||
ItemFields.MEDIA_SOURCES,
|
||||
ItemFields.MEDIA_STREAMS,
|
||||
ItemFields.CHAPTERS,
|
||||
ItemFields.TRICKPLAY,
|
||||
),
|
||||
sortBy = setOf(
|
||||
ItemSortBy.PARENT_INDEX_NUMBER,
|
||||
ItemSortBy.INDEX_NUMBER,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.jellyfin.androidtv.ui.itemdetail
|
||||
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.data.repository.ItemMutationRepository
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
@@ -29,7 +30,7 @@ fun ItemListFragment.loadItem(itemId: UUID) {
|
||||
|
||||
lifecycleScope.launch {
|
||||
val item by api.userLibraryApi.getItem(itemId)
|
||||
setBaseItem(item)
|
||||
if (isActive) setBaseItem(item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +51,7 @@ fun MusicFavoritesListFragment.getFavoritePlaylist(
|
||||
fields = itemFields,
|
||||
)
|
||||
|
||||
callback(result.items.orEmpty())
|
||||
callback(result.items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +79,7 @@ fun ItemListFragment.getPlaylist(
|
||||
)
|
||||
}
|
||||
|
||||
callback(result.items.orEmpty())
|
||||
callback(result.items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +89,7 @@ fun ItemListFragment.toggleFavorite(item: BaseItemDto, callback: (item: BaseItem
|
||||
lifecycleScope.launch {
|
||||
val userData = itemMutationRepository.setFavorite(
|
||||
item = item.id,
|
||||
favorite = item.userData?.isFavorite ?: true
|
||||
favorite = !(item.userData?.isFavorite ?: false)
|
||||
)
|
||||
callback(item.copy(userData = userData))
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ open class BaseItemDtoBaseRowItem @JvmOverloads constructor(
|
||||
val preferSeriesPoster: Boolean = false,
|
||||
) : BaseRowItem(
|
||||
baseRowType = when (item.type) {
|
||||
BaseItemKind.CHANNEL,
|
||||
BaseItemKind.TV_CHANNEL,
|
||||
BaseItemKind.LIVE_TV_CHANNEL -> BaseRowType.LiveTvChannel
|
||||
|
||||
@@ -80,6 +79,8 @@ open class BaseItemDtoBaseRowItem @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
override fun getCardName(context: Context) = when {
|
||||
baseItem?.type == BaseItemKind.AUDIO && baseItem.artists != null -> baseItem.artists?.joinToString(", ")
|
||||
baseItem?.type == BaseItemKind.AUDIO && baseItem.albumArtists != null -> baseItem.albumArtists?.joinToString(", ")
|
||||
baseItem?.type == BaseItemKind.AUDIO && baseItem.albumArtist != null -> baseItem.albumArtist
|
||||
baseItem?.type == BaseItemKind.AUDIO && baseItem.album != null -> baseItem.album
|
||||
else -> baseItem?.getFullName(context)
|
||||
|
||||
@@ -77,7 +77,7 @@ fun ItemRowAdapter.retrieveResumeItems(api: ApiClient, query: GetResumeItemsRequ
|
||||
val response by api.itemsApi.getResumeItems(query)
|
||||
|
||||
setItems(
|
||||
items = response.items.orEmpty(),
|
||||
items = response.items,
|
||||
transform = { item, _ ->
|
||||
BaseItemDtoBaseRowItem(
|
||||
item,
|
||||
@@ -87,7 +87,7 @@ fun ItemRowAdapter.retrieveResumeItems(api: ApiClient, query: GetResumeItemsRequ
|
||||
}
|
||||
)
|
||||
|
||||
if (response.items.isNullOrEmpty()) removeRow()
|
||||
if (response.items.isEmpty()) removeRow()
|
||||
}.fold(
|
||||
onSuccess = { notifyRetrieveFinished() },
|
||||
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
|
||||
@@ -101,8 +101,8 @@ fun ItemRowAdapter.retrieveNextUpItems(api: ApiClient, query: GetNextUpRequest)
|
||||
val response by api.tvShowsApi.getNextUp(query)
|
||||
|
||||
// Some special flavor for series, used in FullDetailsFragment
|
||||
val firstNextUp = response.items?.firstOrNull()
|
||||
if (query.seriesId != null && response.items?.size == 1 && firstNextUp?.seasonId != null && firstNextUp.indexNumber != null) {
|
||||
val firstNextUp = response.items.firstOrNull()
|
||||
if (query.seriesId != null && response.items.size == 1 && firstNextUp?.seasonId != null && firstNextUp.indexNumber != null) {
|
||||
// If we have exactly 1 episode returned, the series is currently partially watched
|
||||
// we want to query the server for all episodes in the same season starting from
|
||||
// this one to create a list of all unwatched episodes
|
||||
@@ -114,7 +114,7 @@ fun ItemRowAdapter.retrieveNextUpItems(api: ApiClient, query: GetNextUpRequest)
|
||||
// Combine the next up episode with the additionally retrieved episodes
|
||||
val items = buildList {
|
||||
add(firstNextUp)
|
||||
episodesResponse.items?.let { addAll(it) }
|
||||
addAll(episodesResponse.items)
|
||||
}
|
||||
|
||||
setItems(
|
||||
@@ -131,7 +131,7 @@ fun ItemRowAdapter.retrieveNextUpItems(api: ApiClient, query: GetNextUpRequest)
|
||||
if (items.isEmpty()) removeRow()
|
||||
} else {
|
||||
setItems(
|
||||
items = response.items.orEmpty(),
|
||||
items = response.items,
|
||||
transform = { item, _ ->
|
||||
BaseItemDtoBaseRowItem(
|
||||
item,
|
||||
@@ -141,7 +141,7 @@ fun ItemRowAdapter.retrieveNextUpItems(api: ApiClient, query: GetNextUpRequest)
|
||||
}
|
||||
)
|
||||
|
||||
if (response.items.isNullOrEmpty()) removeRow()
|
||||
if (response.items.isEmpty()) removeRow()
|
||||
}
|
||||
}.fold(
|
||||
onSuccess = { notifyRetrieveFinished() },
|
||||
@@ -202,11 +202,11 @@ fun ItemRowAdapter.retrieveAdditionalParts(api: ApiClient, query: GetAdditionalP
|
||||
val response by api.videosApi.getAdditionalPart(query.itemId)
|
||||
|
||||
setItems(
|
||||
items = response.items.orEmpty(),
|
||||
items = response.items,
|
||||
transform = { item, _ -> BaseItemDtoBaseRowItem(item) }
|
||||
)
|
||||
|
||||
if (response.items.isNullOrEmpty()) removeRow()
|
||||
if (response.items.isEmpty()) removeRow()
|
||||
}.fold(
|
||||
onSuccess = { notifyRetrieveFinished() },
|
||||
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
|
||||
@@ -219,7 +219,7 @@ fun ItemRowAdapter.retrieveUserViews(api: ApiClient, userViewsRepository: UserVi
|
||||
runCatching {
|
||||
val response by api.userViewsApi.getUserViews()
|
||||
|
||||
val filteredItems = response.items.orEmpty()
|
||||
val filteredItems = response.items
|
||||
.filter { userViewsRepository.isSupported(it.collectionType) }
|
||||
.map { it.copy(displayPreferencesId = it.id.toString()) }
|
||||
|
||||
@@ -242,11 +242,11 @@ fun ItemRowAdapter.retrieveSeasons(api: ApiClient, query: GetSeasonsRequest) {
|
||||
val response by api.tvShowsApi.getSeasons(query)
|
||||
|
||||
setItems(
|
||||
items = response.items.orEmpty(),
|
||||
items = response.items,
|
||||
transform = { item, _ -> BaseItemDtoBaseRowItem(item) }
|
||||
)
|
||||
|
||||
if (response.items.isNullOrEmpty()) removeRow()
|
||||
if (response.items.isEmpty()) removeRow()
|
||||
}.fold(
|
||||
onSuccess = { notifyRetrieveFinished() },
|
||||
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
|
||||
@@ -260,11 +260,11 @@ fun ItemRowAdapter.retrieveUpcomingEpisodes(api: ApiClient, query: GetUpcomingEp
|
||||
val response by api.tvShowsApi.getUpcomingEpisodes(query)
|
||||
|
||||
setItems(
|
||||
items = response.items.orEmpty(),
|
||||
items = response.items,
|
||||
transform = { item, _ -> BaseItemDtoBaseRowItem(item) }
|
||||
)
|
||||
|
||||
if (response.items.isNullOrEmpty()) removeRow()
|
||||
if (response.items.isEmpty()) removeRow()
|
||||
}.fold(
|
||||
onSuccess = { notifyRetrieveFinished() },
|
||||
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
|
||||
@@ -278,11 +278,11 @@ fun ItemRowAdapter.retrieveSimilarItems(api: ApiClient, query: GetSimilarItemsRe
|
||||
val response by api.libraryApi.getSimilarItems(query)
|
||||
|
||||
setItems(
|
||||
items = response.items.orEmpty(),
|
||||
items = response.items,
|
||||
transform = { item, _ -> BaseItemDtoBaseRowItem(item) }
|
||||
)
|
||||
|
||||
if (response.items.isNullOrEmpty()) removeRow()
|
||||
if (response.items.isEmpty()) removeRow()
|
||||
}.fold(
|
||||
onSuccess = { notifyRetrieveFinished() },
|
||||
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
|
||||
@@ -325,7 +325,7 @@ fun ItemRowAdapter.retrieveLiveTvRecommendedPrograms(
|
||||
val response by api.liveTvApi.getRecommendedPrograms(query)
|
||||
|
||||
setItems(
|
||||
items = response.items.orEmpty(),
|
||||
items = response.items,
|
||||
transform = { item, _ ->
|
||||
BaseItemDtoBaseRowItem(
|
||||
item,
|
||||
@@ -335,7 +335,7 @@ fun ItemRowAdapter.retrieveLiveTvRecommendedPrograms(
|
||||
}
|
||||
)
|
||||
|
||||
if (response.items.isNullOrEmpty()) removeRow()
|
||||
if (response.items.isEmpty()) removeRow()
|
||||
}.fold(
|
||||
onSuccess = { notifyRetrieveFinished() },
|
||||
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
|
||||
@@ -349,7 +349,7 @@ fun ItemRowAdapter.retrieveLiveTvRecordings(api: ApiClient, query: GetRecordings
|
||||
val response by api.liveTvApi.getRecordings(query)
|
||||
|
||||
setItems(
|
||||
items = response.items.orEmpty(),
|
||||
items = response.items,
|
||||
transform = { item, _ ->
|
||||
BaseItemDtoBaseRowItem(
|
||||
item,
|
||||
@@ -359,7 +359,7 @@ fun ItemRowAdapter.retrieveLiveTvRecordings(api: ApiClient, query: GetRecordings
|
||||
}
|
||||
)
|
||||
|
||||
if (response.items.isNullOrEmpty()) removeRow()
|
||||
if (response.items.isEmpty()) removeRow()
|
||||
}.fold(
|
||||
onSuccess = { notifyRetrieveFinished() },
|
||||
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
|
||||
@@ -401,7 +401,7 @@ fun ItemRowAdapter.retrieveLiveTvSeriesTimers(
|
||||
)
|
||||
}
|
||||
|
||||
addAll(response.items.orEmpty())
|
||||
addAll(response.items)
|
||||
},
|
||||
transform = { item, _ ->
|
||||
when (item) {
|
||||
@@ -412,7 +412,7 @@ fun ItemRowAdapter.retrieveLiveTvSeriesTimers(
|
||||
}
|
||||
)
|
||||
|
||||
if (response.items.isNullOrEmpty()) removeRow()
|
||||
if (response.items.isEmpty()) removeRow()
|
||||
}.fold(
|
||||
onSuccess = { notifyRetrieveFinished() },
|
||||
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
|
||||
@@ -437,7 +437,7 @@ fun ItemRowAdapter.retrieveLiveTvChannels(
|
||||
|
||||
totalItems = response.totalRecordCount
|
||||
setItems(
|
||||
items = response.items.orEmpty(),
|
||||
items = response.items,
|
||||
transform = { item, _ ->
|
||||
BaseItemDtoBaseRowItem(
|
||||
item,
|
||||
@@ -447,7 +447,7 @@ fun ItemRowAdapter.retrieveLiveTvChannels(
|
||||
},
|
||||
)
|
||||
|
||||
if (response.items.isNullOrEmpty()) removeRow()
|
||||
if (response.items.isEmpty()) removeRow()
|
||||
}.fold(
|
||||
onSuccess = { notifyRetrieveFinished() },
|
||||
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
|
||||
@@ -472,7 +472,7 @@ fun ItemRowAdapter.retrieveAlbumArtists(
|
||||
|
||||
totalItems = response.totalRecordCount
|
||||
setItems(
|
||||
items = response.items.orEmpty(),
|
||||
items = response.items,
|
||||
transform = { item, _ ->
|
||||
BaseItemDtoBaseRowItem(
|
||||
item,
|
||||
@@ -482,7 +482,7 @@ fun ItemRowAdapter.retrieveAlbumArtists(
|
||||
},
|
||||
)
|
||||
|
||||
if (response.items.isNullOrEmpty()) removeRow()
|
||||
if (response.items.isEmpty()) removeRow()
|
||||
}.fold(
|
||||
onSuccess = { notifyRetrieveFinished() },
|
||||
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
|
||||
@@ -507,7 +507,7 @@ fun ItemRowAdapter.retrieveArtists(
|
||||
|
||||
totalItems = response.totalRecordCount
|
||||
setItems(
|
||||
items = response.items.orEmpty(),
|
||||
items = response.items,
|
||||
transform = { item, _ ->
|
||||
BaseItemDtoBaseRowItem(
|
||||
item,
|
||||
@@ -517,7 +517,7 @@ fun ItemRowAdapter.retrieveArtists(
|
||||
},
|
||||
)
|
||||
|
||||
if (response.items.isNullOrEmpty()) removeRow()
|
||||
if (response.items.isEmpty()) removeRow()
|
||||
}.fold(
|
||||
onSuccess = { notifyRetrieveFinished() },
|
||||
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
|
||||
@@ -542,7 +542,7 @@ fun ItemRowAdapter.retrieveItems(
|
||||
|
||||
totalItems = response.totalRecordCount
|
||||
setItems(
|
||||
items = response.items.orEmpty(),
|
||||
items = response.items,
|
||||
transform = { item, _ ->
|
||||
BaseItemDtoBaseRowItem(
|
||||
item,
|
||||
@@ -552,7 +552,7 @@ fun ItemRowAdapter.retrieveItems(
|
||||
},
|
||||
)
|
||||
|
||||
if (response.items.isNullOrEmpty()) removeRow()
|
||||
if (response.items.isEmpty()) removeRow()
|
||||
}.fold(
|
||||
onSuccess = { notifyRetrieveFinished() },
|
||||
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
|
||||
@@ -567,12 +567,9 @@ fun ItemRowAdapter.retrievePremieres(
|
||||
ProcessLifecycleOwner.get().lifecycleScope.launch {
|
||||
runCatching {
|
||||
val response by api.itemsApi.getItems(query)
|
||||
val filteredItems = response.items
|
||||
.orEmpty()
|
||||
.filter { it.indexNumber == 1 }
|
||||
|
||||
setItems(
|
||||
items = filteredItems,
|
||||
items = response.items,
|
||||
transform = { item, _ ->
|
||||
BaseItemDtoBaseRowItem(
|
||||
item,
|
||||
@@ -582,7 +579,7 @@ fun ItemRowAdapter.retrievePremieres(
|
||||
}
|
||||
)
|
||||
|
||||
if (filteredItems.isEmpty()) removeRow()
|
||||
if (response.items.isEmpty()) removeRow()
|
||||
}.fold(
|
||||
onSuccess = { notifyRetrieveFinished() },
|
||||
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
|
||||
|
||||
@@ -538,8 +538,8 @@ public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.O
|
||||
// set focus parameters if we are not on first row
|
||||
// this makes focus movements more predictable for the grid view
|
||||
if (prevRow != null) {
|
||||
TvManager.setFocusParms(row, prevRow, true);
|
||||
TvManager.setFocusParms(prevRow, row, false);
|
||||
TvManager.setFocusParams(row, prevRow, true);
|
||||
TvManager.setFocusParams(prevRow, row, false);
|
||||
}
|
||||
prevRow = row;
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ public class TvManager {
|
||||
}
|
||||
|
||||
// this makes focus movements more predictable for the grid view
|
||||
public static void setFocusParms(LinearLayout currentRow, LinearLayout otherRow, boolean up) {
|
||||
public static void setFocusParams(LinearLayout currentRow, LinearLayout otherRow, boolean up) {
|
||||
for (int currentRowNdx = 0; currentRowNdx < currentRow.getChildCount(); currentRowNdx++) {
|
||||
ProgramGridCell cell = (ProgramGridCell) currentRow.getChildAt(currentRowNdx);
|
||||
ProgramGridCell otherCell = getOtherCell(otherRow, cell);
|
||||
|
||||
@@ -40,7 +40,7 @@ fun loadLiveTvChannels(fragment: Fragment, callback: (channels: Collection<BaseI
|
||||
enableFavoriteSorting = liveTvPreferences[LiveTvPreferences.favsAtTop],
|
||||
sortBy = if (sortDatePlayed) setOf(ItemSortBy.DATE_PLAYED) else setOf(ItemSortBy.SORT_NAME),
|
||||
sortOrder = if (sortDatePlayed) SortOrder.DESCENDING else SortOrder.ASCENDING,
|
||||
).content.items.orEmpty()
|
||||
).content.items
|
||||
}.fold(
|
||||
onSuccess = { channels -> callback(channels) },
|
||||
onFailure = { callback(null) },
|
||||
@@ -84,7 +84,7 @@ fun getScheduleRows(
|
||||
runCatching {
|
||||
api.liveTvApi.getTimers(
|
||||
seriesTimerId = seriesTimerId,
|
||||
).content.items.orEmpty()
|
||||
).content.items
|
||||
}.fold(
|
||||
onSuccess = { timers ->
|
||||
val groupedTimers = timers
|
||||
|
||||
@@ -15,7 +15,7 @@ sealed interface NavigationAction {
|
||||
) : NavigationAction
|
||||
|
||||
/**
|
||||
* Open the activity in [destination] and immediatly call [onOpened] to clear the emitted state.
|
||||
* Open the activity in [destination] and immediately call [onOpened] to clear the emitted state.
|
||||
*/
|
||||
data class NavigateActivity(
|
||||
val destination: Destination.Activity,
|
||||
|
||||
@@ -60,20 +60,13 @@ interface NavigationRepository {
|
||||
}
|
||||
|
||||
class NavigationRepositoryImpl(
|
||||
private val initialDestination: Destination.Fragment,
|
||||
private val defaultDestination: Destination.Fragment,
|
||||
) : NavigationRepository {
|
||||
private val fragmentHistory = Stack<Destination.Fragment>()
|
||||
|
||||
private val _currentAction = MutableSharedFlow<NavigationAction>(1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
override val currentAction = _currentAction.asSharedFlow()
|
||||
|
||||
init {
|
||||
// Never add the initial destination to the history to prevent an empty screen when the user
|
||||
// uses the "back" button to close the app
|
||||
_currentAction.tryEmit(NavigationAction.NavigateFragment(initialDestination, false, false, false))
|
||||
Timber.d("Navigating to $initialDestination (via init)")
|
||||
}
|
||||
|
||||
override fun navigate(destination: Destination, replace: Boolean) {
|
||||
Timber.d("Navigating to $destination (via navigate function)")
|
||||
val action = when (destination) {
|
||||
@@ -83,11 +76,11 @@ class NavigationRepositoryImpl(
|
||||
_currentAction.tryEmit(NavigationAction.Nothing)
|
||||
}
|
||||
}
|
||||
_currentAction.tryEmit(action)
|
||||
if (destination is Destination.Fragment) {
|
||||
if (replace && fragmentHistory.isNotEmpty()) fragmentHistory[fragmentHistory.lastIndex] = destination
|
||||
else fragmentHistory.push(destination)
|
||||
}
|
||||
_currentAction.tryEmit(action)
|
||||
}
|
||||
|
||||
override val canGoBack: Boolean get() = fragmentHistory.isNotEmpty()
|
||||
@@ -103,8 +96,8 @@ class NavigationRepositoryImpl(
|
||||
|
||||
override fun reset(destination: Destination.Fragment?, clearHistory: Boolean) {
|
||||
fragmentHistory.clear()
|
||||
val actualDestination = destination ?: initialDestination
|
||||
_currentAction.tryEmit(NavigationAction.NavigateFragment(actualDestination, false, false, clearHistory))
|
||||
val actualDestination = destination ?: defaultDestination
|
||||
_currentAction.tryEmit(NavigationAction.NavigateFragment(actualDestination, true, false, clearHistory))
|
||||
Timber.d("Navigating to $actualDestination (via reset, clearHistory=$clearHistory)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ class PictureViewerFragment : Fragment(), View.OnKeyListener {
|
||||
actionHideTimer = lifecycleScope.launch {
|
||||
delay(AUTO_HIDE_ACTIONS_DURATION)
|
||||
|
||||
// Only auto-hide when there is an active presenation
|
||||
// Only auto-hide when there is an active presentation
|
||||
if (pictureViewerViewModel.presentationActive.value) hideActions()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class PictureViewerViewModel(private val api: ApiClient) : ViewModel() {
|
||||
sortBy = sortBy,
|
||||
sortOrder = listOf(sortOrder),
|
||||
)
|
||||
album = albumResponse.items.orEmpty()
|
||||
album = albumResponse.items
|
||||
albumIndex = album.indexOfFirst { it.id == id }
|
||||
|
||||
// In some rare cases the album of the image might be empty when the
|
||||
@@ -53,6 +53,8 @@ class PictureViewerViewModel(private val api: ApiClient) : ViewModel() {
|
||||
// Album actions
|
||||
|
||||
fun showNext() {
|
||||
if (album.isEmpty()) return
|
||||
|
||||
albumIndex++
|
||||
if (albumIndex == album.size) albumIndex = 0
|
||||
|
||||
@@ -61,6 +63,8 @@ class PictureViewerViewModel(private val api: ApiClient) : ViewModel() {
|
||||
}
|
||||
|
||||
fun showPrevious() {
|
||||
if (album.isEmpty()) return
|
||||
|
||||
albumIndex--
|
||||
if (albumIndex == -1) albumIndex = album.size - 1
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import org.jellyfin.androidtv.util.ImageHelper;
|
||||
import org.jellyfin.androidtv.util.KeyProcessor;
|
||||
import org.jellyfin.androidtv.util.TimeUtils;
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
import org.jellyfin.playback.core.PlaybackManager;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -84,6 +85,7 @@ public class AudioNowPlayingFragment extends Fragment {
|
||||
|
||||
private final Lazy<BackgroundService> backgroundService = inject(BackgroundService.class);
|
||||
private final Lazy<MediaManager> mediaManager = inject(MediaManager.class);
|
||||
private final Lazy<PlaybackManager> playbackManager = inject(PlaybackManager.class);
|
||||
private final Lazy<NavigationRepository> navigationRepository = inject(NavigationRepository.class);
|
||||
private final Lazy<KeyProcessor> keyProcessor = inject(KeyProcessor.class);
|
||||
private final Lazy<ImageHelper> imageHelper = inject(ImageHelper.class);
|
||||
@@ -107,6 +109,7 @@ public class AudioNowPlayingFragment extends Fragment {
|
||||
mCurrentNdx = binding.track;
|
||||
mScrollView = binding.mainScroller;
|
||||
mCounter = binding.counter;
|
||||
AudioNowPlayingFragmentHelperKt.initializeLyricsView(binding.poster, binding.lyrics, playbackManager.getValue());
|
||||
|
||||
mPlayPauseButton = binding.playPauseBtn;
|
||||
mPlayPauseButton.setContentDescription(getString(R.string.lbl_pause));
|
||||
@@ -144,7 +147,7 @@ public class AudioNowPlayingFragment extends Fragment {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
mediaManager.getValue().toggleRepeat();
|
||||
updateButtons(mediaManager.getValue().isPlayingAudio());
|
||||
updateButtons();
|
||||
}
|
||||
});
|
||||
mRepeatButton.setOnFocusChangeListener(mainAreaFocusListener);
|
||||
@@ -155,7 +158,7 @@ public class AudioNowPlayingFragment extends Fragment {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
mediaManager.getValue().shuffleAudioQueue();
|
||||
updateButtons(mediaManager.getValue().isPlayingAudio());
|
||||
updateButtons();
|
||||
}
|
||||
});
|
||||
mShuffleButton.setOnFocusChangeListener(mainAreaFocusListener);
|
||||
@@ -175,7 +178,7 @@ public class AudioNowPlayingFragment extends Fragment {
|
||||
mArtistButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
if (mBaseItem.getAlbumArtists() != null && mBaseItem.getAlbumArtists().size() > 0) {
|
||||
if (mBaseItem.getAlbumArtists() != null && !mBaseItem.getAlbumArtists().isEmpty()) {
|
||||
navigationRepository.getValue().navigate(Destinations.INSTANCE.itemDetails(mBaseItem.getAlbumArtists().get(0).getId()));
|
||||
}
|
||||
}
|
||||
@@ -217,10 +220,10 @@ public class AudioNowPlayingFragment extends Fragment {
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
loadItem();
|
||||
//link events
|
||||
mediaManager.getValue().addAudioEventListener(audioEventListener);
|
||||
updateButtons(mediaManager.getValue().isPlayingAudio());
|
||||
loadItem();
|
||||
updateButtons();
|
||||
|
||||
// load the item duration and set the position to 0 since it won't be set elsewhere until playback is initialized
|
||||
if (!mediaManager.getValue().isAudioPlayerInitialized())
|
||||
@@ -238,13 +241,8 @@ public class AudioNowPlayingFragment extends Fragment {
|
||||
@Override
|
||||
public void onPlaybackStateChange(@NonNull PlaybackController.PlaybackState newState, @Nullable org.jellyfin.sdk.model.api.BaseItemDto currentItem) {
|
||||
Timber.d("**** Got playstate change: %s", newState.toString());
|
||||
if (newState == PlaybackController.PlaybackState.PLAYING && currentItem != mBaseItem) {
|
||||
// new item started
|
||||
loadItem();
|
||||
updateButtons(true);
|
||||
} else {
|
||||
updateButtons(newState == PlaybackController.PlaybackState.PLAYING);
|
||||
}
|
||||
if (currentItem != mBaseItem) loadItem();
|
||||
updateButtons();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -257,11 +255,11 @@ public class AudioNowPlayingFragment extends Fragment {
|
||||
|
||||
@Override
|
||||
public void onQueueStatusChanged(boolean hasQueue) {
|
||||
Timber.d("Queue status changed");
|
||||
Timber.d("Queue status changed (hasQueue=%s)", hasQueue);
|
||||
if (hasQueue) {
|
||||
loadItem();
|
||||
if (mediaManager.getValue().isAudioPlayerInitialized()) {
|
||||
updateButtons(mediaManager.getValue().isPlayingAudio());
|
||||
updateButtons();
|
||||
}
|
||||
} else {
|
||||
if (navigationRepository.getValue().getCanGoBack()) navigationRepository.getValue().goBack();
|
||||
@@ -313,8 +311,9 @@ public class AudioNowPlayingFragment extends Fragment {
|
||||
}
|
||||
}
|
||||
|
||||
private void updateButtons(final boolean playing) {
|
||||
private void updateButtons() {
|
||||
Timber.d("Updating buttons");
|
||||
boolean playing = mediaManager.getValue().isPlayingAudio();
|
||||
requireActivity().runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
@@ -334,14 +333,14 @@ public class AudioNowPlayingFragment extends Fragment {
|
||||
mShuffleButton.setActivated(mediaManager.getValue().isShuffleMode());
|
||||
if (mBaseItem != null) {
|
||||
mAlbumButton.setEnabled(mBaseItem.getAlbumId() != null);
|
||||
mArtistButton.setEnabled(mBaseItem.getAlbumArtists() != null && mBaseItem.getAlbumArtists().size() > 0);
|
||||
mArtistButton.setEnabled(mBaseItem.getAlbumArtists() != null && !mBaseItem.getAlbumArtists().isEmpty());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private String getArtistName(org.jellyfin.sdk.model.api.BaseItemDto item) {
|
||||
String artistName = item.getArtists() != null && item.getArtists().size() > 0 ? item.getArtists().get(0) : item.getAlbumArtist();
|
||||
String artistName = item.getArtists() != null && !item.getArtists().isEmpty() ? item.getArtists().get(0) : item.getAlbumArtist();
|
||||
return artistName != null ? artistName : "";
|
||||
}
|
||||
|
||||
@@ -365,7 +364,7 @@ public class AudioNowPlayingFragment extends Fragment {
|
||||
|
||||
public void setCurrentTime(long time) {
|
||||
// Round the current time as otherwise the time played and time remaining will not be in sync
|
||||
time = Math.round(time / 1000) * 1000;
|
||||
time = Math.round(time / 1000L) * 1000L;
|
||||
mCurrentProgress.setProgress(((Long) time).intValue());
|
||||
mCurrentPos.setText(TimeUtils.formatMillis(time));
|
||||
mRemainingTime.setText(mCurrentDuration > 0 ? "-" + TimeUtils.formatMillis(mCurrentDuration - time) : "");
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.jellyfin.androidtv.ui.playback
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.jellyfin.androidtv.ui.AsyncImageView
|
||||
import org.jellyfin.androidtv.ui.composable.LyricsDtoBox
|
||||
import org.jellyfin.androidtv.ui.composable.modifier.fadingEdges
|
||||
import org.jellyfin.androidtv.ui.composable.rememberPlayerProgress
|
||||
import org.jellyfin.androidtv.ui.composable.rememberQueueEntry
|
||||
import org.jellyfin.playback.core.PlaybackManager
|
||||
import org.jellyfin.playback.core.model.PlayState
|
||||
import org.jellyfin.playback.jellyfin.lyrics
|
||||
import org.jellyfin.playback.jellyfin.lyricsFlow
|
||||
|
||||
fun initializeLyricsView(
|
||||
coverView: AsyncImageView,
|
||||
lyricsView: ComposeView,
|
||||
playbackManager: PlaybackManager,
|
||||
) {
|
||||
lyricsView.setContent {
|
||||
val entry by rememberQueueEntry(playbackManager)
|
||||
val lyrics = entry?.run { lyricsFlow.collectAsState(lyrics) }?.value
|
||||
|
||||
// Animate cover view alpha
|
||||
val coverViewAlpha by animateFloatAsState(
|
||||
label = "CoverViewAlpha",
|
||||
targetValue = if (lyrics == null) 1f else 0.2f,
|
||||
)
|
||||
LaunchedEffect(coverViewAlpha) { coverView.alpha = coverViewAlpha }
|
||||
|
||||
// Display lyrics overlay
|
||||
if (lyrics != null) {
|
||||
val playState by remember { playbackManager.state.playState }.collectAsState()
|
||||
|
||||
// Using the progress animation causes the layout to recompose, which we need for synced lyrics to work
|
||||
// we don't actually use the animation value here
|
||||
rememberPlayerProgress(playbackManager)
|
||||
|
||||
LyricsDtoBox(
|
||||
lyricDto = lyrics,
|
||||
currentTimestamp = playbackManager.state.positionInfo.active,
|
||||
duration = playbackManager.state.positionInfo.duration,
|
||||
paused = playState != PlayState.PLAYING,
|
||||
fontSize = 12.sp,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.fadingEdges(vertical = 50.dp)
|
||||
.padding(horizontal = 15.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,14 @@ import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.media.AudioManager;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.view.Gravity;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
import android.view.animation.Animation;
|
||||
import android.view.animation.AnimationUtils;
|
||||
import android.widget.LinearLayout;
|
||||
@@ -25,7 +24,6 @@ import android.widget.TextView;
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
@@ -45,7 +43,6 @@ import org.jellyfin.androidtv.data.repository.CustomMessageRepository;
|
||||
import org.jellyfin.androidtv.data.service.BackgroundService;
|
||||
import org.jellyfin.androidtv.databinding.OverlayTvGuideBinding;
|
||||
import org.jellyfin.androidtv.databinding.VlcPlayerInterfaceBinding;
|
||||
import org.jellyfin.androidtv.preference.UserPreferences;
|
||||
import org.jellyfin.androidtv.ui.GuideChannelHeader;
|
||||
import org.jellyfin.androidtv.ui.GuidePagingButton;
|
||||
import org.jellyfin.androidtv.ui.HorizontalScrollViewListener;
|
||||
@@ -67,7 +64,6 @@ import org.jellyfin.androidtv.ui.presentation.CardPresenter;
|
||||
import org.jellyfin.androidtv.ui.presentation.ChannelCardPresenter;
|
||||
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter;
|
||||
import org.jellyfin.androidtv.ui.presentation.PositionableListRowPresenter;
|
||||
import org.jellyfin.androidtv.ui.shared.PaddedLineBackgroundSpan;
|
||||
import org.jellyfin.androidtv.util.CoroutineUtils;
|
||||
import org.jellyfin.androidtv.util.DateTimeExtensionsKt;
|
||||
import org.jellyfin.androidtv.util.ImageHelper;
|
||||
@@ -77,12 +73,9 @@ 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.BaseItemExtensionsKt;
|
||||
import org.jellyfin.apiclient.model.mediainfo.SubtitleTrackEvent;
|
||||
import org.jellyfin.apiclient.model.mediainfo.SubtitleTrackInfo;
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto;
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind;
|
||||
import org.jellyfin.sdk.model.api.ChapterInfo;
|
||||
import org.koin.java.KoinJavaComponent;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
@@ -93,7 +86,7 @@ import kotlin.Lazy;
|
||||
import timber.log.Timber;
|
||||
|
||||
public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGuide, View.OnKeyListener {
|
||||
private VlcPlayerInterfaceBinding binding;
|
||||
protected VlcPlayerInterfaceBinding binding;
|
||||
private OverlayTvGuideBinding tvGuideBinding;
|
||||
|
||||
private RowsSupportFragment mPopupRowsFragment;
|
||||
@@ -130,20 +123,9 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
private boolean mFadeEnabled = false;
|
||||
private boolean mIsVisible = false;
|
||||
private boolean mPopupPanelVisible = false;
|
||||
private boolean navigating = false;
|
||||
|
||||
private LeanbackOverlayFragment leanbackOverlayFragment;
|
||||
|
||||
// Subtitle fields
|
||||
private static final int SUBTITLE_PADDING = 20;
|
||||
private static final long SUBTITLE_RENDER_INTERVAL_MS = 50;
|
||||
private SubtitleTrackInfo subtitleTrackInfo;
|
||||
private int currentSubtitleIndex = 0;
|
||||
private long lastSubtitlePositionMs = 0;
|
||||
private final UserPreferences userPreferences = KoinJavaComponent.<UserPreferences>get(UserPreferences.class);
|
||||
private final int subtitlesSize = userPreferences.get(UserPreferences.Companion.getSubtitlesSize());
|
||||
private final boolean subtitlesBackgroundEnabled = userPreferences.get(UserPreferences.Companion.getSubtitlesBackgroundEnabled());
|
||||
private final int subtitlesPosition = userPreferences.get(UserPreferences.Companion.getSubtitlePosition());
|
||||
private final int subtitlesStrokeWidth = userPreferences.get(UserPreferences.Companion.getSubtitleStrokeSize());
|
||||
protected LeanbackOverlayFragment leanbackOverlayFragment;
|
||||
|
||||
private final Lazy<org.jellyfin.sdk.api.client.ApiClient> api = inject(org.jellyfin.sdk.api.client.ApiClient.class);
|
||||
private final Lazy<MediaManager> mediaManager = inject(MediaManager.class);
|
||||
@@ -173,11 +155,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
requireActivity().setVolumeControlStream(AudioManager.STREAM_MUSIC);
|
||||
|
||||
mItemsToPlay = videoQueueManager.getValue().getCurrentVideoQueue();
|
||||
if (mItemsToPlay == null || mItemsToPlay.size() == 0) {
|
||||
Utils.showToast(requireContext(), getString(R.string.msg_no_playable_items));
|
||||
closePlayer();
|
||||
return;
|
||||
}
|
||||
if (mItemsToPlay == null || mItemsToPlay.isEmpty()) return;
|
||||
|
||||
int mediaPosition = videoQueueManager.getValue().getCurrentMediaPosition();
|
||||
|
||||
@@ -186,7 +164,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
// setup fade task
|
||||
mHideTask = () -> {
|
||||
if (mIsVisible) {
|
||||
hide();
|
||||
leanbackOverlayFragment.hideOverlay();
|
||||
}
|
||||
};
|
||||
@@ -221,9 +198,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
tvGuideBinding.getRoot().setVisibility(View.GONE);
|
||||
|
||||
binding.getRoot().setOnTouchListener((v, event) -> {
|
||||
//if we're not visible, show us
|
||||
if (!mIsVisible) show();
|
||||
|
||||
//and then manage our fade timer
|
||||
if (mFadeEnabled) startFadeTimer();
|
||||
|
||||
@@ -238,6 +212,12 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
if (mItemsToPlay == null || mItemsToPlay.isEmpty()) {
|
||||
Utils.showToast(requireContext(), getString(R.string.msg_no_playable_items));
|
||||
closePlayer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (playbackControllerContainer.getValue().getPlaybackController() != null) {
|
||||
playbackControllerContainer.getValue().getPlaybackController().init(new VideoManager((requireActivity()), view, helper), this);
|
||||
}
|
||||
@@ -255,30 +235,10 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
@Override
|
||||
public void onActivityCreated(Bundle savedInstanceState) {
|
||||
super.onActivityCreated(savedInstanceState);
|
||||
if (mItemsToPlay == null || mItemsToPlay.size() == 0) return;
|
||||
if (mItemsToPlay == null || mItemsToPlay.isEmpty()) return;
|
||||
|
||||
prepareOverlayFragment();
|
||||
|
||||
//manual subtitles
|
||||
// This configuration is required for the PaddedLineBackgroundSpan to work
|
||||
binding.subtitlesText.setShadowLayer(SUBTITLE_PADDING, 0, 0, Color.TRANSPARENT);
|
||||
binding.subtitlesText.setPadding(SUBTITLE_PADDING, 0, SUBTITLE_PADDING, 0);
|
||||
|
||||
// Subtitles font size configuration
|
||||
binding.subtitlesText.setTextSize(subtitlesSize);
|
||||
|
||||
// Subtitles font position (margin bottom)
|
||||
if (subtitlesPosition > 0) {
|
||||
ViewGroup.MarginLayoutParams currentLayoutParams = (ViewGroup.MarginLayoutParams) binding.subtitlesText.getLayoutParams();
|
||||
currentLayoutParams.bottomMargin = (8 + Utils.convertDpToPixel(requireContext(), subtitlesPosition));
|
||||
binding.subtitlesText.setLayoutParams(currentLayoutParams);
|
||||
}
|
||||
|
||||
// Subtitles stroke width
|
||||
if (subtitlesStrokeWidth > 0 && !subtitlesBackgroundEnabled) {
|
||||
binding.subtitlesText.setStrokeWidth(subtitlesStrokeWidth);
|
||||
}
|
||||
|
||||
//pre-load animations
|
||||
fadeOut = AnimationUtils.loadAnimation(requireContext(), androidx.leanback.R.anim.abc_fade_out);
|
||||
fadeOut.setAnimationListener(hideAnimationListener);
|
||||
@@ -331,14 +291,21 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
return null;
|
||||
});
|
||||
|
||||
requireActivity().getOnBackPressedDispatcher().addCallback(backPressedCallback);
|
||||
|
||||
int startPos = getArguments().getInt("Position", 0);
|
||||
|
||||
// start playing
|
||||
playbackControllerContainer.getValue().getPlaybackController().play(startPos);
|
||||
leanbackOverlayFragment.updatePlayState();
|
||||
|
||||
// Set initial skip overlay state
|
||||
binding.skipOverlay.setSkipUiEnabled(!mIsVisible && !mGuideVisible && !mPopupPanelVisible);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(@NonNull Context context) {
|
||||
super.onAttach(context);
|
||||
|
||||
requireActivity().getOnBackPressedDispatcher().addCallback(this, backPressedCallback);
|
||||
}
|
||||
|
||||
private void prepareOverlayFragment() {
|
||||
@@ -418,7 +385,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
};
|
||||
|
||||
private OnBackPressedCallback backPressedCallback = new OnBackPressedCallback(true) {
|
||||
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
if (mPopupPanelVisible) {
|
||||
@@ -510,6 +476,22 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
leanbackOverlayFragment.hideOverlay();
|
||||
}
|
||||
|
||||
if (binding.skipOverlay.getVisible()) {
|
||||
// Hide without doing anything
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_BUTTON_B || keyCode == KeyEvent.KEYCODE_ESCAPE) {
|
||||
binding.skipOverlay.setTargetPositionMs(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Hide with seek
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||
playbackControllerContainer.getValue().getPlaybackController().seek(binding.skipOverlay.getTargetPositionMs(), true);
|
||||
leanbackOverlayFragment.setShouldShowOverlay(false);
|
||||
if (binding != null) binding.skipOverlay.setTargetPositionMs(null);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (keyCode == KeyEvent.KEYCODE_MEDIA_STOP) {
|
||||
closePlayer();
|
||||
return true;
|
||||
@@ -575,7 +557,8 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
// up or down should close panel
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN || keyCode == KeyEvent.KEYCODE_DPAD_UP) {
|
||||
hidePopupPanel();
|
||||
if (playbackControllerContainer.getValue().getPlaybackController().isLiveTv()) hide(); //also close this if live tv
|
||||
if (playbackControllerContainer.getValue().getPlaybackController().isLiveTv())
|
||||
hide(); //also close this if live tv
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
@@ -617,9 +600,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
playbackControllerContainer.getValue().getPlaybackController().playPause();
|
||||
return true;
|
||||
}
|
||||
|
||||
//if we're not visible, show us
|
||||
show();
|
||||
}
|
||||
|
||||
//and then manage our fade timer
|
||||
@@ -673,13 +653,10 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
|
||||
// Close player when resuming without a valid playback contoller
|
||||
if (!playbackControllerContainer.getValue().getPlaybackController().hasFragment()) {
|
||||
if (navigationRepository.getValue().getCanGoBack()) {
|
||||
navigationRepository.getValue().goBack();
|
||||
} else {
|
||||
navigationRepository.getValue().reset(Destinations.INSTANCE.getHome());
|
||||
}
|
||||
// Close player when resuming without a valid playback controller
|
||||
PlaybackController playbackController = playbackControllerContainer.getValue().getPlaybackController();
|
||||
if (playbackController == null || !playbackController.hasFragment()) {
|
||||
closePlayer();
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -698,6 +675,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
if (mItemsToPlay == null || mItemsToPlay.isEmpty()) return;
|
||||
|
||||
setPlayPauseActionState(0);
|
||||
|
||||
@@ -712,10 +690,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
|
||||
if (leanbackOverlayFragment != null)
|
||||
leanbackOverlayFragment.setOnKeyInterceptListener(null);
|
||||
if (backPressedCallback != null) {
|
||||
backPressedCallback.remove();
|
||||
backPressedCallback = null;
|
||||
}
|
||||
|
||||
// end playback from here if this fragment belongs to the current session.
|
||||
// if it doesn't, playback has already been stopped elsewhere, and the references to this have been replaced
|
||||
@@ -723,27 +697,39 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
Timber.d("this fragment belongs to the current session, ending it");
|
||||
playbackControllerContainer.getValue().getPlaybackController().endPlayback();
|
||||
}
|
||||
|
||||
closePlayer();
|
||||
}
|
||||
|
||||
public void show() {
|
||||
// Already showing!
|
||||
if (mIsVisible) return;
|
||||
|
||||
binding.topPanel.startAnimation(slideDown);
|
||||
mIsVisible = true;
|
||||
binding.skipOverlay.setSkipUiEnabled(!mIsVisible && !mGuideVisible && !mPopupPanelVisible);
|
||||
}
|
||||
|
||||
public void hide() {
|
||||
// Can't hide what's already hidden
|
||||
if (!mIsVisible) return;
|
||||
|
||||
mIsVisible = false;
|
||||
binding.topPanel.startAnimation(fadeOut);
|
||||
binding.skipOverlay.setSkipUiEnabled(!mIsVisible && !mGuideVisible && !mPopupPanelVisible);
|
||||
}
|
||||
|
||||
private void showChapterPanel() {
|
||||
setFadingEnabled(false);
|
||||
binding.popupArea.startAnimation(showPopup);
|
||||
binding.skipOverlay.setSkipUiEnabled(!mIsVisible && !mGuideVisible && !mPopupPanelVisible);
|
||||
}
|
||||
|
||||
private void hidePopupPanel() {
|
||||
startFadeTimer();
|
||||
binding.popupArea.startAnimation(hidePopup);
|
||||
mPopupPanelVisible = false;
|
||||
binding.skipOverlay.setSkipUiEnabled(!mIsVisible && !mGuideVisible && !mPopupPanelVisible);
|
||||
}
|
||||
|
||||
public void showGuide() {
|
||||
@@ -764,12 +750,14 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
if (needLoad) {
|
||||
loadGuide();
|
||||
}
|
||||
binding.skipOverlay.setSkipUiEnabled(!mIsVisible && !mGuideVisible && !mPopupPanelVisible);
|
||||
}
|
||||
|
||||
private void hideGuide() {
|
||||
tvGuideBinding.getRoot().setVisibility(View.GONE);
|
||||
playbackControllerContainer.getValue().getPlaybackController().mVideoManager.setVideoFullSize(true);
|
||||
mGuideVisible = false;
|
||||
binding.skipOverlay.setSkipUiEnabled(!mIsVisible && !mGuideVisible && !mPopupPanelVisible);
|
||||
}
|
||||
|
||||
private void loadGuide() {
|
||||
@@ -819,6 +807,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
mDisplayProgramsTask.execute(mCurrentDisplayChannelStartNdx, mCurrentDisplayChannelEndNdx);
|
||||
}
|
||||
});
|
||||
binding.skipOverlay.setSkipUiEnabled(!mIsVisible && !mGuideVisible && !mPopupPanelVisible);
|
||||
}
|
||||
|
||||
DisplayProgramsTask mDisplayProgramsTask;
|
||||
@@ -883,8 +872,8 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
// set focus parameters if we are not on first row
|
||||
// this makes focus movements more predictable for the grid view
|
||||
if (prevRow != null) {
|
||||
TvManager.setFocusParms(row, prevRow, true);
|
||||
TvManager.setFocusParms(prevRow, row, false);
|
||||
TvManager.setFocusParams(row, prevRow, true);
|
||||
TvManager.setFocusParams(prevRow, row, false);
|
||||
}
|
||||
prevRow = row;
|
||||
|
||||
@@ -1216,6 +1205,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
}
|
||||
|
||||
public void setCurrentTime(long time) {
|
||||
binding.skipOverlay.setCurrentPositionMs(time);
|
||||
if (leanbackOverlayFragment != null)
|
||||
leanbackOverlayFragment.updateCurrentPosition();
|
||||
}
|
||||
@@ -1225,7 +1215,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
|
||||
public void setFadingEnabled(boolean value) {
|
||||
mFadeEnabled = value;
|
||||
if (!mIsVisible) requireActivity().runOnUiThread(this::show);
|
||||
if (mFadeEnabled) {
|
||||
startFadeTimer();
|
||||
} else {
|
||||
@@ -1302,6 +1291,9 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
}
|
||||
|
||||
public void closePlayer() {
|
||||
if (navigating) return;
|
||||
navigating = true;
|
||||
|
||||
if (navigationRepository.getValue().getCanGoBack()) {
|
||||
navigationRepository.getValue().goBack();
|
||||
} else {
|
||||
@@ -1310,140 +1302,12 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
}
|
||||
|
||||
public void showNextUp(@NonNull UUID id) {
|
||||
if (navigating) return;
|
||||
navigating = true;
|
||||
|
||||
navigationRepository.getValue().navigate(Destinations.INSTANCE.nextUp(id), true);
|
||||
}
|
||||
|
||||
public void addManualSubtitles(@Nullable SubtitleTrackInfo info) {
|
||||
subtitleTrackInfo = info;
|
||||
currentSubtitleIndex = -1;
|
||||
lastSubtitlePositionMs = 0;
|
||||
clearSubtitles();
|
||||
}
|
||||
|
||||
public void showSubLoadingMsg(final boolean show) {
|
||||
if (show) {
|
||||
renderSubtitles(requireContext().getString(R.string.msg_subtitles_loading));
|
||||
} else {
|
||||
clearSubtitles();
|
||||
}
|
||||
}
|
||||
|
||||
public void updateSubtitles(long positionMs) {
|
||||
int iterCount = 1;
|
||||
final long positionTicks = positionMs * 10000;
|
||||
final long lastPositionTicks = lastSubtitlePositionMs * 10000;
|
||||
|
||||
if (subtitleTrackInfo == null
|
||||
|| subtitleTrackInfo.getTrackEvents() == null
|
||||
|| subtitleTrackInfo.getTrackEvents().size() < 1
|
||||
|| currentSubtitleIndex >= subtitleTrackInfo.getTrackEvents().size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (positionTicks < subtitleTrackInfo.getTrackEvents().get(0).getStartPositionTicks())
|
||||
return;
|
||||
|
||||
// Skip rendering if the interval ms have not passed since last render
|
||||
if (lastSubtitlePositionMs > 0
|
||||
&& Math.abs(lastSubtitlePositionMs - positionMs) < SUBTITLE_RENDER_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the user has skipped back, reset the subtitle index
|
||||
if (lastSubtitlePositionMs > positionMs) {
|
||||
currentSubtitleIndex = -1;
|
||||
clearSubtitles();
|
||||
}
|
||||
|
||||
if (currentSubtitleIndex == -1)
|
||||
Timber.d("subtitle track events size %s", subtitleTrackInfo.getTrackEvents().size());
|
||||
|
||||
// Find the next subtitle event that should be rendered
|
||||
for (int tmpSubtitleIndex = currentSubtitleIndex == -1 ? 0 : currentSubtitleIndex; tmpSubtitleIndex < subtitleTrackInfo.getTrackEvents().size(); tmpSubtitleIndex++) {
|
||||
SubtitleTrackEvent trackEvent = subtitleTrackInfo.getTrackEvents().get(tmpSubtitleIndex);
|
||||
|
||||
if (positionTicks >= trackEvent.getStartPositionTicks()
|
||||
&& positionTicks < trackEvent.getEndPositionTicks()) {
|
||||
// This subtitle event should be displayed now
|
||||
// use lastPositionTicks to ensure it is only rendered once
|
||||
|
||||
if (lastPositionTicks < trackEvent.getStartPositionTicks() || lastPositionTicks >= trackEvent.getEndPositionTicks()) {
|
||||
Timber.d("rendering subtitle event: %s (pos %s start %s end %s)", tmpSubtitleIndex, positionMs, trackEvent.getStartPositionTicks() / 10000, trackEvent.getEndPositionTicks() / 10000);
|
||||
renderSubtitles(trackEvent.getText());
|
||||
}
|
||||
|
||||
currentSubtitleIndex = tmpSubtitleIndex;
|
||||
lastSubtitlePositionMs = positionMs;
|
||||
// rendering should happen on the 2nd iteration
|
||||
if (iterCount > 2)
|
||||
Timber.d("subtitles handled in %s iterations", iterCount);
|
||||
return;
|
||||
} else if (tmpSubtitleIndex < subtitleTrackInfo.getTrackEvents().size() - 1) {
|
||||
SubtitleTrackEvent nextTrackEvent = subtitleTrackInfo.getTrackEvents().get(tmpSubtitleIndex + 1);
|
||||
|
||||
if (positionTicks >= trackEvent.getEndPositionTicks() && positionTicks < nextTrackEvent.getStartPositionTicks()) {
|
||||
// clear the subtitles if between events
|
||||
// use lastPositionTicks to ensure it is only cleared once
|
||||
|
||||
if (currentSubtitleIndex > -1 && !(lastPositionTicks >= trackEvent.getEndPositionTicks() && lastPositionTicks < nextTrackEvent.getStartPositionTicks())) {
|
||||
Timber.d("clearing subtitle event: %s (pos %s - event end %s)", tmpSubtitleIndex, positionMs, trackEvent.getEndPositionTicks() / 10000);
|
||||
clearSubtitles();
|
||||
}
|
||||
|
||||
// set currentSubtitleIndex in case it was -1
|
||||
currentSubtitleIndex = tmpSubtitleIndex;
|
||||
lastSubtitlePositionMs = positionMs;
|
||||
if (iterCount > 1)
|
||||
Timber.d("subtitles handled in %s iterations", iterCount);
|
||||
return;
|
||||
}
|
||||
}
|
||||
iterCount++;
|
||||
}
|
||||
// handles clearing the last event
|
||||
if (iterCount > 1)
|
||||
Timber.d("subtitles handled in %s iterations", iterCount);
|
||||
clearSubtitles();
|
||||
}
|
||||
|
||||
private void clearSubtitles() {
|
||||
requireActivity().runOnUiThread(() -> {
|
||||
binding.subtitlesText.setVisibility(View.INVISIBLE);
|
||||
binding.subtitlesText.setText(null);
|
||||
});
|
||||
}
|
||||
|
||||
private void renderSubtitles(@Nullable final String text) {
|
||||
if (text == null || text.length() == 0) {
|
||||
clearSubtitles();
|
||||
return;
|
||||
}
|
||||
requireActivity().runOnUiThread(() -> {
|
||||
final String htmlText = text
|
||||
// Encode whitespace as html entities
|
||||
.replaceAll("\\r\\n", "<br>")
|
||||
.replaceAll("\\n", "<br>")
|
||||
.replaceAll("\\\\h", " ")
|
||||
// Remove SSA tags
|
||||
.replaceAll("\\{\\\\.*?\\}", "");
|
||||
|
||||
final SpannableString span = new SpannableString(TextUtilsKt.toHtmlSpanned(htmlText));
|
||||
if (subtitlesBackgroundEnabled) {
|
||||
// Disable the text outlining when the background is enabled
|
||||
binding.subtitlesText.setStrokeWidth(0.0f);
|
||||
|
||||
// get the alignment gravity of the TextView
|
||||
// extract the absolute horizontal gravity so the span can draw its background aligned
|
||||
int gravity = binding.subtitlesText.getGravity();
|
||||
int horizontalGravity = Gravity.getAbsoluteGravity(gravity, binding.subtitlesText.getLayoutDirection()) & Gravity.HORIZONTAL_GRAVITY_MASK;
|
||||
span.setSpan(new PaddedLineBackgroundSpan(ContextCompat.getColor(requireContext(), R.color.black_opaque), SUBTITLE_PADDING, horizontalGravity), 0, span.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
|
||||
binding.subtitlesText.setText(span);
|
||||
binding.subtitlesText.setVisibility(View.VISIBLE);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
@@ -1451,5 +1315,12 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
// Show system bars
|
||||
WindowCompat.setDecorFitsSystemWindows(requireActivity().getWindow(), true);
|
||||
WindowCompat.getInsetsController(requireActivity().getWindow(), requireActivity().getWindow().getDecorView()).show(WindowInsetsCompat.Type.systemBars());
|
||||
|
||||
// Reset display mode
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
WindowManager.LayoutParams params = getActivity().getWindow().getAttributes();
|
||||
params.preferredDisplayModeId = 0;
|
||||
getActivity().getWindow().setAttributes(params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.koin.android.ext.android.inject
|
||||
import timber.log.Timber
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
|
||||
fun CustomPlaybackOverlayFragment.toggleFavorite() {
|
||||
val header = mSelectedProgramView as? GuideChannelHeader
|
||||
@@ -174,3 +175,7 @@ fun CustomPlaybackOverlayFragment.recordProgram(program: BaseItemDto, isSeries:
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun CustomPlaybackOverlayFragment.askToSkip(position: Duration) {
|
||||
binding.skipOverlay.targetPosition = position
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ public class ExternalPlayerActivity extends FragmentActivity {
|
||||
if (data.hasExtra(API_MX_RESULT_POSITION)) {
|
||||
pos = data.getIntExtra(API_MX_RESULT_POSITION, 0);
|
||||
} else if (data.hasExtra(API_VLC_RESULT_POSITION)) {
|
||||
pos = data.getIntExtra(API_VLC_RESULT_POSITION, 0);
|
||||
pos = (int) data.getLongExtra(API_VLC_RESULT_POSITION, 0);
|
||||
} else if (data.hasExtra(API_VIMU_RESULT_POSITION)) {
|
||||
pos = data.getIntExtra(API_VIMU_RESULT_POSITION, 0);
|
||||
}
|
||||
@@ -152,7 +152,7 @@ public class ExternalPlayerActivity extends FragmentActivity {
|
||||
Long reportPos = (long) pos * RUNTIME_TICKS_TO_MS;
|
||||
|
||||
stopReportLoop();
|
||||
reportingHelper.getValue().reportStopped(item, mCurrentStreamInfo, reportPos);
|
||||
reportingHelper.getValue().reportStopped(this, item, mCurrentStreamInfo, reportPos);
|
||||
|
||||
//Check against a total failure (no apps installed)
|
||||
if (playerFinishedTime - mLastPlayerStart < 1000) {
|
||||
@@ -232,13 +232,13 @@ public class ExternalPlayerActivity extends FragmentActivity {
|
||||
|
||||
private void startReportLoop() {
|
||||
PlaybackController playbackController = playbackControllerContainer.getValue().getPlaybackController();
|
||||
reportingHelper.getValue().reportProgress(playbackController, mItemsToPlay.get(mCurrentNdx), mCurrentStreamInfo, null, false);
|
||||
reportingHelper.getValue().reportProgress(this, playbackController, mItemsToPlay.get(mCurrentNdx), mCurrentStreamInfo, 0, false);
|
||||
mReportLoop = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (!getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) return;
|
||||
|
||||
reportingHelper.getValue().reportProgress(playbackController, mItemsToPlay.get(mCurrentNdx), mCurrentStreamInfo, mPosition * RUNTIME_TICKS_TO_MS, false);
|
||||
reportingHelper.getValue().reportProgress(ExternalPlayerActivity.this, playbackController, mItemsToPlay.get(mCurrentNdx), mCurrentStreamInfo, mPosition * RUNTIME_TICKS_TO_MS, false);
|
||||
mHandler.postDelayed(this, 15000);
|
||||
}
|
||||
};
|
||||
@@ -374,7 +374,7 @@ public class ExternalPlayerActivity extends FragmentActivity {
|
||||
|
||||
try {
|
||||
mLastPlayerStart = Instant.now().toEpochMilli();
|
||||
reportingHelper.getValue().reportStart(item, mPosition * RUNTIME_TICKS_TO_MS);
|
||||
reportingHelper.getValue().reportStart(this, playbackControllerContainer.getValue().getPlaybackController(), item, mCurrentStreamInfo, mPosition * RUNTIME_TICKS_TO_MS, false);
|
||||
startReportLoop();
|
||||
startActivityForResult(external, 1);
|
||||
} catch (ActivityNotFoundException e) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import org.jellyfin.androidtv.util.sdk.compat.ModelCompat;
|
||||
import org.jellyfin.apiclient.interaction.ApiClient;
|
||||
import org.jellyfin.apiclient.interaction.QueryStringDictionary;
|
||||
import org.jellyfin.apiclient.interaction.Response;
|
||||
import org.jellyfin.apiclient.model.dlna.PlaybackErrorCode;
|
||||
import org.jellyfin.apiclient.model.dto.MediaSourceInfo;
|
||||
import org.jellyfin.apiclient.model.mediainfo.LiveStreamRequest;
|
||||
import org.jellyfin.apiclient.model.mediainfo.LiveStreamResponse;
|
||||
@@ -26,9 +25,8 @@ public class GetPlaybackInfoResponse extends Response<PlaybackInfoResponse> {
|
||||
private AudioOptions options;
|
||||
private Response<StreamInfo> response;
|
||||
private boolean isVideo;
|
||||
private Long startPositionTicks;
|
||||
|
||||
public GetPlaybackInfoResponse(PlaybackManager playbackManager, DeviceInfo deviceInfo, ApiClient apiClient, AudioOptions options, Response<StreamInfo> response, boolean isVideo, Long startPositionTicks) {
|
||||
public GetPlaybackInfoResponse(PlaybackManager playbackManager, DeviceInfo deviceInfo, ApiClient apiClient, AudioOptions options, Response<StreamInfo> response, boolean isVideo) {
|
||||
super(response);
|
||||
this.playbackManager = playbackManager;
|
||||
this.deviceInfo = deviceInfo;
|
||||
@@ -36,7 +34,6 @@ public class GetPlaybackInfoResponse extends Response<PlaybackInfoResponse> {
|
||||
this.options = options;
|
||||
this.response = response;
|
||||
this.isVideo = isVideo;
|
||||
this.startPositionTicks = startPositionTicks;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -96,7 +93,6 @@ public class GetPlaybackInfoResponse extends Response<PlaybackInfoResponse> {
|
||||
streamInfo.setItemId(options.getItemId());
|
||||
streamInfo.setDeviceProfile(options.getProfile());
|
||||
streamInfo.setPlaySessionId(playbackInfo.getPlaySessionId());
|
||||
streamInfo.setStartPositionTicks(startPositionTicks);
|
||||
|
||||
if (options.getEnableDirectPlay() && mediaSourceInfo.getSupportsDirectPlay()){
|
||||
if (canDirectPlay(mediaSourceInfo)) {
|
||||
@@ -169,14 +165,6 @@ public class GetPlaybackInfoResponse extends Response<PlaybackInfoResponse> {
|
||||
streamInfo.setMediaUrl(apiClient.GetApiUrl(mediaSourceInfo.getTranscodingUrl()));
|
||||
}
|
||||
|
||||
// A null url will crash the app, make sure to call onError instead
|
||||
if (streamInfo.getMediaUrl() == null) {
|
||||
PlaybackException exception = new PlaybackException();
|
||||
exception.setErrorCode(PlaybackErrorCode.NoCompatibleStream);
|
||||
response.onError(exception);
|
||||
return;
|
||||
}
|
||||
|
||||
playbackManager.SendResponse(response, streamInfo);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,26 +15,23 @@ import androidx.annotation.Nullable;
|
||||
import org.jellyfin.androidtv.R;
|
||||
import org.jellyfin.androidtv.data.compat.PlaybackException;
|
||||
import org.jellyfin.androidtv.data.compat.StreamInfo;
|
||||
import org.jellyfin.androidtv.data.compat.SubtitleStreamInfo;
|
||||
import org.jellyfin.androidtv.data.compat.VideoOptions;
|
||||
import org.jellyfin.androidtv.data.model.DataRefreshService;
|
||||
import org.jellyfin.androidtv.preference.UserPreferences;
|
||||
import org.jellyfin.androidtv.preference.UserSettingPreferences;
|
||||
import org.jellyfin.androidtv.preference.constant.AudioBehavior;
|
||||
import org.jellyfin.androidtv.preference.constant.NextUpBehavior;
|
||||
import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior;
|
||||
import org.jellyfin.androidtv.preference.constant.ZoomMode;
|
||||
import org.jellyfin.androidtv.ui.livetv.TvManager;
|
||||
import org.jellyfin.androidtv.util.TimeUtils;
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
import org.jellyfin.androidtv.util.apiclient.ReportingHelper;
|
||||
import org.jellyfin.androidtv.util.apiclient.StreamHelper;
|
||||
import org.jellyfin.androidtv.util.profile.ExoPlayerProfile;
|
||||
import org.jellyfin.androidtv.util.sdk.ModelUtils;
|
||||
import org.jellyfin.androidtv.util.sdk.compat.JavaCompat;
|
||||
import org.jellyfin.apiclient.interaction.ApiClient;
|
||||
import org.jellyfin.apiclient.interaction.Response;
|
||||
import org.jellyfin.apiclient.model.dlna.DeviceProfile;
|
||||
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod;
|
||||
import org.jellyfin.apiclient.model.mediainfo.SubtitleTrackInfo;
|
||||
import org.jellyfin.apiclient.model.session.PlayMethod;
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto;
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind;
|
||||
@@ -42,6 +39,7 @@ import org.jellyfin.sdk.model.api.LocationType;
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo;
|
||||
import org.jellyfin.sdk.model.api.MediaStream;
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType;
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod;
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializerKt;
|
||||
import org.koin.java.KoinJavaComponent;
|
||||
|
||||
@@ -70,20 +68,18 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
List<BaseItemDto> mItems;
|
||||
VideoManager mVideoManager;
|
||||
int mCurrentIndex;
|
||||
private long mCurrentPosition = 0;
|
||||
protected long mCurrentPosition = 0;
|
||||
private PlaybackState mPlaybackState = PlaybackState.IDLE;
|
||||
|
||||
private StreamInfo mCurrentStreamInfo;
|
||||
private List<SubtitleStreamInfo> mSubtitleStreams;
|
||||
|
||||
@Nullable
|
||||
private CustomPlaybackOverlayFragment mFragment;
|
||||
private Boolean spinnerOff = false;
|
||||
|
||||
private VideoOptions mCurrentOptions;
|
||||
private int mDefaultSubIndex = -1;
|
||||
protected VideoOptions mCurrentOptions;
|
||||
private int mDefaultAudioIndex = -1;
|
||||
private boolean burningSubs = false;
|
||||
protected boolean burningSubs = false;
|
||||
private float mRequestedPlaybackSpeed = -1.0f;
|
||||
|
||||
private Runnable mReportLoop;
|
||||
@@ -101,7 +97,6 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
private long mCurrentTranscodeStartTime;
|
||||
private boolean isLiveTv = false;
|
||||
private boolean directStreamLiveTv;
|
||||
private boolean exoErrorEncountered;
|
||||
private int playbackRetries = 0;
|
||||
private long lastPlaybackError = 0;
|
||||
|
||||
@@ -139,6 +134,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
public void init(@NonNull VideoManager mgr, @NonNull CustomPlaybackOverlayFragment fragment) {
|
||||
mVideoManager = mgr;
|
||||
mVideoManager.subscribe(this);
|
||||
mVideoManager.setZoom(userPreferences.getValue().get(UserPreferences.Companion.getPlayerZoomMode()));
|
||||
mFragment = fragment;
|
||||
directStreamLiveTv = userPreferences.getValue().get(UserPreferences.Companion.getLiveTvDirectPlayEnabled());
|
||||
}
|
||||
@@ -211,18 +207,6 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
return (mCurrentOptions != null && mCurrentOptions.getSubtitleStreamIndex() != null) ? mCurrentOptions.getSubtitleStreamIndex() : -1;
|
||||
}
|
||||
|
||||
public List<SubtitleStreamInfo> getSubtitleStreams() {
|
||||
return mSubtitleStreams;
|
||||
}
|
||||
|
||||
public SubtitleStreamInfo getSubtitleStreamInfo(int index) {
|
||||
for (SubtitleStreamInfo info : mSubtitleStreams) {
|
||||
if (info.getIndex() == index) return info;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean isTranscoding() {
|
||||
// use or here so that true is the default since
|
||||
// this method is used to exclude features that may break unless we are sure playback is direct
|
||||
@@ -247,8 +231,6 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
}
|
||||
|
||||
public void playerErrorEncountered() {
|
||||
exoErrorEncountered = true;
|
||||
|
||||
// reset the retry count if it's been more than 30s since previous error
|
||||
if (playbackRetries > 0 && Instant.now().toEpochMilli() - lastPlaybackError > 30000) {
|
||||
Timber.d("playback stabilized - retry count reset to 0 from %s", playbackRetries);
|
||||
@@ -305,7 +287,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
if (mode.getPhysicalWidth() < 1280 || mode.getPhysicalHeight() < 720) // Skip non-HD
|
||||
continue;
|
||||
|
||||
if (mode.getPhysicalWidth() < videoStream.getWidth() || mode.getPhysicalHeight() < videoStream.getHeight()) // Disallow reso downgrade
|
||||
if (mode.getPhysicalWidth() < videoStream.getWidth() || mode.getPhysicalHeight() < videoStream.getHeight()) // Disallow resolution downgrade
|
||||
continue;
|
||||
|
||||
int rate = Math.round(mode.getRefreshRate() * 100);
|
||||
@@ -403,7 +385,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
play(position, null);
|
||||
}
|
||||
|
||||
private void play(long position, @Nullable Integer forcedSubtitleIndex) {
|
||||
protected void play(long position, @Nullable Integer forcedSubtitleIndex) {
|
||||
Timber.i("Play called from state: %s with pos: %d and sub index: %d", mPlaybackState, position, forcedSubtitleIndex);
|
||||
|
||||
if (mFragment == null) {
|
||||
@@ -516,18 +498,23 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
internalOptions.setItemId(item.getId());
|
||||
internalOptions.setMediaSources(item.getMediaSources());
|
||||
internalOptions.setMaxBitrate(maxBitrate);
|
||||
if (exoErrorEncountered || (isLiveTv && !directStreamLiveTv))
|
||||
internalOptions.setEnableDirectStream(false);
|
||||
internalOptions.setMaxAudioChannels(Utils.downMixAudio(mFragment.getContext()) ? 2 : null); //have to downmix at server
|
||||
internalOptions.setSubtitleStreamIndex(forcedSubtitleIndex);
|
||||
if (playbackRetries > 0 || (isLiveTv && !directStreamLiveTv)) internalOptions.setEnableDirectStream(false);
|
||||
if (playbackRetries > 1) internalOptions.setEnableDirectPlay(false);
|
||||
if (mCurrentOptions != null) {
|
||||
internalOptions.setSubtitleStreamIndex(mCurrentOptions.getSubtitleStreamIndex());
|
||||
internalOptions.setAudioStreamIndex(mCurrentOptions.getAudioStreamIndex());
|
||||
}
|
||||
if (forcedSubtitleIndex != null) {
|
||||
internalOptions.setSubtitleStreamIndex(forcedSubtitleIndex);
|
||||
}
|
||||
MediaSourceInfo currentMediaSource = getCurrentMediaSource();
|
||||
if (!isLiveTv && currentMediaSource != null) {
|
||||
internalOptions.setMediaSourceId(currentMediaSource.getId());
|
||||
}
|
||||
DeviceProfile internalProfile = new ExoPlayerProfile(
|
||||
mFragment.getContext(),
|
||||
isLiveTv && !userPreferences.getValue().get(UserPreferences.Companion.getLiveTvDirectPlayEnabled()),
|
||||
userPreferences.getValue().get(UserPreferences.Companion.getAc3Enabled())
|
||||
userPreferences.getValue().get(UserPreferences.Companion.getAc3Enabled()),
|
||||
userPreferences.getValue().get(UserPreferences.Companion.getAudioBehaviour()) == AudioBehavior.DOWNMIX_TO_STEREO
|
||||
);
|
||||
internalOptions.setProfile(internalProfile);
|
||||
return internalOptions;
|
||||
@@ -561,6 +548,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
if (mVideoManager == null)
|
||||
return;
|
||||
mCurrentOptions = internalOptions;
|
||||
if (internalOptions.getSubtitleStreamIndex() == null) burningSubs = internalResponse.getSubtitleDeliveryMethod() == SubtitleDeliveryMethod.ENCODE;
|
||||
startItem(item, position, internalResponse);
|
||||
}
|
||||
|
||||
@@ -603,29 +591,26 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
}
|
||||
|
||||
mStartPosition = position;
|
||||
|
||||
mCurrentStreamInfo = response;
|
||||
|
||||
// set media source Id in case we need to switch to transcoding
|
||||
mCurrentOptions.setMediaSourceId(response.getMediaSource().getId());
|
||||
|
||||
if (response.getMediaUrl() == null) {
|
||||
// If baking subtitles doesn't work (e.g. no permissions to transcode), disable them
|
||||
if (response.getSubtitleDeliveryMethod() == SubtitleDeliveryMethod.ENCODE && (response.getMediaSource().getDefaultSubtitleStreamIndex() == null || response.getMediaSource().getDefaultSubtitleStreamIndex() != -1)) {
|
||||
burningSubs = false;
|
||||
stop();
|
||||
play(position, -1);
|
||||
} else {
|
||||
handlePlaybackInfoError(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// get subtitle info
|
||||
mSubtitleStreams = response.getSubtitleProfiles(false, apiClient.getValue().getApiUrl(), apiClient.getValue().getAccessToken());
|
||||
mDefaultSubIndex = response.getMediaSource().getDefaultSubtitleStreamIndex() != null ? response.getMediaSource().getDefaultSubtitleStreamIndex() : mDefaultSubIndex;
|
||||
mCurrentOptions.setSubtitleStreamIndex(response.getMediaSource().getDefaultSubtitleStreamIndex() != null ? response.getMediaSource().getDefaultSubtitleStreamIndex() : null);
|
||||
setDefaultAudioIndex(response);
|
||||
Timber.d("default audio index set to %s remote default %s", mDefaultAudioIndex, response.getMediaSource().getDefaultAudioStreamIndex());
|
||||
Timber.d("default sub index set to %s remote default %s", mDefaultSubIndex, response.getMediaSource().getDefaultSubtitleStreamIndex());
|
||||
|
||||
// if burning in, set the subtitle index and the burningSubs flag so that onPrepared and switchSubtitleStream will know that we already have subtitles enabled
|
||||
burningSubs = false;
|
||||
if (mCurrentStreamInfo.getPlayMethod() == PlayMethod.Transcode && getSubtitleStreamInfo(mDefaultSubIndex) != null &&
|
||||
getSubtitleStreamInfo(mDefaultSubIndex).getDeliveryMethod() == SubtitleDeliveryMethod.Encode) {
|
||||
mCurrentOptions.setSubtitleStreamIndex(mDefaultSubIndex);
|
||||
Timber.d("stream started with burnt in subs");
|
||||
burningSubs = true;
|
||||
} else {
|
||||
mCurrentOptions.setSubtitleStreamIndex(null);
|
||||
}
|
||||
Timber.d("default sub index set to %s remote default %s", mCurrentOptions.getSubtitleStreamIndex(), response.getMediaSource().getDefaultSubtitleStreamIndex());
|
||||
|
||||
Long mbPos = position * 10000;
|
||||
|
||||
@@ -641,20 +626,30 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
if (mFragment != null) mFragment.updateDisplay();
|
||||
|
||||
if (mVideoManager != null) {
|
||||
mVideoManager.setVideoPath(response.getMediaUrl());
|
||||
mVideoManager.setMediaStreamInfo(api.getValue(), response);
|
||||
}
|
||||
|
||||
//wait a beat before attempting to start so the player surface is fully initialized and video is ready
|
||||
mHandler.postDelayed(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (mVideoManager != null)
|
||||
mVideoManager.start();
|
||||
PlaybackControllerHelperKt.applyMediaSegments(this, item, () -> {
|
||||
// Set video start delay
|
||||
long videoStartDelay = userPreferences.getValue().get(UserPreferences.Companion.getVideoStartDelay());
|
||||
if (videoStartDelay > 0) {
|
||||
mHandler.postDelayed(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (mVideoManager != null) {
|
||||
mVideoManager.start();
|
||||
}
|
||||
}
|
||||
}, videoStartDelay);
|
||||
} else {
|
||||
mVideoManager.start();
|
||||
}
|
||||
}, 750);
|
||||
|
||||
dataRefreshService.getValue().setLastPlayedItem(item);
|
||||
reportingHelper.getValue().reportStart(item, mbPos);
|
||||
dataRefreshService.getValue().setLastPlayedItem(item);
|
||||
reportingHelper.getValue().reportStart(mFragment, PlaybackController.this, item, response, mbPos, false);
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
public void startSpinner() {
|
||||
@@ -745,117 +740,6 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void switchSubtitleStream(int index) {
|
||||
if (!hasInitializedVideoManager())
|
||||
return;
|
||||
// get current timestamp first
|
||||
refreshCurrentPosition();
|
||||
Timber.d("Setting subtitle index to: %d", index);
|
||||
|
||||
// clear subtitles first
|
||||
if (mFragment != null) mFragment.addManualSubtitles(null);
|
||||
mVideoManager.disableSubs();
|
||||
// clear the default in case there's an error loading the subtitles
|
||||
mDefaultSubIndex = -1;
|
||||
|
||||
// handle setting subtitles as disabled
|
||||
// restart playback if turning off burnt-in subtitles
|
||||
if (index < 0) {
|
||||
mCurrentOptions.setSubtitleStreamIndex(-1);
|
||||
if (burningSubs) {
|
||||
stop();
|
||||
play(mCurrentPosition, -1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
org.jellyfin.sdk.model.api.MediaStream stream = StreamHelper.getMediaStream(getCurrentMediaSource(), index);
|
||||
if (stream == null) {
|
||||
if (mFragment != null)
|
||||
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.subtitle_error));
|
||||
return;
|
||||
}
|
||||
SubtitleStreamInfo streamInfo = getSubtitleStreamInfo(index);
|
||||
if (streamInfo == null) {
|
||||
if (mFragment != null)
|
||||
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_unable_load_subs));
|
||||
return;
|
||||
}
|
||||
|
||||
// handle switching on or between burnt-in subtitles, or switching to non-burnt subtitles
|
||||
// if switching from burnt-in subtitles to another type, playback still needs to be restarted
|
||||
if (burningSubs || streamInfo.getDeliveryMethod() == SubtitleDeliveryMethod.Encode) {
|
||||
stop();
|
||||
if (mFragment != null && streamInfo.getDeliveryMethod() == SubtitleDeliveryMethod.Encode)
|
||||
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_burn_sub_warning));
|
||||
play(mCurrentPosition, index);
|
||||
return;
|
||||
}
|
||||
|
||||
// when burnt-in subtitles are selected, mCurrentOptions SubtitleStreamIndex is set in startItem() as soon as playback starts
|
||||
// otherwise mCurrentOptions SubtitleStreamIndex is kept null until now so we knew subtitles needed to be enabled but weren't already
|
||||
|
||||
switch (streamInfo.getDeliveryMethod()) {
|
||||
case Embed:
|
||||
if (!mVideoManager.setExoPlayerTrack(index, MediaStreamType.SUBTITLE, getCurrentlyPlayingItem().getMediaStreams())) {
|
||||
// error selecting internal subs
|
||||
if (mFragment != null)
|
||||
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_unable_load_subs));
|
||||
} else {
|
||||
mCurrentOptions.setSubtitleStreamIndex(index);
|
||||
mDefaultSubIndex = index;
|
||||
}
|
||||
break;
|
||||
case External:
|
||||
if (mFragment != null) mFragment.showSubLoadingMsg(true);
|
||||
|
||||
stream = ModelUtils.withDelivery(
|
||||
stream,
|
||||
org.jellyfin.sdk.model.api.SubtitleDeliveryMethod.EXTERNAL,
|
||||
String.format(
|
||||
"%1$s/Videos/%2$s/%3$s/Subtitles/%4$s/0/Stream.JSON",
|
||||
apiClient.getValue().getApiUrl(),
|
||||
mCurrentStreamInfo.getItemId(),
|
||||
mCurrentStreamInfo.getMediaSourceId(),
|
||||
String.valueOf(stream.getIndex())
|
||||
)
|
||||
);
|
||||
apiClient.getValue().getSubtitles(stream.getDeliveryUrl(), new Response<SubtitleTrackInfo>() {
|
||||
|
||||
@Override
|
||||
public void onResponse(final SubtitleTrackInfo info) {
|
||||
|
||||
if (info != null) {
|
||||
Timber.d("Adding json subtitle track to player");
|
||||
if (mFragment != null) mFragment.addManualSubtitles(info);
|
||||
mCurrentOptions.setSubtitleStreamIndex(index);
|
||||
mDefaultSubIndex = index;
|
||||
} else {
|
||||
Timber.e("Empty subtitle result");
|
||||
if (mFragment != null) {
|
||||
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_unable_load_subs));
|
||||
mFragment.showSubLoadingMsg(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Exception ex) {
|
||||
Timber.e(ex, "Error downloading subtitles");
|
||||
if (mFragment != null) {
|
||||
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_unable_load_subs));
|
||||
mFragment.showSubLoadingMsg(false);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
break;
|
||||
case Hls:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void pause() {
|
||||
Timber.d("pause called at %s", mCurrentPosition);
|
||||
// if playback is paused and the seekbar is scrubbed, it will call pause even if already paused
|
||||
@@ -897,8 +781,10 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
mPlaybackState = PlaybackState.IDLE;
|
||||
|
||||
if (mVideoManager != null && mVideoManager.isPlaying()) mVideoManager.stopPlayback();
|
||||
Long mbPos = mCurrentPosition * 10000;
|
||||
reportingHelper.getValue().reportStopped(getCurrentlyPlayingItem(), getCurrentStreamInfo(), mbPos);
|
||||
if (getCurrentlyPlayingItem() != null && mCurrentStreamInfo != null) {
|
||||
Long mbPos = mCurrentPosition * 10000;
|
||||
reportingHelper.getValue().reportStopped(mFragment, getCurrentlyPlayingItem(), mCurrentStreamInfo, mbPos);
|
||||
}
|
||||
clearPlaybackSessionOptions();
|
||||
}
|
||||
}
|
||||
@@ -926,11 +812,10 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
}
|
||||
|
||||
private void resetPlayerErrors() {
|
||||
exoErrorEncountered = false;
|
||||
playbackRetries = 0;
|
||||
}
|
||||
|
||||
private void clearPlaybackSessionOptions() {
|
||||
mDefaultSubIndex = -1;
|
||||
mDefaultAudioIndex = -1;
|
||||
mSeekPosition = -1;
|
||||
finishedInitialSeek = false;
|
||||
@@ -976,7 +861,11 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
}
|
||||
|
||||
public void seek(long pos) {
|
||||
pos = Utils.getSafeSeekPosition(pos, getDuration());
|
||||
seek(pos, false);
|
||||
}
|
||||
|
||||
public void seek(long pos, boolean skipToNext) {
|
||||
if (pos <= 0) pos = 0;
|
||||
|
||||
Timber.d("Trying to seek from %s to %d", mCurrentPosition, pos);
|
||||
Timber.d("Container: %s", mCurrentStreamInfo == null ? "unknown" : mCurrentStreamInfo.getContainer());
|
||||
@@ -995,6 +884,20 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
}
|
||||
wasSeeking = true;
|
||||
|
||||
// Stop playback when the requested seek position is at the end of the video
|
||||
if (skipToNext && pos >= (getDuration() - 100)) {
|
||||
// Since we've skipped ahead, set the current position so the PlaybackStopInfo will report the correct end time
|
||||
mCurrentPosition = getDuration();
|
||||
// Make sure we also set the seek positions so mCurrentPosition won't get overwritten in refreshCurrentPosition()
|
||||
currentSkipPos = mCurrentPosition;
|
||||
mSeekPosition = mCurrentPosition;
|
||||
// Finalize item playback
|
||||
itemComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pos >= getDuration()) pos = getDuration();
|
||||
|
||||
// set seekPosition so real position isn't used until playback starts again
|
||||
mSeekPosition = pos;
|
||||
|
||||
@@ -1011,7 +914,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
public void onResponse(StreamInfo response) {
|
||||
mCurrentStreamInfo = response;
|
||||
if (mVideoManager != null) {
|
||||
mVideoManager.setVideoPath(response.getMediaUrl());
|
||||
mVideoManager.setMediaStreamInfo(api.getValue(), response);
|
||||
mVideoManager.start();
|
||||
}
|
||||
}
|
||||
@@ -1097,8 +1000,10 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
}
|
||||
|
||||
private void startReportLoop() {
|
||||
if (mCurrentStreamInfo == null) return;
|
||||
|
||||
stopReportLoop();
|
||||
reportingHelper.getValue().reportProgress(this, getCurrentlyPlayingItem(), getCurrentStreamInfo(), mCurrentPosition * 10000, false);
|
||||
reportingHelper.getValue().reportProgress(mFragment, this, getCurrentlyPlayingItem(), getCurrentStreamInfo(), mCurrentPosition * 10000, false);
|
||||
mReportLoop = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
@@ -1106,7 +1011,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
refreshCurrentPosition();
|
||||
long currentTime = isLiveTv ? getTimeShiftedProgress() : mCurrentPosition;
|
||||
|
||||
reportingHelper.getValue().reportProgress(PlaybackController.this, getCurrentlyPlayingItem(), getCurrentStreamInfo(), currentTime * 10000, false);
|
||||
reportingHelper.getValue().reportProgress(mFragment, PlaybackController.this, getCurrentlyPlayingItem(), getCurrentStreamInfo(), currentTime * 10000, false);
|
||||
}
|
||||
if (mPlaybackState != PlaybackState.UNDEFINED && mPlaybackState != PlaybackState.IDLE) {
|
||||
mHandler.postDelayed(this, PROGRESS_REPORTING_INTERVAL);
|
||||
@@ -1118,7 +1023,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
|
||||
private void startPauseReportLoop() {
|
||||
stopReportLoop();
|
||||
reportingHelper.getValue().reportProgress(this, getCurrentlyPlayingItem(), getCurrentStreamInfo(), mCurrentPosition * 10000, true);
|
||||
reportingHelper.getValue().reportProgress(mFragment, this, getCurrentlyPlayingItem(), getCurrentStreamInfo(), mCurrentPosition * 10000, true);
|
||||
mReportLoop = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
@@ -1139,7 +1044,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
mFragment.setSecondaryTime(getRealTimeProgress());
|
||||
}
|
||||
|
||||
reportingHelper.getValue().reportProgress(PlaybackController.this, currentItem, getCurrentStreamInfo(), currentTime * 10000, true);
|
||||
reportingHelper.getValue().reportProgress(mFragment, PlaybackController.this, currentItem, getCurrentStreamInfo(), currentTime * 10000, true);
|
||||
mHandler.postDelayed(this, PROGRESS_REPORTING_PAUSE_INTERVAL);
|
||||
}
|
||||
};
|
||||
@@ -1205,7 +1110,10 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
@Override
|
||||
public void onPrepared() {
|
||||
if (mPlaybackState == PlaybackState.BUFFERING) {
|
||||
if (mFragment != null) mFragment.setFadingEnabled(true);
|
||||
if (mFragment != null) {
|
||||
mFragment.setFadingEnabled(true);
|
||||
mFragment.leanbackOverlayFragment.setShouldShowOverlay(false);
|
||||
}
|
||||
|
||||
mPlaybackState = PlaybackState.PLAYING;
|
||||
mCurrentTranscodeStartTime = mCurrentStreamInfo.getPlayMethod() == PlayMethod.Transcode ? Instant.now().toEpochMilli() : 0;
|
||||
@@ -1217,16 +1125,11 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
if (mPlaybackState == PlaybackState.PAUSED) {
|
||||
mPlaybackState = PlaybackState.PLAYING;
|
||||
} else {
|
||||
// select or disable subtitles
|
||||
Integer currentSubtitleIndex = mCurrentOptions.getSubtitleStreamIndex();
|
||||
if (mDefaultSubIndex >= 0 && currentSubtitleIndex != null && currentSubtitleIndex == mDefaultSubIndex) {
|
||||
Timber.i("subtitle stream %s is already selected", mDefaultSubIndex);
|
||||
} else {
|
||||
if (mDefaultSubIndex < 0)
|
||||
Timber.i("Turning off subs");
|
||||
else
|
||||
Timber.i("Enabling default sub stream: %d", mDefaultSubIndex);
|
||||
switchSubtitleStream(mDefaultSubIndex);
|
||||
if (!burningSubs) {
|
||||
// Make sure the requested subtitles are enabled when external/embedded
|
||||
Integer currentSubtitleIndex = mCurrentOptions.getSubtitleStreamIndex();
|
||||
if (currentSubtitleIndex == null) currentSubtitleIndex = -1;
|
||||
PlaybackControllerHelperKt.setSubtitleIndex(this, currentSubtitleIndex, true);
|
||||
}
|
||||
|
||||
// select an audio track
|
||||
@@ -1279,9 +1182,6 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
stopSpinner();
|
||||
}
|
||||
}
|
||||
|
||||
if (mFragment != null && finishedInitialSeek)
|
||||
mFragment.updateSubtitles(mCurrentPosition);
|
||||
}
|
||||
if (mFragment != null)
|
||||
mFragment.setCurrentTime(mCurrentPosition);
|
||||
@@ -1319,11 +1219,11 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
return mPlaybackState == PlaybackState.PAUSED;
|
||||
}
|
||||
|
||||
public int getZoomMode() {
|
||||
return hasInitializedVideoManager() ? mVideoManager.getZoomMode() : 0;
|
||||
public @NonNull ZoomMode getZoomMode() {
|
||||
return hasInitializedVideoManager() ? mVideoManager.getZoomMode() : ZoomMode.FIT;
|
||||
}
|
||||
|
||||
public void setZoom(int mode) {
|
||||
public void setZoom(@NonNull ZoomMode mode) {
|
||||
if (hasInitializedVideoManager())
|
||||
mVideoManager.setZoom(mode);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
package org.jellyfin.androidtv.ui.playback
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentAction
|
||||
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepository
|
||||
import org.jellyfin.androidtv.util.sdk.end
|
||||
import org.jellyfin.androidtv.util.sdk.start
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import org.koin.android.ext.android.inject
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
fun PlaybackController.getLiveTvChannel(
|
||||
@@ -22,3 +35,166 @@ fun PlaybackController.getLiveTvChannel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@JvmOverloads
|
||||
fun PlaybackController.setSubtitleIndex(index: Int, force: Boolean = false) {
|
||||
Timber.i("Switching subtitles from index ${mCurrentOptions.subtitleStreamIndex} to $index")
|
||||
|
||||
// Already using this subtitle index
|
||||
if (mCurrentOptions.subtitleStreamIndex == index && !force) return
|
||||
|
||||
// Disable subtitles
|
||||
if (index == -1) {
|
||||
mCurrentOptions.subtitleStreamIndex = -1
|
||||
|
||||
if (burningSubs) {
|
||||
Timber.i("Disabling subtitle baking")
|
||||
|
||||
stop()
|
||||
burningSubs = false
|
||||
play(mCurrentPosition, -1)
|
||||
} else {
|
||||
Timber.i("Disabling subtitles")
|
||||
|
||||
with(mVideoManager.mExoPlayer.trackSelector!!) {
|
||||
parameters = parameters.buildUpon()
|
||||
.clearOverridesOfType(C.TRACK_TYPE_TEXT)
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
} else if (burningSubs) {
|
||||
Timber.i("Restarting playback to disable subtitle baking")
|
||||
|
||||
// If we're currently burning subs and want to switch streams we need some special behavior
|
||||
// to stop the current baked subs. We can just stop & start with the new subtitle index for that
|
||||
stop()
|
||||
burningSubs = true
|
||||
mCurrentOptions.subtitleStreamIndex = index
|
||||
play(mCurrentPosition, index)
|
||||
} else {
|
||||
val mediaSource = currentMediaSource
|
||||
val stream = mediaSource.mediaStreams?.first { it.type == MediaStreamType.SUBTITLE && it.index == index }
|
||||
if (stream == null) {
|
||||
Timber.w("Failed to find correct media stream")
|
||||
return setSubtitleIndex(-1)
|
||||
}
|
||||
|
||||
when (stream.deliveryMethod) {
|
||||
SubtitleDeliveryMethod.ENCODE -> {
|
||||
Timber.i("Restarting playback for subtitle baking")
|
||||
|
||||
stop()
|
||||
burningSubs = true
|
||||
mCurrentOptions.subtitleStreamIndex = index
|
||||
play(mCurrentPosition, index)
|
||||
}
|
||||
|
||||
SubtitleDeliveryMethod.EXTERNAL,
|
||||
SubtitleDeliveryMethod.EMBED,
|
||||
SubtitleDeliveryMethod.HLS -> {
|
||||
// External subtitles need to be resolved differently
|
||||
val group = if (stream.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL) {
|
||||
mVideoManager.mExoPlayer.currentTracks.groups.firstOrNull { group ->
|
||||
// Verify this is a group with a single format (the subtitles) that is added by us. Because ExoPlayer uses a
|
||||
// MergingMediaSource, each external subtitle format id is prefixed with its source index (normally starting at 1,
|
||||
// increasing for each external subttitle). So we only check the end of the id
|
||||
group.length == 1 && group.getTrackFormat(0).id?.endsWith(":JF_EXTERNAL:$index") == true
|
||||
}
|
||||
} else {
|
||||
// The server does not send a reliable index in all cases, so calculate it manually
|
||||
val localIndex = mediaSource.mediaStreams.orEmpty()
|
||||
.filter { it.type == MediaStreamType.SUBTITLE }
|
||||
.filter { it.deliveryMethod == SubtitleDeliveryMethod.EMBED || it.deliveryMethod == SubtitleDeliveryMethod.HLS }
|
||||
.indexOf(stream)
|
||||
.takeIf { index -> index != -1 }
|
||||
|
||||
if (localIndex == null) {
|
||||
Timber.w("Failed to find local subtitle index")
|
||||
return setSubtitleIndex(-1)
|
||||
}
|
||||
|
||||
mVideoManager.mExoPlayer.currentTracks.groups
|
||||
.filter { it.type == C.TRACK_TYPE_TEXT }
|
||||
.filterNot { it.length == 1 && it.getTrackFormat(0).id?.endsWith(":JF_EXTERNAL:$index") == true }
|
||||
.getOrNull(localIndex)
|
||||
}?.mediaTrackGroup
|
||||
|
||||
if (group == null) {
|
||||
Timber.w("Failed to find correct subtitle group for method ${stream.deliveryMethod}")
|
||||
return setSubtitleIndex(-1)
|
||||
}
|
||||
|
||||
Timber.i("Enabling subtitle group $index via method ${stream.deliveryMethod}")
|
||||
mCurrentOptions.subtitleStreamIndex = index
|
||||
with(mVideoManager.mExoPlayer.trackSelector!!) {
|
||||
parameters = parameters.buildUpon()
|
||||
.clearOverridesOfType(C.TRACK_TYPE_TEXT)
|
||||
.addOverride(TrackSelectionOverride(group, 0))
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
SubtitleDeliveryMethod.DROP, null -> {
|
||||
Timber.i("Dropping subtitles")
|
||||
setSubtitleIndex(-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun PlaybackController.applyMediaSegments(
|
||||
item: BaseItemDto,
|
||||
callback: () -> Unit,
|
||||
) {
|
||||
val mediaSegmentRepository by fragment.inject<MediaSegmentRepository>()
|
||||
|
||||
fragment.lifecycleScope.launch {
|
||||
val mediaSegments = runCatching {
|
||||
mediaSegmentRepository.getSegmentsForItem(item)
|
||||
}.getOrNull().orEmpty()
|
||||
|
||||
for (mediaSegment in mediaSegments) {
|
||||
val action = mediaSegmentRepository.getMediaSegmentAction(mediaSegment)
|
||||
|
||||
when (action) {
|
||||
MediaSegmentAction.SKIP -> addSkipAction(mediaSegment)
|
||||
MediaSegmentAction.ASK_TO_SKIP -> addAskToSkipAction(mediaSegment)
|
||||
MediaSegmentAction.NOTHING -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun PlaybackController.addSkipAction(mediaSegment: MediaSegmentDto) {
|
||||
mVideoManager.mExoPlayer
|
||||
.createMessage { _, _ ->
|
||||
// We can't seek directly on the ExoPlayer instance as not all media is seekable
|
||||
// the seek function in the PlaybackController checks this and optionally starts a transcode
|
||||
// at the requested position
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
seek(mediaSegment.end.inWholeMilliseconds, true)
|
||||
}
|
||||
}
|
||||
// Segments at position 0 will never be hit by ExoPlayer so we need to add a minimum value
|
||||
.setPosition(mediaSegment.start.inWholeMilliseconds.coerceAtLeast(1))
|
||||
.setDeleteAfterDelivery(false)
|
||||
.send()
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun PlaybackController.addAskToSkipAction(mediaSegment: MediaSegmentDto) {
|
||||
mVideoManager.mExoPlayer
|
||||
.createMessage { _, _ ->
|
||||
fragment?.askToSkip(mediaSegment.end)
|
||||
}
|
||||
// Segments at position 0 will never be hit by ExoPlayer so we need to add a minimum value
|
||||
.setPosition(mediaSegment.start.inWholeMilliseconds.coerceAtLeast(1))
|
||||
.setDeleteAfterDelivery(false)
|
||||
.send()
|
||||
}
|
||||
|
||||
@@ -4,15 +4,10 @@ import org.jellyfin.androidtv.data.compat.PlaybackException;
|
||||
import org.jellyfin.androidtv.data.compat.StreamInfo;
|
||||
import org.jellyfin.androidtv.data.compat.VideoOptions;
|
||||
import org.jellyfin.apiclient.interaction.ApiClient;
|
||||
import org.jellyfin.apiclient.interaction.EmptyResponse;
|
||||
import org.jellyfin.apiclient.interaction.Response;
|
||||
import org.jellyfin.apiclient.model.dlna.PlaybackErrorCode;
|
||||
import org.jellyfin.apiclient.model.mediainfo.PlaybackInfoRequest;
|
||||
import org.jellyfin.apiclient.model.session.PlaybackProgressInfo;
|
||||
import org.jellyfin.apiclient.model.session.PlaybackStartInfo;
|
||||
import org.jellyfin.apiclient.model.session.PlaybackStopInfo;
|
||||
import org.jellyfin.sdk.model.DeviceInfo;
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo;
|
||||
import org.jellyfin.sdk.model.api.MediaStream;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -58,15 +53,12 @@ public class PlaybackManager {
|
||||
request.setAudioStreamIndex(audioIdx);
|
||||
}
|
||||
Integer subIdx = options.getSubtitleStreamIndex();
|
||||
if (subIdx != null) {
|
||||
request.setSubtitleStreamIndex(subIdx);
|
||||
}
|
||||
if (subIdx != null) request.setSubtitleStreamIndex(subIdx);
|
||||
|
||||
request.setAllowVideoStreamCopy(true);
|
||||
request.setAllowAudioStreamCopy(true);
|
||||
|
||||
apiClient.GetPlaybackInfoWithPost(request, new GetPlaybackInfoResponse(this, deviceInfo, apiClient, options, response, true, startPositionTicks));
|
||||
|
||||
apiClient.GetPlaybackInfoWithPost(request, new GetPlaybackInfoResponse(this, deviceInfo, apiClient, options, response, true));
|
||||
}
|
||||
|
||||
public void changeVideoStream(final StreamInfo currentStreamInfo, DeviceInfo deviceInfo, final VideoOptions options, Long startPositionTicks, ApiClient apiClient, final Response<StreamInfo> response) {
|
||||
@@ -74,32 +66,4 @@ public class PlaybackManager {
|
||||
|
||||
apiClient.StopTranscodingProcesses(api.getDeviceInfo().getId(), playSessionId, new StopTranscodingResponse(this, deviceInfo, options, startPositionTicks, apiClient, response));
|
||||
}
|
||||
|
||||
public void reportPlaybackStart(PlaybackStartInfo info, ApiClient apiClient, EmptyResponse response) {
|
||||
apiClient.ReportPlaybackStartAsync(info, response);
|
||||
}
|
||||
|
||||
public void reportPlaybackProgress(PlaybackProgressInfo info, final StreamInfo streamInfo, ApiClient apiClient, EmptyResponse response) {
|
||||
MediaSourceInfo mediaSource = streamInfo.getMediaSource();
|
||||
|
||||
if (mediaSource != null) {
|
||||
info.setLiveStreamId(mediaSource.getLiveStreamId());
|
||||
}
|
||||
|
||||
info.setPlaySessionId(streamInfo.getPlaySessionId());
|
||||
|
||||
apiClient.ReportPlaybackProgressAsync(info, response);
|
||||
}
|
||||
|
||||
public void reportPlaybackStopped(PlaybackStopInfo info, final StreamInfo streamInfo, final ApiClient apiClient, final EmptyResponse response) {
|
||||
MediaSourceInfo mediaSource = streamInfo.getMediaSource();
|
||||
|
||||
if (mediaSource != null) {
|
||||
info.setLiveStreamId(mediaSource.getLiveStreamId());
|
||||
}
|
||||
|
||||
info.setPlaySessionId(streamInfo.getPlaySessionId());
|
||||
|
||||
apiClient.ReportPlaybackStoppedAsync(info, response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.jellyfin.androidtv.ui.playback;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.media.audiofx.DynamicsProcessing;
|
||||
import android.media.audiofx.DynamicsProcessing.Limiter;
|
||||
import android.media.audiofx.Equalizer;
|
||||
@@ -32,19 +33,26 @@ import androidx.media3.exoplayer.DefaultRenderersFactory;
|
||||
import androidx.media3.exoplayer.ExoPlayer;
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector;
|
||||
import androidx.media3.exoplayer.util.EventLogger;
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory;
|
||||
import androidx.media3.extractor.ts.TsExtractor;
|
||||
import androidx.media3.ui.AspectRatioFrameLayout;
|
||||
import androidx.media3.ui.CaptionStyleCompat;
|
||||
import androidx.media3.ui.PlayerView;
|
||||
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
|
||||
import org.jellyfin.androidtv.R;
|
||||
import org.jellyfin.androidtv.data.compat.StreamInfo;
|
||||
import org.jellyfin.androidtv.preference.UserPreferences;
|
||||
import org.jellyfin.androidtv.preference.constant.ZoomMode;
|
||||
import org.jellyfin.playback.media3.exoplayer.mapping.SubtitleKt;
|
||||
import org.jellyfin.sdk.api.client.ApiClient;
|
||||
import org.jellyfin.sdk.model.api.MediaStream;
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType;
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod;
|
||||
import org.koin.java.KoinJavaComponent;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -52,19 +60,14 @@ import timber.log.Timber;
|
||||
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public class VideoManager {
|
||||
public final static int ZOOM_FIT = 0;
|
||||
public final static int ZOOM_AUTO_CROP = 1;
|
||||
public final static int ZOOM_STRETCH = 2;
|
||||
|
||||
private int mZoomMode = ZOOM_FIT;
|
||||
|
||||
private ZoomMode mZoomMode;
|
||||
private Activity mActivity;
|
||||
private Equalizer mEqualizer;
|
||||
private DynamicsProcessing mDynamicsProcessing;
|
||||
private Limiter mLimiter;
|
||||
private PlaybackControllerNotifiable mPlaybackControllerNotifiable;
|
||||
private PlaybackOverlayFragmentHelper _helper;
|
||||
private ExoPlayer mExoPlayer;
|
||||
public ExoPlayer mExoPlayer;
|
||||
private PlayerView mExoPlayerView;
|
||||
private Handler mHandler = new Handler();
|
||||
|
||||
@@ -74,23 +77,35 @@ public class VideoManager {
|
||||
|
||||
public boolean isContracted = false;
|
||||
|
||||
private final UserPreferences userPreferences = KoinJavaComponent.get(UserPreferences.class);
|
||||
|
||||
public VideoManager(@NonNull Activity activity, @NonNull View view, @NonNull PlaybackOverlayFragmentHelper helper) {
|
||||
mActivity = activity;
|
||||
_helper = helper;
|
||||
nightModeEnabled = KoinJavaComponent.<UserPreferences>get(UserPreferences.class).get(UserPreferences.Companion.getAudioNightMode());
|
||||
nightModeEnabled = userPreferences.get(UserPreferences.Companion.getAudioNightMode());
|
||||
|
||||
mExoPlayer = configureExoplayerBuilder(activity).build();
|
||||
|
||||
if (userPreferences.get(UserPreferences.Companion.getDebuggingEnabled())) {
|
||||
mExoPlayer.addAnalyticsListener(new EventLogger());
|
||||
}
|
||||
|
||||
// Volume normalisation (audio night mode).
|
||||
if (nightModeEnabled) enableAudioNightMode(mExoPlayer.getAudioSessionId());
|
||||
|
||||
mExoPlayer.setTrackSelectionParameters(mExoPlayer.getTrackSelectionParameters()
|
||||
.buildUpon()
|
||||
.setDisabledTrackTypes(ImmutableSet.of(C.TRACK_TYPE_TEXT))
|
||||
.build());
|
||||
|
||||
mExoPlayerView = view.findViewById(R.id.exoPlayerView);
|
||||
mExoPlayerView.setPlayer(mExoPlayer);
|
||||
int strokeColor = userPreferences.get(UserPreferences.Companion.getSubtitleTextStrokeColor()).intValue();
|
||||
CaptionStyleCompat subtitleStyle = new CaptionStyleCompat(
|
||||
userPreferences.get(UserPreferences.Companion.getSubtitlesTextColor()).intValue(),
|
||||
userPreferences.get(UserPreferences.Companion.getSubtitlesBackgroundColor()).intValue(),
|
||||
Color.TRANSPARENT,
|
||||
Color.alpha(strokeColor) == 0 ? CaptionStyleCompat.EDGE_TYPE_NONE : CaptionStyleCompat.EDGE_TYPE_OUTLINE,
|
||||
strokeColor,
|
||||
null
|
||||
);
|
||||
mExoPlayerView.getSubtitleView().setFractionalTextSize(0.0533f * userPreferences.get(UserPreferences.Companion.getSubtitlesTextSize()));
|
||||
mExoPlayerView.getSubtitleView().setStyle(subtitleStyle);
|
||||
mExoPlayer.addListener(new Player.Listener() {
|
||||
@Override
|
||||
public void onPlayerError(@NonNull PlaybackException error) {
|
||||
@@ -125,7 +140,7 @@ public class VideoManager {
|
||||
|
||||
@Override
|
||||
public void onPlaybackParametersChanged(@NonNull PlaybackParameters playbackParameters) {
|
||||
if (mPlaybackControllerNotifiable != null){
|
||||
if (mPlaybackControllerNotifiable != null) {
|
||||
mPlaybackControllerNotifiable.onPlaybackSpeedChange(playbackParameters.speed);
|
||||
}
|
||||
}
|
||||
@@ -150,10 +165,18 @@ public class VideoManager {
|
||||
});
|
||||
}
|
||||
|
||||
public void subscribe(@NonNull PlaybackControllerNotifiable notifier){
|
||||
public void subscribe(@NonNull PlaybackControllerNotifiable notifier) {
|
||||
mPlaybackControllerNotifiable = notifier;
|
||||
}
|
||||
|
||||
private int determineExoPlayerExtensionRendererMode() {
|
||||
if (userPreferences.get(UserPreferences.Companion.getPreferExoPlayerFfmpeg())) {
|
||||
return DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER;
|
||||
}
|
||||
|
||||
return DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures Exoplayer for video playback. Initially we try with core decoders, but allow
|
||||
* ExoPlayer to silently fallback to software renderers.
|
||||
@@ -165,7 +188,7 @@ public class VideoManager {
|
||||
ExoPlayer.Builder exoPlayerBuilder = new ExoPlayer.Builder(context);
|
||||
DefaultRenderersFactory defaultRendererFactory = new DefaultRenderersFactory(context);
|
||||
defaultRendererFactory.setEnableDecoderFallback(true);
|
||||
defaultRendererFactory.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON);
|
||||
defaultRendererFactory.setExtensionRendererMode(determineExoPlayerExtensionRendererMode());
|
||||
exoPlayerBuilder.setRenderersFactory(defaultRendererFactory);
|
||||
|
||||
DefaultTrackSelector trackSelector = new DefaultTrackSelector(context);
|
||||
@@ -185,6 +208,8 @@ public class VideoManager {
|
||||
httpDataSourceFactory.setReadTimeoutMs(30 * 1000);
|
||||
DefaultDataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(context, httpDataSourceFactory);
|
||||
exoPlayerBuilder.setMediaSourceFactory(new DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory));
|
||||
extractorsFactory.setConstantBitrateSeekingEnabled(true);
|
||||
extractorsFactory.setConstantBitrateSeekingAlwaysEnabled(true);
|
||||
|
||||
return exoPlayerBuilder;
|
||||
}
|
||||
@@ -193,20 +218,20 @@ public class VideoManager {
|
||||
return mExoPlayer != null;
|
||||
}
|
||||
|
||||
public int getZoomMode() {
|
||||
public @NonNull ZoomMode getZoomMode() {
|
||||
return mZoomMode;
|
||||
}
|
||||
|
||||
public void setZoom(int mode) {
|
||||
public void setZoom(@NonNull ZoomMode mode) {
|
||||
mZoomMode = mode;
|
||||
switch (mode) {
|
||||
case ZOOM_FIT:
|
||||
case FIT:
|
||||
mExoPlayerView.setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FIT);
|
||||
break;
|
||||
case ZOOM_AUTO_CROP:
|
||||
case AUTO_CROP:
|
||||
mExoPlayerView.setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_ZOOM);
|
||||
break;
|
||||
case ZOOM_STRETCH:
|
||||
case STRETCH:
|
||||
mExoPlayerView.setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FILL);
|
||||
break;
|
||||
}
|
||||
@@ -268,7 +293,6 @@ public class VideoManager {
|
||||
public void stopPlayback() {
|
||||
if (mExoPlayer != null) {
|
||||
mExoPlayer.stop();
|
||||
disableSubs();
|
||||
|
||||
mExoPlayer.setTrackSelectionParameters(mExoPlayer.getTrackSelectionParameters()
|
||||
.buildUpon()
|
||||
@@ -297,7 +321,15 @@ public class VideoManager {
|
||||
return pos;
|
||||
}
|
||||
|
||||
public void setVideoPath(@Nullable String path) {
|
||||
private int getSubtitleSelectionFlags(MediaStream mediaStream) {
|
||||
int flags = 0;
|
||||
if (mediaStream.isDefault()) flags &= C.SELECTION_FLAG_DEFAULT;
|
||||
if (mediaStream.isForced()) flags &= C.SELECTION_FLAG_FORCED;
|
||||
return flags;
|
||||
}
|
||||
|
||||
public void setMediaStreamInfo(ApiClient api, StreamInfo streamInfo) {
|
||||
String path = streamInfo.getMediaUrl();
|
||||
if (path == null) {
|
||||
Timber.w("Video path is null cannot continue");
|
||||
return;
|
||||
@@ -305,23 +337,37 @@ public class VideoManager {
|
||||
Timber.i("Video path set to: %s", path);
|
||||
|
||||
try {
|
||||
mExoPlayer.setMediaItem(MediaItem.fromUri(Uri.parse(path)));
|
||||
// Add external subtitles
|
||||
List<MediaItem.SubtitleConfiguration> subtitleConfigurations = new ArrayList<>();
|
||||
for (MediaStream mediaStream : streamInfo.getMediaSource().getMediaStreams()) {
|
||||
if (mediaStream.getType() != MediaStreamType.SUBTITLE) continue;
|
||||
|
||||
if (mediaStream.getDeliveryMethod() == SubtitleDeliveryMethod.EXTERNAL) {
|
||||
Uri subtitleUri = Uri.parse(api.createUrl(mediaStream.getDeliveryUrl(), Collections.emptyMap(), Collections.emptyMap(), true));
|
||||
MediaItem.SubtitleConfiguration subtitleConfiguration = new MediaItem.SubtitleConfiguration.Builder(subtitleUri)
|
||||
.setId("JF_EXTERNAL:" + String.valueOf(mediaStream.getIndex()))
|
||||
.setMimeType(SubtitleKt.getFfmpegSubtitleMimeType(mediaStream.getCodec()))
|
||||
.setLanguage(mediaStream.getLanguage())
|
||||
.setLabel(mediaStream.getDisplayTitle())
|
||||
.setSelectionFlags(getSubtitleSelectionFlags(mediaStream))
|
||||
.build();
|
||||
Timber.i("Adding subtitle track %s of type %s", subtitleConfiguration.uri, subtitleConfiguration.mimeType);
|
||||
subtitleConfigurations.add(subtitleConfiguration);
|
||||
}
|
||||
}
|
||||
|
||||
MediaItem mediaItem = new MediaItem.Builder()
|
||||
.setUri(Uri.parse(path))
|
||||
.setSubtitleConfigurations(subtitleConfigurations)
|
||||
.build();
|
||||
|
||||
mExoPlayer.setMediaItem(mediaItem);
|
||||
mExoPlayer.prepare();
|
||||
} catch (IllegalStateException e) {
|
||||
Timber.e(e, "Unable to set video path. Probably backing out.");
|
||||
}
|
||||
}
|
||||
|
||||
public void disableSubs() {
|
||||
if (!isInitialized())
|
||||
return;
|
||||
|
||||
mExoPlayer.setTrackSelectionParameters(mExoPlayer.getTrackSelectionParameters()
|
||||
.buildUpon()
|
||||
.setDisabledTrackTypes(ImmutableSet.of(C.TRACK_TYPE_TEXT))
|
||||
.build());
|
||||
}
|
||||
|
||||
private int offsetStreamIndex(int index, boolean adjustByAdding, boolean indexStartsAtOne, @Nullable List<org.jellyfin.sdk.model.api.MediaStream> allStreams) {
|
||||
if (index < 0 || allStreams == null)
|
||||
return -1;
|
||||
@@ -370,7 +416,11 @@ public class VideoManager {
|
||||
if (trackFormat.id != null) {
|
||||
int id;
|
||||
try {
|
||||
id = Integer.parseInt(trackFormat.id);
|
||||
if (trackFormat.id.contains(":")) {
|
||||
id = Integer.parseInt(trackFormat.id.split(":")[1]);
|
||||
} else {
|
||||
id = Integer.parseInt(trackFormat.id);
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
Timber.d("failed to parse track ID [%s]", trackFormat.id);
|
||||
break;
|
||||
@@ -399,13 +449,9 @@ public class VideoManager {
|
||||
int chosenTrackType = streamType == org.jellyfin.sdk.model.api.MediaStreamType.SUBTITLE ? C.TRACK_TYPE_TEXT : C.TRACK_TYPE_AUDIO;
|
||||
|
||||
// Make sure the index is present
|
||||
Optional<MediaStream> candidateOptional = allStreams.stream().filter(stream -> stream.getIndex() == index).findFirst();
|
||||
Optional<MediaStream> candidateOptional = allStreams.stream().filter(stream -> stream.getIndex() == index && !stream.isExternal() && stream.getType() == streamType).findFirst();
|
||||
if (!candidateOptional.isPresent()) return false;
|
||||
|
||||
org.jellyfin.sdk.model.api.MediaStream candidate = candidateOptional.get();
|
||||
if (candidate.isExternal() || candidate.getType() != streamType)
|
||||
return false;
|
||||
|
||||
int exoTrackID = offsetStreamIndex(index, false, true, allStreams);
|
||||
if (exoTrackID < 0)
|
||||
return false;
|
||||
@@ -435,14 +481,18 @@ public class VideoManager {
|
||||
Format trackFormat = group.getFormat(i);
|
||||
|
||||
Timber.d("track %s group %s/%s trackType %s label %s mime %s isSelected %s isSupported %s",
|
||||
trackFormat.id, i+1, group.length, trackType, trackFormat.label, trackFormat.sampleMimeType, isSelected, isSupported);
|
||||
trackFormat.id, i + 1, group.length, trackType, trackFormat.label, trackFormat.sampleMimeType, isSelected, isSupported);
|
||||
|
||||
if (trackType != chosenTrackType || trackFormat.id == null)
|
||||
continue;
|
||||
|
||||
int id;
|
||||
try {
|
||||
id = Integer.parseInt(trackFormat.id);
|
||||
if (trackFormat.id.contains(":")) {
|
||||
id = Integer.parseInt(trackFormat.id.split(":")[1]);
|
||||
} else {
|
||||
id = Integer.parseInt(trackFormat.id);
|
||||
}
|
||||
if (id != exoTrackID)
|
||||
continue;
|
||||
} catch (NumberFormatException e) {
|
||||
@@ -471,8 +521,6 @@ public class VideoManager {
|
||||
try {
|
||||
TrackSelectionParameters.Builder mExoPlayerSelectionParams = mExoPlayer.getTrackSelectionParameters().buildUpon();
|
||||
mExoPlayerSelectionParams.setOverrideForType(new TrackSelectionOverride(matchedGroup, 0));
|
||||
if (streamType == MediaStreamType.SUBTITLE)
|
||||
mExoPlayerSelectionParams.setDisabledTrackTypes(ImmutableSet.of(C.TRACK_TYPE_NONE));
|
||||
mExoPlayer.setTrackSelectionParameters(mExoPlayerSelectionParams.build());
|
||||
} catch (Exception e) {
|
||||
Timber.d("Error setting track selection");
|
||||
@@ -481,7 +529,7 @@ public class VideoManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
public float getPlaybackSpeed(){
|
||||
public float getPlaybackSpeed() {
|
||||
if (!isInitialized()) {
|
||||
return 1.0f;
|
||||
} else {
|
||||
@@ -554,6 +602,7 @@ public class VideoManager {
|
||||
}
|
||||
|
||||
private Runnable progressLoop;
|
||||
|
||||
private void startProgressLoop() {
|
||||
stopProgressLoop();
|
||||
progressLoop = new Runnable() {
|
||||
|
||||
@@ -1,21 +1,106 @@
|
||||
package org.jellyfin.androidtv.ui.playback.overlay
|
||||
|
||||
import android.content.Context
|
||||
import androidx.leanback.widget.PlaybackSeekDataProvider
|
||||
import coil3.ImageLoader
|
||||
import coil3.network.NetworkHeaders
|
||||
import coil3.network.httpHeaders
|
||||
import coil3.request.Disposable
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.transformations
|
||||
import coil3.size.Size
|
||||
import coil3.toBitmap
|
||||
import org.jellyfin.androidtv.util.coil.SubsetTransformation
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.trickplayApi
|
||||
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.min
|
||||
|
||||
class CustomSeekProvider(
|
||||
private val videoPlayerAdapter: VideoPlayerAdapter,
|
||||
private val imageLoader: ImageLoader,
|
||||
private val api: ApiClient,
|
||||
private val context: Context,
|
||||
private val trickPlayEnabled: Boolean,
|
||||
private val forwardTime: Long
|
||||
) : PlaybackSeekDataProvider() {
|
||||
companion object {
|
||||
private const val SEEK_LENGTH = 30000L
|
||||
}
|
||||
private val imageRequests = mutableMapOf<Int, Disposable>()
|
||||
|
||||
override fun getSeekPositions(): LongArray {
|
||||
if (!videoPlayerAdapter.canSeek()) return LongArray(0)
|
||||
|
||||
val duration = videoPlayerAdapter.duration
|
||||
val size = ceil(duration.toDouble() / SEEK_LENGTH.toDouble()).toInt() + 1
|
||||
return LongArray(size) { i -> min(i * SEEK_LENGTH, duration) }
|
||||
val size = ceil(duration.toDouble() / forwardTime.toDouble()).toInt() + 1
|
||||
return LongArray(size) { i -> min(i * forwardTime, duration) }
|
||||
}
|
||||
|
||||
override fun getThumbnail(index: Int, callback: ResultCallback) {
|
||||
if (!trickPlayEnabled) return
|
||||
|
||||
val currentRequest = imageRequests[index]
|
||||
if (currentRequest?.isDisposed == false) currentRequest.dispose()
|
||||
|
||||
val item = videoPlayerAdapter.currentlyPlayingItem
|
||||
val mediaSource = videoPlayerAdapter.currentMediaSource
|
||||
val mediaSourceId = mediaSource?.id?.toUUIDOrNull()
|
||||
if (item == null || mediaSource == null || mediaSourceId == null) return
|
||||
|
||||
val trickPlayResolutions = item.trickplay?.get(mediaSource.id)
|
||||
val trickPlayInfo = trickPlayResolutions?.values?.firstOrNull()
|
||||
if (trickPlayInfo == null) return
|
||||
|
||||
val currentTimeMs = (index * forwardTime).coerceIn(0, videoPlayerAdapter.duration)
|
||||
val currentTile = currentTimeMs.floorDiv(trickPlayInfo.interval).toInt()
|
||||
|
||||
val tileSize = trickPlayInfo.tileWidth * trickPlayInfo.tileHeight
|
||||
val tileOffset = currentTile % tileSize
|
||||
val tileIndex = currentTile / tileSize
|
||||
|
||||
val tileOffsetX = tileOffset % trickPlayInfo.tileWidth
|
||||
val tileOffsetY = tileOffset / trickPlayInfo.tileWidth
|
||||
val offsetX = tileOffsetX * trickPlayInfo.width
|
||||
val offsetY = tileOffsetY * trickPlayInfo.height
|
||||
|
||||
val url = api.trickplayApi.getTrickplayTileImageUrl(
|
||||
itemId = item.id,
|
||||
width = trickPlayInfo.width,
|
||||
index = tileIndex,
|
||||
mediaSourceId = mediaSourceId,
|
||||
)
|
||||
|
||||
imageRequests[index] = imageLoader.enqueue(ImageRequest.Builder(context).apply {
|
||||
data(url)
|
||||
size(Size.ORIGINAL)
|
||||
httpHeaders(NetworkHeaders.Builder().apply {
|
||||
set(
|
||||
key = "Authorization",
|
||||
value = AuthorizationHeaderBuilder.buildHeader(
|
||||
api.clientInfo.name,
|
||||
api.clientInfo.version,
|
||||
api.deviceInfo.id,
|
||||
api.deviceInfo.name,
|
||||
api.accessToken
|
||||
)
|
||||
)
|
||||
}.build())
|
||||
|
||||
transformations(SubsetTransformation(offsetX, offsetY, trickPlayInfo.width, trickPlayInfo.height))
|
||||
|
||||
target(
|
||||
onSuccess = { image ->
|
||||
val bitmap = image.toBitmap()
|
||||
callback.onThumbnailLoaded(bitmap, index)
|
||||
}
|
||||
)
|
||||
}.build())
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
for (request in imageRequests.values) {
|
||||
if (!request.isDisposed) request.dispose()
|
||||
}
|
||||
imageRequests.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,20 @@ package org.jellyfin.androidtv.ui.playback.overlay;
|
||||
import static org.koin.java.KoinJavaComponent.inject;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.leanback.app.PlaybackSupportFragment;
|
||||
|
||||
import org.jellyfin.androidtv.preference.UserPreferences;
|
||||
import org.jellyfin.androidtv.preference.UserSettingPreferences;
|
||||
import org.jellyfin.androidtv.ui.playback.CustomPlaybackOverlayFragment;
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackController;
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer;
|
||||
import org.jellyfin.sdk.api.client.ApiClient;
|
||||
|
||||
import coil3.ImageLoader;
|
||||
import kotlin.Lazy;
|
||||
import timber.log.Timber;
|
||||
|
||||
@@ -18,6 +25,10 @@ public class LeanbackOverlayFragment extends PlaybackSupportFragment {
|
||||
private VideoPlayerAdapter playerAdapter;
|
||||
private boolean shouldShowOverlay = true;
|
||||
private Lazy<PlaybackControllerContainer> playbackControllerContainer = inject(PlaybackControllerContainer.class);
|
||||
private final Lazy<UserSettingPreferences> userSettingPreferences = inject(UserSettingPreferences.class);
|
||||
private Lazy<ImageLoader> imageLoader = inject(ImageLoader.class);
|
||||
private Lazy<ApiClient> api = inject(ApiClient.class);
|
||||
private Lazy<UserPreferences> userPreferences = inject(UserPreferences.class);
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
@@ -36,6 +47,13 @@ public class LeanbackOverlayFragment extends PlaybackSupportFragment {
|
||||
playerGlue.setHost(new CustomPlaybackFragmentGlueHost(this));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
super.hideControlsOverlay(false);
|
||||
}
|
||||
|
||||
public void initFromView(CustomPlaybackOverlayFragment customPlaybackOverlayFragment) {
|
||||
playerGlue.setInitialPlaybackDrawable();
|
||||
playerAdapter.setMasterOverlayFragment(customPlaybackOverlayFragment);
|
||||
@@ -45,9 +63,16 @@ public class LeanbackOverlayFragment extends PlaybackSupportFragment {
|
||||
public void showControlsOverlay(boolean runAnimation) {
|
||||
if (shouldShowOverlay) {
|
||||
super.showControlsOverlay(runAnimation);
|
||||
playerAdapter.getMasterOverlayFragment().show();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hideControlsOverlay(boolean runAnimation) {
|
||||
super.hideControlsOverlay(runAnimation);
|
||||
playerAdapter.getMasterOverlayFragment().hide();
|
||||
}
|
||||
|
||||
public void updateCurrentPosition() {
|
||||
playerAdapter.updateCurrentPosition();
|
||||
updatePlayState();
|
||||
@@ -76,7 +101,10 @@ public class LeanbackOverlayFragment extends PlaybackSupportFragment {
|
||||
|
||||
playerGlue.invalidatePlaybackControls();
|
||||
playerGlue.setSeekEnabled(playerAdapter.canSeek());
|
||||
playerGlue.setSeekProvider(playerAdapter.canSeek() ? new CustomSeekProvider(playerAdapter) : null);
|
||||
|
||||
long skipForwardLength = userSettingPreferences.getValue().get(UserSettingPreferences.Companion.getSkipForwardLength()).longValue();
|
||||
boolean enableTrickPlay = userPreferences.getValue().get(UserPreferences.Companion.getTrickPlayEnabled());
|
||||
playerGlue.setSeekProvider(playerAdapter.canSeek() ? new CustomSeekProvider(playerAdapter, imageLoader.getValue(), api.getValue(), requireContext(), enableTrickPlay, skipForwardLength) : null);
|
||||
recordingStateChanged();
|
||||
playerAdapter.updateDuration();
|
||||
}
|
||||
@@ -88,7 +116,9 @@ public class LeanbackOverlayFragment extends PlaybackSupportFragment {
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
playerAdapter.getMasterOverlayFragment().onPause();
|
||||
if (playerAdapter != null) {
|
||||
playerAdapter.getMasterOverlayFragment().onPause();
|
||||
}
|
||||
}
|
||||
|
||||
public CustomPlaybackTransportControlGlue getPlayerGlue() {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package org.jellyfin.androidtv.ui.playback.overlay
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.AbstractComposeView
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepository
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
fun SkipOverlayComposable(
|
||||
visible: Boolean,
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.BottomEnd,
|
||||
modifier = Modifier
|
||||
.padding(48.dp, 48.dp)
|
||||
) {
|
||||
AnimatedVisibility(visible, enter = fadeIn(), exit = fadeOut()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(colorResource(R.color.popup_menu_background).copy(alpha = 0.6f))
|
||||
.padding(10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_control_select),
|
||||
contentDescription = null,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.segment_action_skip),
|
||||
color = colorResource(R.color.button_default_normal_text),
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SkipOverlayView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyle: Int = 0
|
||||
) : AbstractComposeView(context, attrs, defStyle) {
|
||||
private val _currentPosition = MutableStateFlow(Duration.ZERO)
|
||||
private val _targetPosition = MutableStateFlow<Duration?>(null)
|
||||
private val _skipUiEnabled = MutableStateFlow(true)
|
||||
|
||||
var currentPosition: Duration
|
||||
get() = _currentPosition.value
|
||||
set(value) {
|
||||
_currentPosition.value = value
|
||||
}
|
||||
|
||||
var currentPositionMs: Long
|
||||
get() = _currentPosition.value.inWholeMilliseconds
|
||||
set(value) {
|
||||
_currentPosition.value = value.milliseconds
|
||||
}
|
||||
|
||||
var targetPosition: Duration?
|
||||
get() = _targetPosition.value
|
||||
set(value) {
|
||||
_targetPosition.value = value
|
||||
}
|
||||
|
||||
var targetPositionMs: Long?
|
||||
get() = _targetPosition.value?.inWholeMilliseconds
|
||||
set(value) {
|
||||
_targetPosition.value = value?.milliseconds
|
||||
}
|
||||
|
||||
var skipUiEnabled: Boolean
|
||||
get() = _skipUiEnabled.value
|
||||
set(value) {
|
||||
_skipUiEnabled.value = value
|
||||
}
|
||||
|
||||
val visible: Boolean
|
||||
get() {
|
||||
val enabled = _skipUiEnabled.value
|
||||
val targetPosition = _targetPosition.value
|
||||
val currentPosition = _currentPosition.value
|
||||
|
||||
return enabled && targetPosition != null && currentPosition <= (targetPosition - MediaSegmentRepository.SkipMinDuration)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val skipUiEnabled by _skipUiEnabled.collectAsState()
|
||||
val currentPosition by _currentPosition.collectAsState()
|
||||
val targetPosition by _targetPosition.collectAsState()
|
||||
|
||||
val visible by remember(skipUiEnabled, currentPosition, targetPosition) {
|
||||
derivedStateOf { visible }
|
||||
}
|
||||
|
||||
// Auto hide
|
||||
LaunchedEffect(skipUiEnabled, targetPosition) {
|
||||
delay(MediaSegmentRepository.AskToSkipAutoHideDuration)
|
||||
_targetPosition.value = null
|
||||
}
|
||||
|
||||
SkipOverlayComposable(visible)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import org.jellyfin.androidtv.ui.playback.PlaybackController;
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
import org.jellyfin.androidtv.util.apiclient.StreamHelper;
|
||||
import org.jellyfin.sdk.model.api.ChapterInfo;
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo;
|
||||
import org.koin.java.KoinJavaComponent;
|
||||
|
||||
import java.util.List;
|
||||
@@ -164,6 +165,10 @@ public class VideoPlayerAdapter extends PlayerAdapter {
|
||||
return playbackController.getCurrentlyPlayingItem();
|
||||
}
|
||||
|
||||
MediaSourceInfo getCurrentMediaSource() {
|
||||
return playbackController.getCurrentMediaSource();
|
||||
}
|
||||
|
||||
boolean hasChapters() {
|
||||
org.jellyfin.sdk.model.api.BaseItemDto item = getCurrentlyPlayingItem();
|
||||
List<ChapterInfo> chapters = item.getChapters();
|
||||
|
||||
@@ -8,8 +8,9 @@ import android.widget.Toast
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackController
|
||||
import org.jellyfin.androidtv.ui.playback.overlay.CustomPlaybackTransportControlGlue
|
||||
import org.jellyfin.androidtv.ui.playback.overlay.LeanbackOverlayFragment
|
||||
import org.jellyfin.androidtv.ui.playback.overlay.VideoPlayerAdapter
|
||||
import org.jellyfin.androidtv.ui.playback.setSubtitleIndex
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import timber.log.Timber
|
||||
|
||||
class ClosedCaptionsAction(
|
||||
@@ -35,21 +36,24 @@ class ClosedCaptionsAction(
|
||||
videoPlayerAdapter.leanbackOverlayFragment.setFading(false)
|
||||
PopupMenu(context, view, Gravity.END).apply {
|
||||
with(menu) {
|
||||
for (sub in playbackController.subtitleStreams) {
|
||||
add(0, sub.index, sub.index, sub.displayTitle).apply {
|
||||
isChecked = sub.index == playbackController.subtitleStreamIndex
|
||||
}
|
||||
var order = 0
|
||||
add(0, -1, order++, context.getString(R.string.lbl_none)).apply {
|
||||
isChecked = playbackController.subtitleStreamIndex == -1
|
||||
}
|
||||
|
||||
add(0, -1, 0, context.getString(R.string.lbl_none)).apply {
|
||||
isChecked = playbackController.subtitleStreamIndex == -1
|
||||
for (sub in playbackController.currentMediaSource.mediaStreams.orEmpty()) {
|
||||
if (sub.type != MediaStreamType.SUBTITLE) continue
|
||||
|
||||
add(0, sub.index, order++, sub.displayTitle).apply {
|
||||
isChecked = sub.index == playbackController.subtitleStreamIndex
|
||||
}
|
||||
}
|
||||
|
||||
setGroupCheckable(0, true, false)
|
||||
}
|
||||
setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
|
||||
setOnMenuItemClickListener { item ->
|
||||
playbackController.switchSubtitleStream(item.itemId)
|
||||
playbackController.setSubtitleIndex(item.itemId)
|
||||
true
|
||||
}
|
||||
}.show()
|
||||
|
||||
@@ -10,7 +10,6 @@ import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackController
|
||||
import org.jellyfin.androidtv.ui.playback.VideoQualityController
|
||||
import org.jellyfin.androidtv.ui.playback.overlay.CustomPlaybackTransportControlGlue
|
||||
import org.jellyfin.androidtv.ui.playback.overlay.LeanbackOverlayFragment
|
||||
import org.jellyfin.androidtv.ui.playback.overlay.VideoPlayerAdapter
|
||||
|
||||
class SelectQualityAction(
|
||||
@@ -39,8 +38,11 @@ class SelectQualityAction(
|
||||
}
|
||||
|
||||
menu.setGroupCheckable(0, true, true)
|
||||
menu.getItem(qualityProfiles.keys.indexOf(qualityController.currentQuality))?.let { item ->
|
||||
item.isChecked = true
|
||||
val currentQualityIndex = qualityProfiles.keys.indexOf(qualityController.currentQuality)
|
||||
if (currentQualityIndex != -1) {
|
||||
menu.getItem(currentQualityIndex)?.let { item ->
|
||||
item.isChecked = true
|
||||
}
|
||||
}
|
||||
|
||||
setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
|
||||
|
||||
@@ -3,13 +3,12 @@ package org.jellyfin.androidtv.ui.playback.overlay.action
|
||||
import android.content.Context
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.PopupMenu
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.preference.constant.ZoomMode
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackController
|
||||
import org.jellyfin.androidtv.ui.playback.VideoManager
|
||||
import org.jellyfin.androidtv.ui.playback.overlay.CustomPlaybackTransportControlGlue
|
||||
import org.jellyfin.androidtv.ui.playback.overlay.LeanbackOverlayFragment
|
||||
import org.jellyfin.androidtv.ui.playback.overlay.VideoPlayerAdapter
|
||||
import org.jellyfin.androidtv.util.popupMenu
|
||||
|
||||
class ZoomAction(
|
||||
context: Context,
|
||||
@@ -26,43 +25,27 @@ class ZoomAction(
|
||||
view: View,
|
||||
) {
|
||||
videoPlayerAdapter.leanbackOverlayFragment.setFading(false)
|
||||
return PopupMenu(context, view, Gravity.END).apply {
|
||||
with(menu) {
|
||||
add(
|
||||
0,
|
||||
VideoManager.ZOOM_AUTO_CROP,
|
||||
VideoManager.ZOOM_AUTO_CROP,
|
||||
context.getString(R.string.lbl_auto_crop)
|
||||
).apply {
|
||||
isChecked = playbackController.zoomMode == VideoManager.ZOOM_AUTO_CROP
|
||||
}
|
||||
|
||||
add(
|
||||
0,
|
||||
VideoManager.ZOOM_FIT,
|
||||
VideoManager.ZOOM_FIT,
|
||||
context.getString(R.string.lbl_fit)
|
||||
).apply {
|
||||
isChecked = playbackController.zoomMode == VideoManager.ZOOM_FIT
|
||||
}
|
||||
|
||||
add(
|
||||
0,
|
||||
VideoManager.ZOOM_STRETCH,
|
||||
VideoManager.ZOOM_STRETCH,
|
||||
context.getString(R.string.lbl_stretch)
|
||||
).apply {
|
||||
isChecked = playbackController.zoomMode == VideoManager.ZOOM_STRETCH
|
||||
}
|
||||
|
||||
setGroupCheckable(0, true, true)
|
||||
val popup = popupMenu(context, view, Gravity.END) {
|
||||
item(context.getString(R.string.lbl_fit)) {
|
||||
playbackController.setZoom(ZoomMode.FIT)
|
||||
}.apply {
|
||||
isChecked = playbackController.zoomMode == ZoomMode.FIT
|
||||
}
|
||||
|
||||
setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
|
||||
setOnMenuItemClickListener { item ->
|
||||
playbackController.setZoom(item.itemId)
|
||||
true
|
||||
item(context.getString(R.string.lbl_auto_crop)) {
|
||||
playbackController.setZoom(ZoomMode.AUTO_CROP)
|
||||
}.apply {
|
||||
isChecked = playbackController.zoomMode == ZoomMode.AUTO_CROP
|
||||
}
|
||||
}.show()
|
||||
|
||||
item(context.getString(R.string.lbl_stretch)) {
|
||||
playbackController.setZoom(ZoomMode.STRETCH)
|
||||
}.apply {
|
||||
isChecked = playbackController.zoomMode == ZoomMode.STRETCH
|
||||
}
|
||||
}
|
||||
popup.menu.setGroupCheckable(0, true, true)
|
||||
popup.setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
|
||||
popup.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.jellyfin.androidtv.ui.ScreensaverViewModel
|
||||
import org.jellyfin.androidtv.ui.playback.VideoQueueManager
|
||||
import org.jellyfin.playback.core.PlaybackManager
|
||||
import org.jellyfin.playback.core.model.PlayState
|
||||
import org.jellyfin.playback.core.queue.queue
|
||||
import org.jellyfin.playback.core.ui.PlayerSubtitleView
|
||||
import org.jellyfin.playback.core.ui.PlayerSurfaceView
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
@@ -43,17 +44,18 @@ class PlaybackRewriteFragment : Fragment() {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Create a queue from the items added to the legacy video queue
|
||||
val queue = RewriteMediaManager.BaseItemQueue(api)
|
||||
queue.items.addAll(videoQueueManager.getCurrentVideoQueue())
|
||||
Timber.i("Created a queue with ${queue.items.size} items")
|
||||
playbackManager.state.queue.replaceQueue(queue)
|
||||
val queueSupplier = RewriteMediaManager.BaseItemQueueSupplier(api)
|
||||
queueSupplier.items.addAll(videoQueueManager.getCurrentVideoQueue())
|
||||
Timber.i("Created a queue with ${queueSupplier.items.size} items")
|
||||
playbackManager.queue.clear()
|
||||
playbackManager.queue.addSupplier(queueSupplier)
|
||||
|
||||
// Set position
|
||||
val position = arguments?.getInt(EXTRA_POSITION) ?: 0
|
||||
if (position != 0) {
|
||||
lifecycleScope.launch {
|
||||
Timber.i("Skipping to queue item $position")
|
||||
playbackManager.state.queue.setIndex(position, false)
|
||||
playbackManager.queue.setIndex(position, false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,9 @@ import org.jellyfin.playback.core.PlaybackManager
|
||||
import org.jellyfin.playback.core.model.PlayState
|
||||
import org.jellyfin.playback.core.model.PlaybackOrder
|
||||
import org.jellyfin.playback.core.model.RepeatMode
|
||||
import org.jellyfin.playback.core.queue.Queue
|
||||
import org.jellyfin.playback.core.queue.QueueEntry
|
||||
import org.jellyfin.playback.core.queue.queue
|
||||
import org.jellyfin.playback.core.queue.supplier.QueueSupplier
|
||||
import org.jellyfin.playback.jellyfin.queue.baseItem
|
||||
import org.jellyfin.playback.jellyfin.queue.createBaseItemQueueEntry
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
@@ -39,7 +40,7 @@ class RewriteMediaManager(
|
||||
private val navigationRepository: NavigationRepository,
|
||||
private val playbackManager: PlaybackManager,
|
||||
) : MediaManager {
|
||||
private val queue = BaseItemQueue(api)
|
||||
private val queueSupplier = BaseItemQueueSupplier(api)
|
||||
|
||||
override fun hasAudioQueueItems(): Boolean = currentAudioQueue.size() > 0 && currentAudioItem != null
|
||||
|
||||
@@ -47,20 +48,19 @@ class RewriteMediaManager(
|
||||
get() = currentAudioQueue.size()
|
||||
|
||||
override val currentAudioQueuePosition: Int
|
||||
get() = if ((playbackManager.state.queue.entryIndex.value) >= 0) 0 else -1
|
||||
get() = if ((playbackManager.queue.entryIndex.value) >= 0) 0 else -1
|
||||
|
||||
override val currentAudioPosition: Long
|
||||
get() = playbackManager.state.positionInfo.active.inWholeMilliseconds
|
||||
|
||||
override val currentAudioQueueDisplayPosition: String
|
||||
get() = (currentAudioQueuePosition + 1).toString()
|
||||
get() = (playbackManager.queue.entryIndex.value + 1).toString()
|
||||
|
||||
override val currentAudioQueueDisplaySize: String
|
||||
get() = ((playbackManager.state.queue.current.value as? BaseItemQueue)?.items?.size
|
||||
?: currentAudioQueue.size()).toString()
|
||||
get() = playbackManager.queue.estimatedSize.toString()
|
||||
|
||||
override val currentAudioItem: BaseItemDto?
|
||||
get() = playbackManager.state.queue.entry.value?.baseItem
|
||||
get() = playbackManager.queue.entry.value?.baseItem
|
||||
?.takeIf { it.mediaType == MediaType.AUDIO }
|
||||
|
||||
override fun toggleRepeat(): Boolean {
|
||||
@@ -108,12 +108,14 @@ class RewriteMediaManager(
|
||||
val firstItem = currentAudioQueue.get(0) as? AudioQueueBaseRowItem
|
||||
firstItem?.playing = playState == PlayState.PLAYING
|
||||
|
||||
onPlaybackStateChange(when (playState) {
|
||||
PlayState.STOPPED -> PlaybackController.PlaybackState.IDLE
|
||||
PlayState.PLAYING -> PlaybackController.PlaybackState.PLAYING
|
||||
PlayState.PAUSED -> PlaybackController.PlaybackState.PAUSED
|
||||
PlayState.ERROR -> PlaybackController.PlaybackState.ERROR
|
||||
}, currentAudioItem)
|
||||
onPlaybackStateChange(
|
||||
when (playState) {
|
||||
PlayState.STOPPED -> PlaybackController.PlaybackState.IDLE
|
||||
PlayState.PLAYING -> PlaybackController.PlaybackState.PLAYING
|
||||
PlayState.PAUSED -> PlaybackController.PlaybackState.PAUSED
|
||||
PlayState.ERROR -> PlaybackController.PlaybackState.ERROR
|
||||
}, currentAudioItem
|
||||
)
|
||||
}
|
||||
}.launchIn(this)
|
||||
|
||||
@@ -126,27 +128,28 @@ class RewriteMediaManager(
|
||||
}
|
||||
}
|
||||
|
||||
playbackManager.state.queue.current.onEach {
|
||||
playbackManager.queue.entry.onEach { entry ->
|
||||
val baseItem = entry?.baseItem
|
||||
notifyListeners {
|
||||
onQueueStatusChanged(hasAudioQueueItems())
|
||||
onQueueStatusChanged(baseItem?.mediaType == MediaType.AUDIO)
|
||||
}
|
||||
}.launchIn(this)
|
||||
|
||||
playbackManager.state.queue.entry.onEach { updateAdapter() }.launchIn(this)
|
||||
playbackManager.queue.entry.onEach { updateAdapter() }.launchIn(this)
|
||||
}
|
||||
|
||||
private fun updateAdapter() {
|
||||
// Get all items as BaseRowItem
|
||||
val items = queue
|
||||
val items = queueSupplier
|
||||
.items
|
||||
// Map to audio queue items
|
||||
.mapIndexed { index, item ->
|
||||
AudioQueueBaseRowItem(item).apply {
|
||||
playing = playbackManager.state.queue.entryIndex.value == index
|
||||
playing = playbackManager.queue.entryIndex.value == index
|
||||
}
|
||||
}
|
||||
// Remove items before currently playing item
|
||||
.drop(max(0, playbackManager.state.queue.entryIndex.value))
|
||||
.drop(max(0, playbackManager.queue.entryIndex.value))
|
||||
|
||||
// Update item row
|
||||
currentAudioQueue.replaceAll(
|
||||
@@ -185,32 +188,26 @@ class RewriteMediaManager(
|
||||
override fun addToAudioQueue(items: List<BaseItemDto>) {
|
||||
if (items.isEmpty()) return
|
||||
|
||||
val addIndex = when (playbackManager.state.playState.value) {
|
||||
PlayState.PLAYING -> playbackManager.state.queue.entryIndex.value + 1
|
||||
else -> 0
|
||||
}
|
||||
queueSupplier.items.addAll(items)
|
||||
|
||||
queue.items.addAll(addIndex, items)
|
||||
|
||||
if (
|
||||
playbackManager.state.queue.current.value != queue ||
|
||||
playbackManager.state.playState.value != PlayState.PLAYING
|
||||
) {
|
||||
if (playbackManager.state.playState.value != PlayState.PLAYING) {
|
||||
playbackManager.state.setPlaybackOrder(if (isShuffleMode) PlaybackOrder.SHUFFLE else PlaybackOrder.DEFAULT)
|
||||
playbackManager.state.play(queue)
|
||||
playbackManager.queue.clear()
|
||||
playbackManager.queue.addSupplier(queueSupplier)
|
||||
playbackManager.state.play()
|
||||
}
|
||||
|
||||
updateAdapter()
|
||||
}
|
||||
|
||||
override fun removeFromAudioQueue(item: BaseItemDto) {
|
||||
val index = queue.items.indexOf(item)
|
||||
val index = queueSupplier.items.indexOf(item)
|
||||
if (index == -1) return
|
||||
|
||||
// Disallow removing currently playing item (legacy UI cannot keep up)
|
||||
if (playbackManager.state.queue.entryIndex.value == index) return
|
||||
if (playbackManager.queue.entryIndex.value == index) return
|
||||
|
||||
queue.items.removeAt(index)
|
||||
queueSupplier.items.removeAt(index)
|
||||
updateAdapter()
|
||||
}
|
||||
|
||||
@@ -219,19 +216,21 @@ class RewriteMediaManager(
|
||||
|
||||
override fun playNow(context: Context, items: List<BaseItemDto>, position: Int, shuffle: Boolean) {
|
||||
val filteredItems = items.drop(position)
|
||||
queue.items.clear()
|
||||
queue.items.addAll(filteredItems)
|
||||
queueSupplier.items.clear()
|
||||
queueSupplier.items.addAll(filteredItems)
|
||||
playbackManager.state.setPlaybackOrder(if (shuffle) PlaybackOrder.SHUFFLE else PlaybackOrder.DEFAULT)
|
||||
playbackManager.state.play(queue)
|
||||
playbackManager.queue.clear()
|
||||
playbackManager.queue.addSupplier(queueSupplier)
|
||||
playbackManager.state.play()
|
||||
|
||||
navigationRepository.navigate(Destinations.nowPlaying)
|
||||
}
|
||||
|
||||
override fun playFrom(item: BaseItemDto): Boolean {
|
||||
val index = queue.items.indexOf(item)
|
||||
val index = queueSupplier.items.indexOf(item)
|
||||
if (index == -1) return false
|
||||
return runBlocking {
|
||||
playbackManager.state.queue.setIndex(index) != null
|
||||
playbackManager.queue.setIndex(index) != null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,23 +244,23 @@ class RewriteMediaManager(
|
||||
}
|
||||
|
||||
override fun hasNextAudioItem(): Boolean = runBlocking {
|
||||
playbackManager.state.queue.peekNext() != null
|
||||
playbackManager.queue.peekNext() != null
|
||||
}
|
||||
|
||||
override fun hasPrevAudioItem(): Boolean = playbackManager.state.queue.entryIndex.value > 0
|
||||
override fun hasPrevAudioItem(): Boolean = playbackManager.queue.entryIndex.value > 0
|
||||
|
||||
override fun nextAudioItem(): Int {
|
||||
runBlocking { playbackManager.state.queue.next() }
|
||||
runBlocking { playbackManager.queue.next() }
|
||||
notifyListeners { onQueueStatusChanged(hasAudioQueueItems()) }
|
||||
|
||||
return playbackManager.state.queue.entryIndex.value
|
||||
return playbackManager.queue.entryIndex.value
|
||||
}
|
||||
|
||||
override fun prevAudioItem(): Int {
|
||||
runBlocking { playbackManager.state.queue.previous() }
|
||||
runBlocking { playbackManager.queue.previous() }
|
||||
notifyListeners { onQueueStatusChanged(hasAudioQueueItems()) }
|
||||
|
||||
return playbackManager.state.queue.entryIndex.value
|
||||
return playbackManager.queue.entryIndex.value
|
||||
}
|
||||
|
||||
override fun stopAudio(releasePlayer: Boolean) {
|
||||
@@ -275,12 +274,12 @@ class RewriteMediaManager(
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple [Queue] implementation for compatibility with existing UI/playback code. It contains
|
||||
* A simple [QueueSupplier] implementation for compatibility with existing UI/playback code. It contains
|
||||
* a mutable BaseItemDto list that is used to retrieve items from.
|
||||
*/
|
||||
class BaseItemQueue(
|
||||
class BaseItemQueueSupplier(
|
||||
private val api: ApiClient,
|
||||
) : Queue {
|
||||
) : QueueSupplier {
|
||||
val items = mutableListOf<BaseItemDto>()
|
||||
|
||||
override val size: Int
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.jellyfin.androidtv.ui.playback.segment
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
|
||||
enum class MediaSegmentAction(
|
||||
override val nameRes: Int,
|
||||
) : PreferenceEnum {
|
||||
/**
|
||||
* Don't take any action for this segment.
|
||||
*/
|
||||
NOTHING(R.string.segment_action_nothing),
|
||||
|
||||
/**
|
||||
* Seek to the end of this segment (endTicks). If the duration of this segment is shorter than 1 second it should do nothing to avoid
|
||||
* lagg. The skip action will only execute when playing over the segment start, not when seeking into the segment block.
|
||||
*/
|
||||
SKIP(R.string.segment_action_skip),
|
||||
|
||||
/**
|
||||
* Ask the user if they want to skip this segment. When the user agrees this behaves like [SKIP]. Confirmation should only be asked for
|
||||
* segments with a duration of at least 3 seconds to avoid UI flickering.
|
||||
*/
|
||||
ASK_TO_SKIP(R.string.segment_action_ask_to_skip),
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package org.jellyfin.androidtv.ui.playback.segment
|
||||
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.util.sdk.duration
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
interface MediaSegmentRepository {
|
||||
companion object {
|
||||
/**
|
||||
* All media segments currently supported by the app. The order of these is used for the preferences UI.
|
||||
*/
|
||||
val SupportedTypes = listOf(
|
||||
MediaSegmentType.INTRO,
|
||||
MediaSegmentType.OUTRO,
|
||||
MediaSegmentType.PREVIEW,
|
||||
MediaSegmentType.RECAP,
|
||||
MediaSegmentType.COMMERCIAL,
|
||||
)
|
||||
|
||||
/**
|
||||
* The minimum duration for a media segment to allow the [MediaSegmentAction.SKIP] action.
|
||||
*/
|
||||
val SkipMinDuration = 1.seconds
|
||||
|
||||
/**
|
||||
* The minimum duration for a media segment to allow the [MediaSegmentAction.ASK_TO_SKIP] action.
|
||||
*/
|
||||
val AskToSkipMinDuration = 3.seconds
|
||||
|
||||
/**
|
||||
* The duration to wait before automatically hiding the "ask to skip" UI.
|
||||
*/
|
||||
val AskToSkipAutoHideDuration = 8.seconds
|
||||
}
|
||||
|
||||
fun getDefaultSegmentTypeAction(type: MediaSegmentType): MediaSegmentAction
|
||||
fun setDefaultSegmentTypeAction(type: MediaSegmentType, action: MediaSegmentAction)
|
||||
|
||||
suspend fun getSegmentsForItem(item: BaseItemDto): List<MediaSegmentDto>
|
||||
fun getMediaSegmentAction(segment: MediaSegmentDto): MediaSegmentAction
|
||||
}
|
||||
|
||||
fun Map<MediaSegmentType, MediaSegmentAction>.toMediaSegmentActionsString() =
|
||||
map { "${it.key.serialName}=${it.value.name}" }
|
||||
.joinToString(",")
|
||||
|
||||
class MediaSegmentRepositoryImpl(
|
||||
private val userPreferences: UserPreferences,
|
||||
private val api: ApiClient,
|
||||
) : MediaSegmentRepository {
|
||||
private val mediaTypeActions = mutableMapOf<MediaSegmentType, MediaSegmentAction>()
|
||||
|
||||
init {
|
||||
restoreMediaTypeActions()
|
||||
}
|
||||
|
||||
private fun restoreMediaTypeActions() {
|
||||
val restoredMediaTypeActions = userPreferences[UserPreferences.mediaSegmentActions]
|
||||
.split(",")
|
||||
.mapNotNull {
|
||||
runCatching {
|
||||
val (type, action) = it.split('=', limit = 2)
|
||||
MediaSegmentType.fromName(type) to MediaSegmentAction.valueOf(action)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
mediaTypeActions.clear()
|
||||
mediaTypeActions.putAll(restoredMediaTypeActions)
|
||||
}
|
||||
|
||||
private fun saveMediaTypeActions() {
|
||||
userPreferences[UserPreferences.mediaSegmentActions] = mediaTypeActions.toMediaSegmentActionsString()
|
||||
}
|
||||
|
||||
override fun getDefaultSegmentTypeAction(type: MediaSegmentType): MediaSegmentAction {
|
||||
// Always return no action for unsupported types
|
||||
if (!MediaSegmentRepository.SupportedTypes.contains(type)) return MediaSegmentAction.NOTHING
|
||||
|
||||
return mediaTypeActions.getOrDefault(type, MediaSegmentAction.NOTHING)
|
||||
}
|
||||
|
||||
override fun setDefaultSegmentTypeAction(type: MediaSegmentType, action: MediaSegmentAction) {
|
||||
// Don't allow modifying actions for unsupported types
|
||||
if (!MediaSegmentRepository.SupportedTypes.contains(type)) return
|
||||
|
||||
mediaTypeActions[type] = action
|
||||
saveMediaTypeActions()
|
||||
}
|
||||
|
||||
override fun getMediaSegmentAction(segment: MediaSegmentDto): MediaSegmentAction {
|
||||
val action = getDefaultSegmentTypeAction(segment.type)
|
||||
// Skip the skip action if timespan is too short
|
||||
if (action == MediaSegmentAction.SKIP && segment.duration < MediaSegmentRepository.SkipMinDuration) return MediaSegmentAction.NOTHING
|
||||
// Skip the ask to skip action if timespan is too short
|
||||
if (action == MediaSegmentAction.ASK_TO_SKIP && segment.duration < MediaSegmentRepository.AskToSkipMinDuration) return MediaSegmentAction.NOTHING
|
||||
return action
|
||||
}
|
||||
|
||||
override suspend fun getSegmentsForItem(item: BaseItemDto): List<MediaSegmentDto> = runCatching {
|
||||
api.mediaSegmentsApi.getItemSegments(
|
||||
itemId = item.id,
|
||||
includeSegmentTypes = MediaSegmentRepository.SupportedTypes,
|
||||
).content.items
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import java.util.UUID
|
||||
class OptionsItemSeekbar(
|
||||
private val context: Context
|
||||
) : OptionsItemMutable<Int>() {
|
||||
|
||||
var content: String? = null
|
||||
var min: Int = 0
|
||||
var max: Int = 100
|
||||
@@ -32,8 +31,9 @@ class OptionsItemSeekbar(
|
||||
it.isEnabled = dependencyCheckFun() && enabled
|
||||
it.isVisible = visible
|
||||
it.title = title
|
||||
it.min = min
|
||||
// Max must be set before min because the setter of min checks if the value is below the current value of max
|
||||
it.max = max
|
||||
it.min = min
|
||||
it.seekBarIncrement = increment
|
||||
it.value = binder.get()
|
||||
it.showSeekBarValue = true
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package org.jellyfin.androidtv.ui.preference.screen
|
||||
|
||||
import android.text.format.Formatter
|
||||
import coil.ImageLoader
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil3.ImageLoader
|
||||
import coil3.annotation.ExperimentalCoilApi
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.preference.SystemPreferences
|
||||
@@ -39,12 +39,6 @@ class DeveloperPreferencesScreen : OptionsFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
checkbox {
|
||||
setTitle(R.string.enable_reactive_homepage)
|
||||
setContent(R.string.enable_playback_module_description)
|
||||
bind(userPreferences, UserPreferences.homeReactive)
|
||||
}
|
||||
|
||||
// Only show in debug mode
|
||||
// some strings are hardcoded because these options don't show in beta/release builds
|
||||
if (BuildConfig.DEVELOPMENT) {
|
||||
@@ -56,6 +50,20 @@ class DeveloperPreferencesScreen : OptionsFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
checkbox {
|
||||
setTitle(R.string.preference_enable_trickplay)
|
||||
setContent(R.string.enable_playback_module_description)
|
||||
|
||||
bind(userPreferences, UserPreferences.trickPlayEnabled)
|
||||
}
|
||||
|
||||
checkbox {
|
||||
setTitle(R.string.prefer_exoplayer_ffmpeg)
|
||||
setContent(R.string.prefer_exoplayer_ffmpeg_content)
|
||||
|
||||
bind(userPreferences, UserPreferences.preferExoPlayerFfmpeg)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
action {
|
||||
setTitle(R.string.clear_image_cache)
|
||||
|
||||
@@ -21,15 +21,7 @@ class HomePreferencesScreen : OptionsFragment() {
|
||||
category {
|
||||
setTitle(R.string.home_sections)
|
||||
|
||||
arrayOf(
|
||||
UserSettingPreferences.homesection0,
|
||||
UserSettingPreferences.homesection1,
|
||||
UserSettingPreferences.homesection2,
|
||||
UserSettingPreferences.homesection3,
|
||||
UserSettingPreferences.homesection4,
|
||||
UserSettingPreferences.homesection5,
|
||||
UserSettingPreferences.homesection6,
|
||||
).forEachIndexed { index, section ->
|
||||
userSettingPreferences.homesections.forEachIndexed { index, section ->
|
||||
enum<HomeSectionType> {
|
||||
title = getString(R.string.home_section_i, index + 1)
|
||||
bind(userSettingPreferences, section)
|
||||
|
||||
@@ -5,11 +5,14 @@ import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.constant.getQualityProfiles
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.ZoomMode
|
||||
import org.jellyfin.androidtv.ui.preference.custom.DurationSeekBarPreference
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.OptionsFragment
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.checkbox
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.enum
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.list
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.optionsScreen
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.seekbar
|
||||
import org.jellyfin.androidtv.util.TimeUtils
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
@@ -60,6 +63,27 @@ class PlaybackAdvancedPreferencesScreen : OptionsFragment() {
|
||||
bind(userPreferences, UserPreferences.refreshRateSwitchingBehavior)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
seekbar {
|
||||
setTitle(R.string.video_start_delay)
|
||||
min = 0
|
||||
max = 5_000
|
||||
increment = 250
|
||||
valueFormatter = object : DurationSeekBarPreference.ValueFormatter() {
|
||||
override fun display(value: Int): String = "${value.toDouble() / 1000}s"
|
||||
}
|
||||
bind {
|
||||
get { userPreferences[UserPreferences.videoStartDelay].toInt() }
|
||||
set { value -> userPreferences[UserPreferences.videoStartDelay] = value.toLong() }
|
||||
default { UserPreferences.videoStartDelay.defaultValue.toInt() }
|
||||
}
|
||||
}
|
||||
|
||||
enum<ZoomMode> {
|
||||
setTitle(R.string.default_video_zoom)
|
||||
bind(userPreferences, UserPreferences.playerZoomMode)
|
||||
}
|
||||
|
||||
checkbox{
|
||||
setTitle(R.string.pref_external_player)
|
||||
bind(userPreferences, UserPreferences.useExternalPlayer)
|
||||
|
||||
@@ -2,9 +2,12 @@ package org.jellyfin.androidtv.ui.preference.screen
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.preference.UserSettingPreferences
|
||||
import org.jellyfin.androidtv.preference.constant.AudioBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.NEXTUP_TIMER_DISABLED
|
||||
import org.jellyfin.androidtv.preference.constant.NextUpBehavior
|
||||
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentAction
|
||||
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepository
|
||||
import org.jellyfin.androidtv.ui.preference.custom.DurationSeekBarPreference
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.OptionsFragment
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.checkbox
|
||||
@@ -13,10 +16,18 @@ import org.jellyfin.androidtv.ui.preference.dsl.enum
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.link
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.optionsScreen
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.seekbar
|
||||
import org.jellyfin.preference.store.PreferenceStore
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||
import org.koin.android.ext.android.inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class PlaybackPreferencesScreen : OptionsFragment() {
|
||||
private val userPreferences: UserPreferences by inject()
|
||||
private val userSettingPreferences: UserSettingPreferences by inject()
|
||||
private val mediaSegmentRepository: MediaSegmentRepository by inject()
|
||||
|
||||
override val stores: Array<PreferenceStore<*, *>>
|
||||
get() = arrayOf(userSettingPreferences)
|
||||
|
||||
override val screen by optionsScreen {
|
||||
setTitle(R.string.pref_playback)
|
||||
@@ -55,6 +66,19 @@ class PlaybackPreferencesScreen : OptionsFragment() {
|
||||
setContent(R.string.sum_enable_cinema_mode)
|
||||
bind(userPreferences, UserPreferences.cinemaModeEnabled)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
seekbar {
|
||||
setTitle(R.string.skip_forward_length)
|
||||
setContent(R.string.skip_forward_length)
|
||||
min = 5_000
|
||||
max = 30_000
|
||||
increment = 5_000
|
||||
valueFormatter = object : DurationSeekBarPreference.ValueFormatter() {
|
||||
override fun display(value: Int) = "${value / 1000}s"
|
||||
}
|
||||
bind(userSettingPreferences, UserSettingPreferences.skipForwardLength)
|
||||
}
|
||||
}
|
||||
|
||||
category {
|
||||
@@ -69,53 +93,96 @@ class PlaybackPreferencesScreen : OptionsFragment() {
|
||||
category {
|
||||
setTitle(R.string.pref_subtitles)
|
||||
|
||||
checkbox {
|
||||
setTitle(R.string.pref_subtitles_background_title)
|
||||
setContent(R.string.pref_subtitles_background_summary)
|
||||
bind(userPreferences, UserPreferences.subtitlesBackgroundEnabled)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
colorList {
|
||||
setTitle(R.string.lbl_subtitle_fg)
|
||||
setTitle(R.string.lbl_subtitle_text_color)
|
||||
entries = mapOf(
|
||||
0xFFFFFFFF to context.getString(R.string.color_white),
|
||||
0XFF000000 to context.getString(R.string.color_black),
|
||||
0xFF7F7F7F to context.getString(R.string.color_darkgrey),
|
||||
0xFFC80000 to context.getString(R.string.color_red),
|
||||
0xFF00C800 to context.getString(R.string.color_green),
|
||||
0xFF0000C8 to context.getString(R.string.color_blue),
|
||||
0xFFEEDC00 to context.getString(R.string.color_yellow),
|
||||
0xFFD60080 to context.getString(R.string.color_pink),
|
||||
0xFF009FDA to context.getString(R.string.color_cyan),
|
||||
0xFFFFFFFFL to context.getString(R.string.color_white),
|
||||
0XFF000000L to context.getString(R.string.color_black),
|
||||
0xFF7F7F7FL to context.getString(R.string.color_darkgrey),
|
||||
0xFFC80000L to context.getString(R.string.color_red),
|
||||
0xFF00C800L to context.getString(R.string.color_green),
|
||||
0xFF0000C8L to context.getString(R.string.color_blue),
|
||||
0xFFEEDC00L to context.getString(R.string.color_yellow),
|
||||
0xFFD60080L to context.getString(R.string.color_pink),
|
||||
0xFF009FDAL to context.getString(R.string.color_cyan),
|
||||
)
|
||||
bind(userPreferences, UserPreferences.subtitlesTextColor)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
seekbar {
|
||||
setTitle(R.string.lbl_subtitle_stroke)
|
||||
min = 0
|
||||
max = 24
|
||||
increment = 2
|
||||
bind(userPreferences, UserPreferences.subtitleStrokeSize)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
seekbar {
|
||||
setTitle(R.string.lbl_subtitle_position)
|
||||
min = 0
|
||||
max = 300
|
||||
increment = 10
|
||||
bind(userPreferences, UserPreferences.subtitlePosition)
|
||||
colorList {
|
||||
setTitle(R.string.lbl_subtitle_background_color)
|
||||
entries = mapOf(
|
||||
0x00FFFFFFL to context.getString(R.string.lbl_none),
|
||||
0xFFFFFFFFL to context.getString(R.string.color_white),
|
||||
0XFF000000L to context.getString(R.string.color_black),
|
||||
0xFF7F7F7FL to context.getString(R.string.color_darkgrey),
|
||||
0xFFC80000L to context.getString(R.string.color_red),
|
||||
0xFF00C800L to context.getString(R.string.color_green),
|
||||
0xFF0000C8L to context.getString(R.string.color_blue),
|
||||
0xFFEEDC00L to context.getString(R.string.color_yellow),
|
||||
0xFFD60080L to context.getString(R.string.color_pink),
|
||||
0xFF009FDAL to context.getString(R.string.color_cyan),
|
||||
)
|
||||
bind(userPreferences, UserPreferences.subtitlesBackgroundColor)
|
||||
}
|
||||
|
||||
colorList {
|
||||
setTitle(R.string.lbl_subtitle_text_stroke_color)
|
||||
entries = mapOf(
|
||||
0x00FFFFFFL to context.getString(R.string.lbl_none),
|
||||
0xFFFFFFFFL to context.getString(R.string.color_white),
|
||||
0XFF000000L to context.getString(R.string.color_black),
|
||||
0xFF7F7F7FL to context.getString(R.string.color_darkgrey),
|
||||
0xFFC80000L to context.getString(R.string.color_red),
|
||||
0xFF00C800L to context.getString(R.string.color_green),
|
||||
0xFF0000C8L to context.getString(R.string.color_blue),
|
||||
0xFFEEDC00L to context.getString(R.string.color_yellow),
|
||||
0xFFD60080L to context.getString(R.string.color_pink),
|
||||
0xFF009FDAL to context.getString(R.string.color_cyan),
|
||||
)
|
||||
bind(userPreferences, UserPreferences.subtitleTextStrokeColor)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
// Stored in floats (1f = 100%) but seekbar preference works with integers only
|
||||
seekbar {
|
||||
setTitle(R.string.pref_subtitles_size)
|
||||
min = 10
|
||||
max = 38
|
||||
bind(userPreferences, UserPreferences.subtitlesSize)
|
||||
min = 25 // 0.25f
|
||||
max = 250 // 2.5f
|
||||
increment = 25 // 0.25f
|
||||
valueFormatter = object : DurationSeekBarPreference.ValueFormatter() {
|
||||
override fun display(value: Int): String = "$value%"
|
||||
}
|
||||
|
||||
bind {
|
||||
get { (userPreferences[UserPreferences.subtitlesTextSize] * 100f).roundToInt() }
|
||||
set { value -> userPreferences[UserPreferences.subtitlesTextSize] = value / 100f }
|
||||
default { (UserPreferences.subtitlesTextSize.defaultValue * 100f).roundToInt() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
category {
|
||||
setTitle(R.string.pref_mediasegment_actions)
|
||||
|
||||
for (segmentType in MediaSegmentRepository.SupportedTypes) {
|
||||
enum<MediaSegmentAction> {
|
||||
when (segmentType) {
|
||||
MediaSegmentType.UNKNOWN -> R.string.segment_type_unknown
|
||||
MediaSegmentType.COMMERCIAL -> R.string.segment_type_commercial
|
||||
MediaSegmentType.PREVIEW -> R.string.segment_type_preview
|
||||
MediaSegmentType.RECAP -> R.string.segment_type_recap
|
||||
MediaSegmentType.OUTRO -> R.string.segment_type_outro
|
||||
MediaSegmentType.INTRO -> R.string.segment_type_intro
|
||||
}.let(::setTitle)
|
||||
|
||||
bind {
|
||||
get { mediaSegmentRepository.getDefaultSegmentTypeAction(segmentType) }
|
||||
set { value -> mediaSegmentRepository.setDefaultSegmentTypeAction(segmentType, value) }
|
||||
default { MediaSegmentAction.NOTHING }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ public class CardPresenter extends Presenter {
|
||||
} else if (imageType.equals(ImageType.THUMB)) {
|
||||
aspect = ImageHelper.ASPECT_RATIO_16_9;
|
||||
} else {
|
||||
aspect = Utils.getSafeValue(imageHelper.getValue().getImageAspectRatio(itemDto, m.getPreferParentThumb()), ImageHelper.ASPECT_RATIO_7_9);
|
||||
aspect = imageHelper.getValue().getImageAspectRatio(itemDto, m.getPreferParentThumb());
|
||||
}
|
||||
switch (itemDto.getType()) {
|
||||
case AUDIO:
|
||||
@@ -183,9 +183,9 @@ public class CardPresenter extends Presenter {
|
||||
case MOVIE:
|
||||
case VIDEO:
|
||||
mDefaultCardImage = ContextCompat.getDrawable(mCardView.getContext(), R.drawable.tile_port_video);
|
||||
showProgress = true;
|
||||
if (imageType.equals(ImageType.POSTER))
|
||||
aspect = ImageHelper.ASPECT_RATIO_2_3;
|
||||
showProgress = true;
|
||||
break;
|
||||
default:
|
||||
mDefaultCardImage = ContextCompat.getDrawable(mCardView.getContext(), R.drawable.tile_port_video);
|
||||
|
||||
@@ -203,7 +203,7 @@ public class HorizontalGridPresenter extends Presenter {
|
||||
vh.mItemBridgeAdapter.setAdapterListener(new ItemBridgeAdapter.AdapterListener() {
|
||||
@Override
|
||||
public void onBind(final ItemBridgeAdapter.ViewHolder itemViewHolder) {
|
||||
// Only when having an OnItemClickListner, we attach the OnClickListener.
|
||||
// Only when having an OnItemClickListener, we attach the OnClickListener.
|
||||
if (getOnItemViewClickedListener() != null) {
|
||||
final View itemView = itemViewHolder.getViewHolder().view;
|
||||
itemView.setOnClickListener(new View.OnClickListener() {
|
||||
|
||||
@@ -8,7 +8,7 @@ import androidx.recyclerview.widget.ListUpdateCallback
|
||||
|
||||
/**
|
||||
* A leanback ObjectAdapter using a Kotlin list as backend. Implements Iterable to allow collection
|
||||
* operations. And uses generics for strong typing. Uses a MutableList as internal stucture.
|
||||
* operations. And uses generics for strong typing. Uses a MutableList as internal structure.
|
||||
*/
|
||||
open class MutableObjectAdapter<T : Any> : ObjectAdapter, Iterable<T> {
|
||||
private val data = mutableListOf<T>()
|
||||
|
||||
@@ -21,7 +21,7 @@ class MyDetailsOverviewRowPresenter(
|
||||
fun setItem(row: MyDetailsOverviewRow) {
|
||||
setTitle(row.item.name)
|
||||
|
||||
InfoLayoutHelper.addInfoRow(view.context, row.item, binding.fdMainInfoRow, false)
|
||||
InfoLayoutHelper.addInfoRow(view.context, row.item, row.item.mediaSources?.getOrNull(row.selectedMediaSourceIndex), binding.fdMainInfoRow, false)
|
||||
binding.fdGenreRow.text = row.item.genres?.joinToString(" / ")
|
||||
|
||||
binding.infoTitle1.text = row.infoItem1?.label
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.jellyfin.androidtv.ui.presentation
|
||||
|
||||
import android.util.TypedValue
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.leanback.widget.Presenter
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.card.LegacyImageCardView
|
||||
import org.jellyfin.androidtv.ui.itemhandling.BaseRowItem
|
||||
import org.jellyfin.androidtv.util.ImageHelper
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
class UserViewCardPresenter(
|
||||
val small: Boolean,
|
||||
) : Presenter(), KoinComponent {
|
||||
private val imageHelper by inject<ImageHelper>()
|
||||
|
||||
inner class ViewHolder(
|
||||
private val cardView: LegacyImageCardView,
|
||||
) : Presenter.ViewHolder(cardView) {
|
||||
fun setItem(rowItem: BaseRowItem?) {
|
||||
val baseItem = rowItem?.baseItem
|
||||
|
||||
// Load image
|
||||
val imageTag = baseItem?.imageTags?.get(ImageType.PRIMARY)
|
||||
val imageBlurhash = imageTag?.let { baseItem.imageBlurHashes?.get(ImageType.PRIMARY)?.get(it) }
|
||||
val imageUrl = imageTag?.let { imageHelper.getImageUrl(baseItem.id, ImageType.PRIMARY, it) }
|
||||
cardView.mainImageView.load(
|
||||
url = imageUrl,
|
||||
blurHash = imageBlurhash,
|
||||
placeholder = ContextCompat.getDrawable(cardView.context, R.drawable.tile_land_folder),
|
||||
aspectRatio = ImageHelper.ASPECT_RATIO_16_9,
|
||||
blurHashResolution = 32,
|
||||
)
|
||||
|
||||
// Set title
|
||||
cardView.setTitleText(rowItem?.getName(cardView.context))
|
||||
|
||||
// Set size
|
||||
if (small) {
|
||||
cardView.setMainImageDimensions(133, 75)
|
||||
} else {
|
||||
cardView.setMainImageDimensions(224, 126)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
|
||||
val cardView = LegacyImageCardView(parent.context, true)
|
||||
cardView.isFocusable = true
|
||||
cardView.isFocusableInTouchMode = true
|
||||
|
||||
val typedValue = TypedValue()
|
||||
val theme = parent.context.theme
|
||||
theme.resolveAttribute(R.attr.cardViewBackground, typedValue, true)
|
||||
@ColorInt val color = typedValue.data
|
||||
cardView.setBackgroundColor(color)
|
||||
|
||||
return ViewHolder(cardView)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, item: Any) {
|
||||
if (item !is BaseRowItem) return
|
||||
|
||||
(viewHolder as? ViewHolder)?.setItem(item)
|
||||
}
|
||||
|
||||
override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder?) {
|
||||
(viewHolder as? ViewHolder)?.setItem(null)
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ class SearchRepositoryImpl(
|
||||
|
||||
val result by apiClient.itemsApi.getItems(request)
|
||||
|
||||
Result.success(result.items.orEmpty())
|
||||
Result.success(result.items)
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.e("Failed to search for items", e)
|
||||
Result.failure(e)
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package org.jellyfin.androidtv.ui.shared
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
|
||||
class StrokeTextView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
) : AppCompatTextView(context, attrs, defStyleAttr), KoinComponent {
|
||||
var strokeWidth = 0.0f
|
||||
private var isDrawing: Boolean = false
|
||||
private val subtitlesTextColor = get<UserPreferences>()[UserPreferences.subtitlesTextColor]
|
||||
|
||||
init {
|
||||
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StrokeTextView)
|
||||
strokeWidth = styledAttrs.getFloat(R.styleable.StrokeTextView_strokeWidth, 0.0f)
|
||||
styledAttrs.recycle()
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
// To prevent infinite call of onDraw because setTextColor calls invalidate()
|
||||
if (isDrawing) return
|
||||
super.invalidate()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
if (strokeWidth <= 0) return super.onDraw(canvas)
|
||||
isDrawing = true
|
||||
paint.isAntiAlias = true
|
||||
paint.strokeWidth = strokeWidth
|
||||
paint.style = Paint.Style.STROKE
|
||||
paint.strokeJoin = Paint.Join.ROUND
|
||||
setTextColor(ContextCompat.getColor(context, R.color.black))
|
||||
super.onDraw(canvas)
|
||||
|
||||
paint.style = Paint.Style.FILL
|
||||
setTextColor(ColorStateList.valueOf(subtitlesTextColor.toInt()))
|
||||
super.onDraw(canvas)
|
||||
isDrawing = false
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.repository.SessionRepository
|
||||
import org.jellyfin.androidtv.auth.repository.SessionRepositoryState
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
import org.jellyfin.androidtv.databinding.ActivityMainBinding
|
||||
import org.jellyfin.androidtv.databinding.ActivityStartupBinding
|
||||
import org.jellyfin.androidtv.ui.background.AppBackground
|
||||
import org.jellyfin.androidtv.ui.browsing.MainActivity
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
|
||||
@@ -62,7 +62,7 @@ class StartupActivity : FragmentActivity() {
|
||||
private val navigationRepository: NavigationRepository by inject()
|
||||
private val itemLauncher: ItemLauncher by inject()
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var binding: ActivityStartupBinding
|
||||
|
||||
private val networkPermissionsRequester = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
@@ -83,7 +83,7 @@ class StartupActivity : FragmentActivity() {
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
binding = ActivityStartupBinding.inflate(layoutInflater)
|
||||
binding.background.setContent { AppBackground() }
|
||||
binding.screensaver.isVisible = false
|
||||
setContentView(binding.root)
|
||||
@@ -156,7 +156,7 @@ class StartupActivity : FragmentActivity() {
|
||||
else -> null
|
||||
}
|
||||
|
||||
navigationRepository.reset(destination)
|
||||
navigationRepository.reset(destination, true)
|
||||
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
// Clear navigation history
|
||||
|
||||
@@ -70,7 +70,7 @@ class StartupViewModel(
|
||||
fun getUserImage(server: Server, user: User): String? =
|
||||
authenticationRepository.getUserImageUrl(server, user)
|
||||
|
||||
fun loadDiscoveryservers() {
|
||||
fun loadDiscoveryServers() {
|
||||
// Only run one discovery process at a time
|
||||
if (discoveryMutex.isLocked) return
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user