Compare commits
58 Commits
master
...
release-0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a257651f8e | ||
|
|
0690c1c898 | ||
|
|
66ec39f566 | ||
|
|
f4abd1246d | ||
|
|
a17821181f | ||
|
|
4da68f86cb | ||
|
|
487bf3b7ee | ||
|
|
21f5256e0f | ||
|
|
41df9a59df | ||
|
|
3709bf5f5a | ||
|
|
3cdc1cdaba | ||
|
|
6031979f4c | ||
|
|
896f66254f | ||
|
|
3a18933b87 | ||
|
|
8c45084406 | ||
|
|
d4a297710b | ||
|
|
19f30c19b0 | ||
|
|
4e8e8bc232 | ||
|
|
ec5c751390 | ||
|
|
46b0b7aade | ||
|
|
3877739320 | ||
|
|
538eeb4f22 | ||
|
|
0d724c532a | ||
|
|
eba14e0bf4 | ||
|
|
a8c35b0ea0 | ||
|
|
17744729b2 | ||
|
|
fd5442097e | ||
|
|
f9eeae6bf0 | ||
|
|
c2e33b9b13 | ||
|
|
9c4b121574 | ||
|
|
ddb1be18bc | ||
|
|
4a4c1ed6ca | ||
|
|
c28759e165 | ||
|
|
45a5adbc0b | ||
|
|
118eacfa90 | ||
|
|
91ccf32f77 | ||
|
|
065e738773 | ||
|
|
65a7bbf501 | ||
|
|
d661c2faf3 | ||
|
|
8175dbd1c0 | ||
|
|
f068be6945 | ||
|
|
fe465db020 | ||
|
|
d6fe265b38 | ||
|
|
8eecfab5d7 | ||
|
|
0f9ffcacb9 | ||
|
|
aa7c45d462 | ||
|
|
156f5ecd3c | ||
|
|
e56fd0d024 | ||
|
|
06e245fe48 | ||
|
|
9f0fa644a7 | ||
|
|
5b213199dd | ||
|
|
b4f6273ede | ||
|
|
66c624e16c | ||
|
|
e47916f1c2 | ||
|
|
ab7875cbb3 | ||
|
|
5f15615a96 | ||
|
|
9cda86ab84 | ||
|
|
1873497fec |
@@ -2,17 +2,16 @@ root = true
|
||||
|
||||
[*]
|
||||
insert_final_newline = true
|
||||
max_line_length = 140
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[{*.kts,*.kt}]
|
||||
[{*.kts, *.kt}]
|
||||
charset = utf-8
|
||||
indent_style = tab
|
||||
tab_width = 4
|
||||
# Disable wildcard imports in IntelliJ/Android Studio
|
||||
ij_kotlin_name_count_to_use_star_import = 1000
|
||||
ij_kotlin_name_count_to_use_star_import_for_members = 1000
|
||||
ij_kotlin_packages_to_use_import_on_demand = unset
|
||||
ij_kotlin_packages_to_use_import_on_demand =
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
trim_trailing_whitespace = false
|
||||
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -5,5 +5,3 @@
|
||||
|
||||
CONTRIBUTORS.md merge=union
|
||||
app/src/main/res/values*/strings.xml merge=union
|
||||
|
||||
design/src/main/kotlin/token/* linguist-generated
|
||||
|
||||
91
.github/ISSUE_TEMPLATE/bug-report-playback.yaml
vendored
91
.github/ISSUE_TEMPLATE/bug-report-playback.yaml
vendored
@@ -4,24 +4,6 @@ labels:
|
||||
- bug
|
||||
- playback
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: before-posting
|
||||
attributes:
|
||||
label: "This issue respects the following points:"
|
||||
description: All conditions are **required**.
|
||||
options:
|
||||
- label: I have discussed this issue in the [Jellyfin forum](https://forum.jellyfin.org/) and/or [troubleshooting chat](https://jellyfin.org/contact) before opening this issue.
|
||||
required: true
|
||||
- label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin-androidtv/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
|
||||
required: true
|
||||
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
|
||||
required: true
|
||||
- label: This report addresses only a single issue; If you encounter multiple issues, kindly create separate reports for each one.
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Bug information
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
@@ -38,38 +20,10 @@ body:
|
||||
Instead, I expect …
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: mediainfo
|
||||
attributes:
|
||||
label: Media info of the file
|
||||
description: |
|
||||
Please share the media information for the file causing issues. You can use a variety of tools to retrieve this information.
|
||||
- Use ffprobe (`ffprobe ./file.mp4`)
|
||||
- Copy the media info from the web interface
|
||||
placeholder: Paste media info…
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: mediacapabilitiesreport
|
||||
attributes:
|
||||
label: Media capabilities report
|
||||
description: |
|
||||
Please share the media capability report generated from within the app. To do some follow these steps:
|
||||
1. Open the Jellyfin for Android TV app
|
||||
2. Connect to a server **that you have admin access to**
|
||||
3. Go to Preferences -> Playback -> Advanced playback preferences
|
||||
4. On the bottom, press the "Report playback capabilities to server" button
|
||||
5. Go to the logs in your Jellyfin dashboard (in your browser)
|
||||
6. Open the newest log file, it should be named similar to `upload_Jellyfin Android TV[...].log`
|
||||
7. Press the "Download" button and paste the file contents
|
||||
placeholder: Paste capability report contents…
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Logs
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Client logs
|
||||
label: Logs
|
||||
description: |
|
||||
Please paste your crash logs here if applicable. You can find these in your servers dashboard under "logs".
|
||||
The file name should start with "upload_org.jellyfin.androidtv".
|
||||
@@ -83,34 +37,31 @@ body:
|
||||
Please paste your FFmpeg logs here. You can find these in your servers dashboard under "logs".
|
||||
placeholder: Paste logs…
|
||||
render: shell
|
||||
- type: markdown
|
||||
- type: textarea
|
||||
id: mediainfo
|
||||
attributes:
|
||||
value: |
|
||||
## Environment
|
||||
label: Media info of the file
|
||||
description: |
|
||||
Please share the media information for the file causing issues. You can use a variety of tools to retrieve this information.
|
||||
- Use ffprobe (`ffprobe ./file.mp4`)
|
||||
- Use the Media Info tool (set to text format, download here: https://mediaarea.net/en/MediaInfo)
|
||||
- Copy the media info from the web interface
|
||||
placeholder: Paste media info…
|
||||
render: shell
|
||||
- type: input
|
||||
id: app-version
|
||||
attributes:
|
||||
label: Application version
|
||||
description: The version of the installed Jellyfin Android TV app.
|
||||
placeholder: 0.18.2
|
||||
placeholder: 0.14.0
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: installation-source
|
||||
attributes:
|
||||
label: Where did you install the app from?
|
||||
description: Choose the appropriate app store or installation method.
|
||||
options:
|
||||
- Google Play Store
|
||||
- Amazon Appstore
|
||||
- F-Droid
|
||||
- Sideloaded APK
|
||||
- type: input
|
||||
id: device-info
|
||||
attributes:
|
||||
label: Device information
|
||||
description: Manufacturer and model
|
||||
placeholder: Nvidia Shield TV Pro (2019), Fire TV Stick HD (2020)
|
||||
placeholder: Nvidia Shield Pro (2017), Amazon Fire TV Stick v1 (2014)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
@@ -118,7 +69,7 @@ body:
|
||||
attributes:
|
||||
label: Android version
|
||||
description: Version of the OS and other information (e.g. custom ROM / OEM skin)
|
||||
placeholder: Android 13, Fire OS 7
|
||||
placeholder: Android 9, Fire OS 7
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
@@ -126,18 +77,6 @@ body:
|
||||
attributes:
|
||||
label: Jellyfin server version
|
||||
description: If on unstable, please specify the commit hash.
|
||||
placeholder: 10.10.2
|
||||
placeholder: 10.8.1
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Additional
|
||||
- type: input
|
||||
id: other-sources
|
||||
attributes:
|
||||
label: Other sources
|
||||
description: Please link the related forum topics or chat messages that prompted you open this issue.
|
||||
placeholder: https://forum.jellyfin.org/…
|
||||
validations:
|
||||
required: false
|
||||
|
||||
45
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
45
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -3,24 +3,6 @@ description: Create a bug report
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: before-posting
|
||||
attributes:
|
||||
label: "This issue respects the following points:"
|
||||
description: All conditions are **required**.
|
||||
options:
|
||||
- label: I have discussed this issue in the [Jellyfin forum](https://forum.jellyfin.org/) and/or [troubleshooting chat](https://jellyfin.org/contact) before opening this issue.
|
||||
required: true
|
||||
- label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin-androidtv/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
|
||||
required: true
|
||||
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
|
||||
required: true
|
||||
- label: This report addresses only a single issue; If you encounter multiple issues, kindly create separate reports for each one.
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Bug information
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
@@ -46,16 +28,12 @@ body:
|
||||
The file name should start with "upload_org.jellyfin.androidtv".
|
||||
Make sure that they don't contain any sensitive information like server URL, auth tokens or passwords.
|
||||
placeholder: Paste logs…
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Environment
|
||||
- type: input
|
||||
id: app-version
|
||||
attributes:
|
||||
label: Application version
|
||||
description: The version of the installed Jellyfin Android TV app.
|
||||
placeholder: 0.18.2
|
||||
placeholder: 0.14.0
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
@@ -64,16 +42,15 @@ body:
|
||||
label: Where did you install the app from?
|
||||
description: Choose the appropriate app store or installation method.
|
||||
options:
|
||||
- Google Play Store
|
||||
- Google Play
|
||||
- Amazon Appstore
|
||||
- F-Droid
|
||||
- Sideloaded APK
|
||||
- type: input
|
||||
id: device-info
|
||||
attributes:
|
||||
label: Device information
|
||||
description: Manufacturer and model
|
||||
placeholder: Nvidia Shield TV Pro (2019), Fire TV Stick HD (2020)
|
||||
placeholder: Nvidia Shield Pro (2017), Amazon Fire TV Stick v1 (2014)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
@@ -81,7 +58,7 @@ body:
|
||||
attributes:
|
||||
label: Android version
|
||||
description: Version of the OS and other information (e.g. custom ROM / OEM skin)
|
||||
placeholder: Android 13, Fire OS 7
|
||||
placeholder: Android 9, Fire OS 7
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
@@ -89,18 +66,6 @@ body:
|
||||
attributes:
|
||||
label: Jellyfin server version
|
||||
description: If on unstable, please specify the commit hash.
|
||||
placeholder: 10.10.2
|
||||
placeholder: 10.8.1
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Additional
|
||||
- type: input
|
||||
id: other-sources
|
||||
attributes:
|
||||
label: Other sources
|
||||
description: Please link the related forum topics or chat messages that prompted you open this issue.
|
||||
placeholder: https://forum.jellyfin.org/…
|
||||
validations:
|
||||
required: false
|
||||
|
||||
52
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
52
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
@@ -3,54 +3,12 @@ description: Request a new feature
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: before-posting
|
||||
attributes:
|
||||
label: "This request respects the following points:"
|
||||
description: All conditions are **required**.
|
||||
options:
|
||||
- label: This request is **not** already on [GitHub](https://github.com/jellyfin/jellyfin-androidtv/labels/enhancement) _(I've searched it)_.
|
||||
required: true
|
||||
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Problem description
|
||||
description: Describe the issue you are experiencing or the gap you are trying to address.
|
||||
placeholder: |
|
||||
The Android TV app does not support displaying lyrics, while the Jellyfin web interface does.
|
||||
This creates an inconsistent user experience across platforms.
|
||||
label: Describe the feature you'd like
|
||||
description: |
|
||||
A clear and concise description of what you want to request.
|
||||
You can also attach screenshots or screen recordings to help explain your request.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Provide a detailed description of what you would like to see implemented or changed.
|
||||
placeholder: |
|
||||
The app could display lyrics in the screensaver. Additionally, a lyrics section could be integrated into the page that opens when music is played.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Outline any other approaches you have thought about or explored to solve the problem.
|
||||
placeholder: |
|
||||
An alternative approach could involve adding a dedicated "Lyrics" button in the app, similar to the Jellyfin web interface.
|
||||
Users could press this button to open a full-screen lyrics view.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Include any relevant details, resources, or screenshots that might help in understanding or implementing the request.
|
||||
placeholder: Add any additional context here.
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: other-sources
|
||||
attributes:
|
||||
label: Other sources
|
||||
description: If this topic has been discussed outside of GitHub, please link it.
|
||||
placeholder: https://forum.jellyfin.org/…
|
||||
validations:
|
||||
required: false
|
||||
|
||||
6
.github/pull_request_template.md
vendored
6
.github/pull_request_template.md
vendored
@@ -1,15 +1,11 @@
|
||||
<!--
|
||||
Ensure your title is short, descriptive, and in the imperative mood (Fix X, Change Y, instead of Fixed X, Changed Y).
|
||||
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://jellyfin.org/docs/general/contributing/issues/ page.
|
||||
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://docs.jellyfin.org/general/contributing/issues.html page.
|
||||
-->
|
||||
|
||||
**Changes**
|
||||
<!-- Describe your changes here in 1-5 sentences. -->
|
||||
|
||||
**Code assistance**
|
||||
<!-- If code assistance was used, describe how it contributed
|
||||
e.g., code generated by LLM, explanation of code base, debugging guidance. -->
|
||||
|
||||
**Issues**
|
||||
<!-- Tag any issues that this PR solves here.
|
||||
ex. Fixes # -->
|
||||
|
||||
13
.github/workflows/app-build.yaml
vendored
13
.github/workflows/app-build.yaml
vendored
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- release-*
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
@@ -13,23 +12,23 @@ permissions:
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version-file: .tools-versions
|
||||
java-version: 17
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
|
||||
uses: gradle/gradle-build-action@8cbcb9948b5892387aed077daf6f90e1f0ba5b27 # v2.11.0
|
||||
- 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 # v4.0.0
|
||||
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-24.04
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version-file: .tools-versions
|
||||
java-version: 17
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
|
||||
uses: gradle/gradle-build-action@8cbcb9948b5892387aed077daf6f90e1f0ba5b27 # v2.11.0
|
||||
- name: Run detekt and lint tasks
|
||||
run: ./gradlew detekt lint
|
||||
- name: Upload SARIF files
|
||||
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
uses: github/codeql-action/upload-sarif@b374143c1149a9115d881581d29b8390bbcbb59c # v3.22.11
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
sarif_file: .
|
||||
|
||||
14
.github/workflows/app-publish.yaml
vendored
14
.github/workflows/app-publish.yaml
vendored
@@ -8,18 +8,18 @@ on:
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ contains(github.repository_owner, 'jellyfin') }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version-file: .tools-versions
|
||||
java-version: 17
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
|
||||
uses: gradle/gradle-build-action@8cbcb9948b5892387aed077daf6f90e1f0ba5b27 # v2.11.0
|
||||
- name: Set JELLYFIN_VERSION
|
||||
run: echo "JELLYFIN_VERSION=$(echo ${GITHUB_REF#refs/tags/v} | tr / -)" >> $GITHUB_ENV
|
||||
- name: Assemble release files
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
mv ${{ steps.signAab.outputs.signedReleaseFile }} build/jellyfin-publish/jellyfin-androidtv-v${{ env.JELLYFIN_VERSION }}-release.aab
|
||||
mv app/build/version.txt build/jellyfin-publish/
|
||||
- name: Upload release archive to GitHub release
|
||||
uses: alexellis/upload-assets@13926a61cdb2cb35f5fdef1c06b8b591523236d3 # 0.4.1
|
||||
uses: alexellis/upload-assets@259de5111cb56966d046ced998941e93f91d2c93 # tag=0.4.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
with:
|
||||
@@ -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@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
uses: appleboy/ssh-action@55dabf81b49d4120609345970c91507e2d734799 # v1.0.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-24.04
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version-file: .tools-versions
|
||||
java-version: 17
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
|
||||
uses: gradle/gradle-build-action@8cbcb9948b5892387aed077daf6f90e1f0ba5b27 # v2.11.0
|
||||
- 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-24.04
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
|
||||
uses: gradle/wrapper-validation-action@56b90f209b02bf6d1deae490e9ef18b21a389cd4 # v1.1.0
|
||||
|
||||
4
.github/workflows/repo-merge-conflict.yaml
vendored
4
.github/workflows/repo-merge-conflict.yaml
vendored
@@ -9,10 +9,10 @@ on:
|
||||
jobs:
|
||||
triage:
|
||||
name: Triage
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ contains(github.repository_owner, 'jellyfin') }}
|
||||
steps:
|
||||
- uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||
- uses: eps1lon/actions-label-merge-conflict@fd1f295ee7443d13745804bc49fe158e240f6c6e # tag=v2.1.0
|
||||
with:
|
||||
dirtyLabel: merge conflict
|
||||
repoToken: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
4
.github/workflows/repo-stale.yaml
vendored
4
.github/workflows/repo-stale.yaml
vendored
@@ -12,10 +12,10 @@ permissions:
|
||||
jobs:
|
||||
triage:
|
||||
name: Triage
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ contains(github.repository_owner, 'jellyfin') }}
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
- uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
days-before-stale: 120
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,5 @@ local.properties
|
||||
|
||||
/.idea
|
||||
.gradle
|
||||
.kotlin/
|
||||
build/
|
||||
captures/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
java 21
|
||||
@@ -13,9 +13,6 @@
|
||||
- [sparky3387](https://github.com/sparky3387)
|
||||
- [mohd-akram](https://github.com/mohd-akram)
|
||||
- [3l0w](https://github.com/3l0w)
|
||||
- [MajMongoose](https://github.com/majmongoose)
|
||||
- [Olaren15](https://github.com/Olaren15)
|
||||
- [dtrexler](https://github.com/dtrexler)
|
||||
|
||||
# Emby Contributors
|
||||
|
||||
|
||||
70
README.md
70
README.md
@@ -25,6 +25,9 @@
|
||||
<a href="https://matrix.to/#/+jellyfin:matrix.org">
|
||||
<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfin:matrix.org.svg?logo=matrix"/>
|
||||
</a>
|
||||
<a href="https://www.reddit.com/r/jellyfin">
|
||||
<img alt="Join our Subreddit" src="https://img.shields.io/badge/reddit-r%2Fjellyfin-%23FF5700.svg"/>
|
||||
</a>
|
||||
<br/>
|
||||
<a href="https://play.google.com/store/apps/details?id=org.jellyfin.androidtv">
|
||||
<img width="153" alt="Jellyfin on Google Play" src="https://jellyfin.org/images/store-icons/google-play.png"/>
|
||||
@@ -39,39 +42,48 @@
|
||||
<a href="https://repo.jellyfin.org/releases/client/androidtv/">Download archive</a>
|
||||
</p>
|
||||
|
||||
Jellyfin Android TV is a Jellyfin client for Android TV, Nvidia Shield, and Amazon Fire TV devices. We welcome all contributions and pull
|
||||
requests! If you have a larger feature in mind please open an issue so we can discuss the implementation before you start.
|
||||
|
||||
## Building
|
||||
|
||||
The app uses Gradle and requires the Android SDK. We recommend using Android Studio, which includes all required dependencies, for
|
||||
development and building. For manual building without Android Studio make sure a compatible JDK and Android SDK are installed and in your
|
||||
PATH, then use the Gradle wrapper (`./gradlew`) to build the project with the `assembleDebug` Gradle task to generate an apk file:
|
||||
|
||||
```shell
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
The task will create an APK file in the `/app/build/outputs/apk/debug` directory. This APK file uses a different app-id from our stable
|
||||
builds and can be manually installed to your device.
|
||||
|
||||
## Branching
|
||||
|
||||
The `master` branch is the primary development branch and the target for all pull requests. It is **unstable** and may contain breaking
|
||||
changes or unresolved bugs. For production deployments and forks, always use the latest `release-x.y.z` branch. Do not base production work
|
||||
or long-lived forks on `master`.
|
||||
|
||||
Release branches are created at the start of a beta cycle and are kept up to date with each published release. Maintainers will cherry-pick
|
||||
selected changes into release branches as needed for backports. These branches are reused for subsequent patch releases.
|
||||
Jellyfin Android TV is a Jellyfin client for Android TV, Nvidia Shield, and Amazon Fire TV devices.
|
||||
We welcome all contributions and pull requests! If you have a larger feature in mind please open an
|
||||
issue so we can discuss the implementation before you start.
|
||||
|
||||
## Translating
|
||||
|
||||
Translations can be improved very easily from our [Weblate](https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv)
|
||||
instance. Look through the following graphic to see if your native language could use some work! We cannot accept changes to translation
|
||||
files via pull requests.
|
||||
Translations can be improved very easily from our
|
||||
[Weblate](https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv) instance.
|
||||
Look through the following graphic to see if your native language could use some work!
|
||||
|
||||
<p align="center">
|
||||
<a href="https://translate.jellyfin.org/engage/jellyfin-android/">
|
||||
<img alt="Detailed Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin-android/-/jellyfin-androidtv/multi-auto.svg"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Build Process
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Android Studio
|
||||
|
||||
### Build
|
||||
|
||||
1. Clone or download this repository
|
||||
|
||||
```sh
|
||||
git clone https://github.com/jellyfin/jellyfin-androidtv.git
|
||||
cd jellyfin-androidtv
|
||||
```
|
||||
|
||||
2. Open the project in Android Studio and run it from there or build an APK directly through Gradle:
|
||||
|
||||
```sh
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Add the Android SDK to your PATH environment variable or create the ANDROID_SDK_ROOT variable for
|
||||
this to work.
|
||||
|
||||
### Deploy to device/emulator
|
||||
|
||||
```sh
|
||||
./gradlew installDebug
|
||||
```
|
||||
|
||||
*You can also replace the "Debug" with "Release" to get an optimized release binary.*
|
||||
|
||||
@@ -13,13 +13,12 @@
|
||||
<issue id="MangledCRLF" severity="warning" />
|
||||
<issue id="NegativeMargin" severity="warning" />
|
||||
<issue id="NoHardKeywords" severity="warning" />
|
||||
<issue id="NotificationPermission">
|
||||
<ignore regexp="com.bumptech.glide.request.target.NotificationTarget" />
|
||||
</issue>
|
||||
<issue id="Registered" severity="warning" />
|
||||
<issue id="RequiredSize" severity="error" />
|
||||
<issue id="Typos" severity="ignore" />
|
||||
<issue id="UnknownNullness" severity="informational" />
|
||||
<issue id="UnusedIds" severity="ignore" />
|
||||
<issue id="UnusedAttribute">
|
||||
<ignore regexp="usesCleartextTraffic" />
|
||||
<ignore regexp="networkSecurityConfig" />
|
||||
</issue>
|
||||
</lint>
|
||||
|
||||
39
app/baselineWorkaround.gradle
Normal file
39
app/baselineWorkaround.gradle
Normal file
@@ -0,0 +1,39 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath libs.android.gradle
|
||||
}
|
||||
}
|
||||
|
||||
import com.android.tools.profgen.ArtProfileKt
|
||||
import com.android.tools.profgen.ArtProfileSerializer
|
||||
import com.android.tools.profgen.DexFile
|
||||
|
||||
/**
|
||||
* This is a temporary workaround for https://issuetracker.google.com/issues/231837768
|
||||
* which will be fixed in AGP 8.1.0, at which point the workaround may be deleted.
|
||||
*/
|
||||
project.afterEvaluate {
|
||||
tasks.compileReleaseArtProfile.doLast {
|
||||
outputs.files.each { file ->
|
||||
if (file.toString().endsWith(".profm")) {
|
||||
println("Sorting ${file}")
|
||||
def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
|
||||
def profile = ArtProfileKt.ArtProfile(file)
|
||||
def keys = new ArrayList(profile.profileData.keySet())
|
||||
def sortedData = new LinkedHashMap()
|
||||
Collections.sort keys, new DexFile.Companion()
|
||||
keys.each { key -> sortedData[key] = profile.profileData[key] }
|
||||
new FileOutputStream(file).with {
|
||||
write(version.magicBytes$profgen)
|
||||
write(version.versionBytes$profgen)
|
||||
version.write$profgen(it, sortedData, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,45 @@
|
||||
plugins {
|
||||
alias(libs.plugins.aboutlibraries)
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
id("com.android.application")
|
||||
kotlin("android")
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.aboutlibraries)
|
||||
}
|
||||
|
||||
// Apply workaround
|
||||
apply("baselineWorkaround.gradle")
|
||||
|
||||
android {
|
||||
namespace = "org.jellyfin.androidtv"
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
minSdk = 21
|
||||
targetSdk = 33
|
||||
|
||||
// Release version
|
||||
applicationId = namespace
|
||||
versionName = project.getVersionName()
|
||||
versionCode = getVersionCode(versionName!!)
|
||||
setProperty("archivesBaseName", "jellyfin-androidtv-v$versionName")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
viewBinding = true
|
||||
compose = true
|
||||
resValues = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get()
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
val release by getting {
|
||||
isMinifyEnabled = false
|
||||
|
||||
// Set package names used in various XML files
|
||||
resValue("string", "app_id", namespace!!)
|
||||
@@ -45,7 +52,7 @@ android {
|
||||
buildConfigField("boolean", "DEVELOPMENT", "false")
|
||||
}
|
||||
|
||||
debug {
|
||||
val debug by getting {
|
||||
// Use different application id to run release and debug at the same time
|
||||
applicationIdSuffix = ".debug"
|
||||
|
||||
@@ -73,10 +80,13 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
base.archivesName.set("jellyfin-androidtv-v${project.getVersionName()}")
|
||||
aboutLibraries {
|
||||
// Remove the "generated" timestamp to allow for reproducible builds
|
||||
excludeFields = arrayOf("generated")
|
||||
}
|
||||
|
||||
tasks.register("versionTxt") {
|
||||
val path = layout.buildDirectory.asFile.get().resolve("version.txt")
|
||||
val versionTxt by tasks.registering {
|
||||
val path = buildDir.resolve("version.txt")
|
||||
|
||||
doLast {
|
||||
val versionString = "v${android.defaultConfig.versionName}=${android.defaultConfig.versionCode}"
|
||||
@@ -87,12 +97,12 @@ tasks.register("versionTxt") {
|
||||
|
||||
dependencies {
|
||||
// Jellyfin
|
||||
implementation(projects.design)
|
||||
implementation(projects.playback.core)
|
||||
implementation(projects.playback.exoplayer)
|
||||
implementation(projects.playback.jellyfin)
|
||||
implementation(projects.playback.media3.exoplayer)
|
||||
implementation(projects.playback.media3.session)
|
||||
implementation(projects.playback.ui)
|
||||
implementation(projects.preference)
|
||||
implementation(libs.jellyfin.apiclient)
|
||||
implementation(libs.jellyfin.sdk) {
|
||||
// Change version if desired
|
||||
val sdkVersion = findProperty("sdk.version")?.toString()
|
||||
@@ -110,12 +120,9 @@ dependencies {
|
||||
// Android(x)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.fragment)
|
||||
implementation(libs.androidx.fragment.compose)
|
||||
implementation(libs.androidx.leanback.core)
|
||||
implementation(libs.androidx.leanback.preference)
|
||||
implementation(libs.androidx.navigation3.ui)
|
||||
implementation(libs.androidx.preference)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.tvprovider)
|
||||
@@ -127,23 +134,22 @@ dependencies {
|
||||
implementation(libs.androidx.cardview)
|
||||
implementation(libs.androidx.startup)
|
||||
implementation(libs.bundles.androidx.compose)
|
||||
implementation(libs.accompanist.permissions)
|
||||
|
||||
// Dependency Injection
|
||||
implementation(libs.bundles.koin)
|
||||
|
||||
// Media players
|
||||
implementation(libs.androidx.media3.exoplayer)
|
||||
implementation(libs.androidx.media3.datasource.okhttp)
|
||||
implementation(libs.androidx.media3.exoplayer.hls)
|
||||
implementation(libs.androidx.media3.ui)
|
||||
implementation(libs.jellyfin.androidx.media3.ffmpeg.decoder)
|
||||
implementation(libs.libass.media3)
|
||||
implementation(libs.libvlc)
|
||||
|
||||
// Markdown
|
||||
implementation(libs.bundles.markwon)
|
||||
|
||||
// Image utility
|
||||
implementation(libs.blurhash)
|
||||
implementation(libs.bundles.coil)
|
||||
|
||||
// Crash Reporting
|
||||
@@ -156,6 +162,10 @@ dependencies {
|
||||
implementation(libs.timber)
|
||||
implementation(libs.slf4j.timber)
|
||||
|
||||
// Debugging
|
||||
if (getProperty("leakcanary.enable")?.toBoolean() == true)
|
||||
debugImplementation(libs.leakcanary)
|
||||
|
||||
// Compatibility (desugaring)
|
||||
coreLibraryDesugaring(libs.android.desugar)
|
||||
|
||||
|
||||
1
app/proguard-rules.pro
vendored
1
app/proguard-rules.pro
vendored
@@ -1 +0,0 @@
|
||||
-dontwarn org.commonmark.ext.gfm.strikethrough.Strikethrough
|
||||
@@ -35,11 +35,6 @@
|
||||
android:host="youtube.com"
|
||||
android:scheme="https" />
|
||||
</intent>
|
||||
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
@@ -52,11 +47,22 @@
|
||||
android:icon="@mipmap/app_icon"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Jellyfin"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<!-- Legacy service required for AccountManagerMigration -->
|
||||
<service
|
||||
android:name=".auth.service.AuthenticatorService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/authenticator" />
|
||||
</service>
|
||||
|
||||
<!-- Screensaver -->
|
||||
<service
|
||||
android:name=".integration.dream.LibraryDreamService"
|
||||
@@ -102,8 +108,9 @@
|
||||
<activity
|
||||
android:name=".ui.startup.StartupActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:noHistory="true"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="landscape"
|
||||
android:windowSoftInputMode="adjustNothing">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -125,9 +132,17 @@
|
||||
<!-- Main application -->
|
||||
<activity
|
||||
android:name=".ui.browsing.MainActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="landscape"
|
||||
android:windowSoftInputMode="adjustNothing" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.preference.PreferencesActivity"
|
||||
android:screenOrientation="behind"
|
||||
android:theme="@style/Theme.Jellyfin.Preferences" />
|
||||
|
||||
<activity android:name=".ui.playback.ExternalPlayerActivity" />
|
||||
|
||||
<!-- Playback Rewrite - Temporary -->
|
||||
<activity android:name=".ui.playback.rewrite.PlaybackForwardingActivity" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -2,11 +2,14 @@ package org.jellyfin.androidtv
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.await
|
||||
import com.vanniktech.blurhash.BlurHash
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -15,6 +18,7 @@ import org.jellyfin.androidtv.data.eventhandling.SocketHandler
|
||||
import org.jellyfin.androidtv.data.repository.NotificationsRepository
|
||||
import org.jellyfin.androidtv.integration.LeanbackChannelWorker
|
||||
import org.jellyfin.androidtv.telemetry.TelemetryService
|
||||
import org.jellyfin.androidtv.util.AutoBitrate
|
||||
import org.koin.android.ext.android.inject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -35,6 +39,7 @@ class JellyfinApplication : Application() {
|
||||
*/
|
||||
suspend fun onSessionStart() = withContext(Dispatchers.IO) {
|
||||
val workManager by inject<WorkManager>()
|
||||
val autoBitrate by inject<AutoBitrate>()
|
||||
val socketListener by inject<SocketHandler>()
|
||||
|
||||
// Update background worker
|
||||
@@ -54,6 +59,16 @@ class JellyfinApplication : Application() {
|
||||
|
||||
// Update WebSockets
|
||||
launch { socketListener.updateSession() }
|
||||
|
||||
// Detect auto bitrate
|
||||
// running in a different scope to prevent slow startups
|
||||
ProcessLifecycleOwner.get().lifecycleScope.launch { autoBitrate.detect() }
|
||||
}
|
||||
|
||||
override fun onLowMemory() {
|
||||
super.onLowMemory()
|
||||
|
||||
BlurHash.clearCache()
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.startup.AppInitializer
|
||||
import androidx.startup.Initializer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.auth.repository.SessionRepository
|
||||
import org.jellyfin.androidtv.di.KoinInitializer
|
||||
@@ -18,7 +17,7 @@ class SessionInitializer : Initializer<Unit> {
|
||||
.koin
|
||||
|
||||
// Restore system session
|
||||
ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.IO) {
|
||||
ProcessLifecycleOwner.get().lifecycleScope.launch {
|
||||
koin.get<SessionRepository>().restoreSession(destroyOnly = false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.jellyfin.androidtv.auth
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import androidx.core.content.getSystemService
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
class AccountManagerMigration(
|
||||
context: Context,
|
||||
) {
|
||||
private val accountManager = requireNotNull(context.getSystemService<AccountManager>())
|
||||
|
||||
fun migrate(
|
||||
servers: Map<UUID, AuthenticationStoreServer>,
|
||||
) = servers.mapValues { (serverId, server) ->
|
||||
Timber.i("Migrating server $serverId (${server.name})")
|
||||
server.copy(
|
||||
users = server.users.mapValues { (userId, user) ->
|
||||
val accessToken = getAccessToken(serverId, userId)
|
||||
Timber.i("Migrating user $userId (${user.name}): ${if (accessToken != null) "success" else "no token"}")
|
||||
user.copy(accessToken = accessToken)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MissingPermission")
|
||||
private fun getAccessToken(serverId: UUID, userId: UUID): String? = runCatching {
|
||||
accountManager.getAccountsByType(ACCOUNT_TYPE)
|
||||
.firstOrNull {
|
||||
val validServerId = accountManager.getUserData(it, ACCOUNT_DATA_SERVER)?.toUUIDOrNull() == serverId
|
||||
val validUserId = accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == userId
|
||||
|
||||
validServerId && validUserId
|
||||
}
|
||||
?.let { account -> accountManager.peekAuthToken(account, ACCOUNT_ACCESS_TOKEN_TYPE) }
|
||||
}.getOrNull()
|
||||
|
||||
companion object {
|
||||
const val ACCOUNT_TYPE = BuildConfig.APPLICATION_ID
|
||||
const val ACCOUNT_DATA_ID = "$ACCOUNT_TYPE.id"
|
||||
const val ACCOUNT_DATA_SERVER = "$ACCOUNT_TYPE.server"
|
||||
const val ACCOUNT_ACCESS_TOKEN_TYPE = "$ACCOUNT_TYPE.access_token"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.jellyfin.androidtv.auth.apiclient
|
||||
|
||||
import org.jellyfin.androidtv.auth.repository.Session
|
||||
import org.jellyfin.androidtv.auth.store.AuthenticationStore
|
||||
import org.jellyfin.androidtv.util.sdk.legacy
|
||||
import org.jellyfin.apiclient.interaction.ApiClient
|
||||
import org.jellyfin.apiclient.model.apiclient.ServerInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import timber.log.Timber
|
||||
|
||||
class ApiBinder(
|
||||
private val api: ApiClient,
|
||||
private val authenticationStore: AuthenticationStore,
|
||||
) {
|
||||
fun updateSession(session: Session?, deviceInfo: DeviceInfo): Boolean {
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
val success = try {
|
||||
updateSessionInternal(session, deviceInfo)
|
||||
} catch (throwable: Throwable) {
|
||||
Timber.e(throwable, "Unable to update legacy API session.")
|
||||
false
|
||||
}
|
||||
|
||||
return success
|
||||
}
|
||||
|
||||
private fun updateSessionInternal(session: Session?, deviceInfo: DeviceInfo): Boolean {
|
||||
if (session == null) return true
|
||||
|
||||
val server = authenticationStore.getServer(session.serverId)
|
||||
if (server == null) {
|
||||
Timber.e("Could not bind API because server ${session.serverId} was not found in the store.")
|
||||
return false
|
||||
}
|
||||
|
||||
api.device = deviceInfo.legacy()
|
||||
api.SetAuthenticationInfo(session.accessToken, session.userId.toString())
|
||||
api.EnableAutomaticNetworking(ServerInfo().apply {
|
||||
id = session.serverId.toString()
|
||||
name = server.name
|
||||
address = server.address.removeSuffix("/")
|
||||
userId = session.userId.toString()
|
||||
accessToken = session.accessToken
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import java.time.Instant
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -20,7 +20,7 @@ data class AuthenticationStoreServer(
|
||||
@SerialName("login_disclaimer") val loginDisclaimer: String? = null,
|
||||
@SerialName("splashscreen_enabled") val splashscreenEnabled: Boolean = false,
|
||||
@SerialName("setup_completed") val setupCompleted: Boolean = true,
|
||||
@SerialName("last_used") val lastUsed: Long = Instant.now().toEpochMilli(),
|
||||
@SerialName("last_refreshed") val lastRefreshed: Long = Instant.now().toEpochMilli(),
|
||||
@SerialName("last_used") val lastUsed: Long = Date().time,
|
||||
@SerialName("last_refreshed") val lastRefreshed: Long = Date().time,
|
||||
val users: Map<UUID, AuthenticationStoreUser> = emptyMap(),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.jellyfin.androidtv.auth.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Instant
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Locally stored user information. New properties require default values or deserialization will fail.
|
||||
@@ -10,7 +10,8 @@ import java.time.Instant
|
||||
@Serializable
|
||||
data class AuthenticationStoreUser(
|
||||
val name: String,
|
||||
@SerialName("last_used") val lastUsed: Long = Instant.now().toEpochMilli(),
|
||||
@SerialName("last_used") val lastUsed: Long = Date().time,
|
||||
@SerialName("require_password") val requirePassword: Boolean = true,
|
||||
@SerialName("image_tag") val imageTag: String? = null,
|
||||
@SerialName("access_token") val accessToken: String? = null,
|
||||
)
|
||||
|
||||
@@ -3,9 +3,9 @@ package org.jellyfin.androidtv.auth.model
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
|
||||
sealed class LoginState
|
||||
data object AuthenticatingState : LoginState()
|
||||
data object RequireSignInState : LoginState()
|
||||
data object ServerUnavailableState : LoginState()
|
||||
object AuthenticatingState : LoginState()
|
||||
object RequireSignInState : LoginState()
|
||||
object ServerUnavailableState : LoginState()
|
||||
data class ServerVersionNotSupported(val server: Server) : LoginState()
|
||||
data class ApiClientErrorLoginState(val error: ApiClientException) : LoginState()
|
||||
data object AuthenticatedState : LoginState()
|
||||
object AuthenticatedState : LoginState()
|
||||
|
||||
@@ -3,14 +3,14 @@ package org.jellyfin.androidtv.auth.model
|
||||
sealed class QuickConnectState
|
||||
|
||||
/**
|
||||
* State unknown until first poll completed.
|
||||
* State unknown untill first poll completed.
|
||||
*/
|
||||
data object UnknownQuickConnectState : QuickConnectState()
|
||||
object UnknownQuickConnectState : QuickConnectState()
|
||||
|
||||
/**
|
||||
* Server does not have QuickConnect enabled.
|
||||
*/
|
||||
data object UnavailableQuickConnectState : QuickConnectState()
|
||||
object UnavailableQuickConnectState : QuickConnectState()
|
||||
|
||||
/**
|
||||
* Connection is pending.
|
||||
@@ -20,4 +20,4 @@ data class PendingQuickConnectState(val code: String) : QuickConnectState()
|
||||
/**
|
||||
* User connected.
|
||||
*/
|
||||
data object ConnectedQuickConnectState : QuickConnectState()
|
||||
object ConnectedQuickConnectState : QuickConnectState()
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.jellyfin.androidtv.auth.model
|
||||
|
||||
import org.jellyfin.androidtv.auth.repository.ServerRepository
|
||||
import org.jellyfin.sdk.model.ServerVersion
|
||||
import java.time.Instant
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -16,9 +16,9 @@ data class Server(
|
||||
val loginDisclaimer: String? = null,
|
||||
val splashscreenEnabled: Boolean = false,
|
||||
val setupCompleted: Boolean = true,
|
||||
var dateLastAccessed: Instant = Instant.MIN,
|
||||
var dateLastAccessed: Date = Date(0),
|
||||
) {
|
||||
val serverVersion = version?.let(ServerVersion::fromString)
|
||||
private val serverVersion = version?.let(ServerVersion::fromString)
|
||||
val versionSupported = serverVersion != null && serverVersion >= ServerRepository.minimumServerVersion
|
||||
|
||||
operator fun compareTo(other: ServerVersion): Int = serverVersion?.compareTo(other) ?: -1
|
||||
|
||||
@@ -10,6 +10,7 @@ sealed class User {
|
||||
abstract val serverId: UUID
|
||||
abstract val name: String
|
||||
abstract val accessToken: String?
|
||||
abstract val requirePassword: Boolean
|
||||
abstract val imageTag: String?
|
||||
|
||||
abstract fun withToken(accessToken: String): User
|
||||
@@ -33,6 +34,7 @@ data class PrivateUser(
|
||||
override val serverId: UUID,
|
||||
override val name: String,
|
||||
override val accessToken: String?,
|
||||
override val requirePassword: Boolean,
|
||||
override val imageTag: String?,
|
||||
val lastUsed: Long,
|
||||
) : User() {
|
||||
@@ -47,6 +49,7 @@ data class PublicUser(
|
||||
override val serverId: UUID,
|
||||
override val name: String,
|
||||
override val accessToken: String?,
|
||||
override val requirePassword: Boolean,
|
||||
override val imageTag: String?,
|
||||
) : User() {
|
||||
override fun withToken(accessToken: String) = copy(accessToken = accessToken)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package org.jellyfin.androidtv.auth.repository
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import org.jellyfin.androidtv.auth.model.ApiClientErrorLoginState
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticateMethod
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticatedState
|
||||
@@ -23,10 +21,7 @@ import org.jellyfin.androidtv.auth.model.ServerVersionNotSupported
|
||||
import org.jellyfin.androidtv.auth.model.User
|
||||
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
|
||||
import org.jellyfin.androidtv.auth.store.AuthenticationStore
|
||||
import org.jellyfin.androidtv.util.apiclient.JellyfinImage
|
||||
import org.jellyfin.androidtv.util.apiclient.JellyfinImageSource
|
||||
import org.jellyfin.androidtv.util.apiclient.getUrl
|
||||
import org.jellyfin.androidtv.util.apiclient.primaryImage
|
||||
import org.jellyfin.androidtv.util.ImageUtils
|
||||
import org.jellyfin.androidtv.util.sdk.forUser
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
@@ -34,13 +29,14 @@ import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.exception.TimeoutException
|
||||
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
||||
import org.jellyfin.sdk.api.client.extensions.authenticateWithQuickConnect
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import org.jellyfin.sdk.model.api.AuthenticationResult
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
import timber.log.Timber
|
||||
import java.time.Instant
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Repository to manage authentication of the user in the app.
|
||||
@@ -60,6 +56,9 @@ class AuthenticationRepositoryImpl(
|
||||
private val defaultDeviceInfo: DeviceInfo,
|
||||
) : AuthenticationRepository {
|
||||
override fun authenticate(server: Server, method: AuthenticateMethod): Flow<LoginState> {
|
||||
// Check server version first
|
||||
if (!server.versionSupported) return flowOf(ServerVersionNotSupported(server))
|
||||
|
||||
return when (method) {
|
||||
is AutomaticAuthenticateMethod -> authenticateAutomatic(server, method.user)
|
||||
is CredentialAuthenticateMethod -> authenticateCredential(server, method.username, method.password)
|
||||
@@ -68,7 +67,7 @@ class AuthenticationRepositoryImpl(
|
||||
}
|
||||
|
||||
private fun authenticateAutomatic(server: Server, user: User): Flow<LoginState> {
|
||||
Timber.i("Authenticating user ${user.id}")
|
||||
Timber.i("Authenticating user %s", user)
|
||||
|
||||
// Automatic logic is disabled when the always authenticate preference is enabled
|
||||
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return flowOf(RequireSignInState)
|
||||
@@ -76,6 +75,8 @@ class AuthenticationRepositoryImpl(
|
||||
val authStoreUser = authenticationStore.getUser(server.id, user.id)
|
||||
// Try login with access token
|
||||
return if (authStoreUser?.accessToken != null) authenticateToken(server, user.withToken(authStoreUser.accessToken))
|
||||
// Try login without password
|
||||
else if (!user.requirePassword) authenticateCredential(server, user.name, "")
|
||||
// Require login
|
||||
else flowOf(RequireSignInState)
|
||||
}
|
||||
@@ -96,7 +97,7 @@ class AuthenticationRepositoryImpl(
|
||||
}
|
||||
|
||||
emitAll(authenticateAuthenticationResult(server, result))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
private fun authenticateQuickConnect(server: Server, secret: String) = flow {
|
||||
val api = jellyfin.createApi(server.address, deviceInfo = defaultDeviceInfo)
|
||||
@@ -114,7 +115,7 @@ class AuthenticationRepositoryImpl(
|
||||
}
|
||||
|
||||
emitAll(authenticateAuthenticationResult(server, result))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
private fun authenticateAuthenticationResult(server: Server, result: AuthenticationResult) = flow {
|
||||
val accessToken = result.accessToken ?: return@flow emit(RequireSignInState)
|
||||
@@ -124,8 +125,9 @@ class AuthenticationRepositoryImpl(
|
||||
serverId = server.id,
|
||||
name = userInfo.name!!,
|
||||
accessToken = result.accessToken,
|
||||
imageTag = userInfo.primaryImage?.tag,
|
||||
lastUsed = Instant.now().toEpochMilli(),
|
||||
requirePassword = userInfo.hasPassword,
|
||||
imageTag = userInfo.primaryImageTag,
|
||||
lastUsed = Date().time,
|
||||
)
|
||||
|
||||
authenticateFinish(server, userInfo, accessToken)
|
||||
@@ -134,18 +136,16 @@ class AuthenticationRepositoryImpl(
|
||||
emit(AuthenticatedState)
|
||||
} else {
|
||||
Timber.w("Failed to set active session after authenticating")
|
||||
if (!server.versionSupported) emit(ServerVersionNotSupported(server))
|
||||
else emit(RequireSignInState)
|
||||
emit(RequireSignInState)
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
private fun authenticateToken(server: Server, user: User) = flow {
|
||||
emit(AuthenticatingState)
|
||||
|
||||
val success = setActiveSession(user, server)
|
||||
if (!success) {
|
||||
if (!server.versionSupported) emit(ServerVersionNotSupported(server))
|
||||
else emit(RequireSignInState)
|
||||
emit(RequireSignInState)
|
||||
} else try {
|
||||
// Update user info
|
||||
val userInfo by userApiClient.userApi.getCurrentUser()
|
||||
@@ -159,19 +159,21 @@ class AuthenticationRepositoryImpl(
|
||||
Timber.e(err, "Unable to get current user data")
|
||||
emit(ApiClientErrorLoginState(err))
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
private suspend fun authenticateFinish(server: Server, userInfo: UserDto, accessToken: String) {
|
||||
val currentUser = authenticationStore.getUser(server.id, userInfo.id)
|
||||
|
||||
val updatedUser = currentUser?.copy(
|
||||
name = userInfo.name!!,
|
||||
lastUsed = Instant.now().toEpochMilli(),
|
||||
imageTag = userInfo.primaryImage?.tag,
|
||||
lastUsed = Date().time,
|
||||
requirePassword = userInfo.hasPassword,
|
||||
imageTag = userInfo.primaryImageTag,
|
||||
accessToken = accessToken,
|
||||
) ?: AuthenticationStoreUser(
|
||||
name = userInfo.name!!,
|
||||
imageTag = userInfo.primaryImage?.tag,
|
||||
requirePassword = userInfo.hasPassword,
|
||||
imageTag = userInfo.primaryImageTag,
|
||||
accessToken = accessToken,
|
||||
)
|
||||
authenticationStore.putUser(server.id, userInfo.id, updatedUser)
|
||||
@@ -183,11 +185,11 @@ class AuthenticationRepositoryImpl(
|
||||
if (authenticated) {
|
||||
// Update last use in store
|
||||
authenticationStore.getServer(server.id)?.let { storedServer ->
|
||||
authenticationStore.putServer(server.id, storedServer.copy(lastUsed = Instant.now().toEpochMilli()))
|
||||
authenticationStore.putServer(server.id, storedServer.copy(lastUsed = Date().time))
|
||||
}
|
||||
|
||||
authenticationStore.getUser(server.id, user.id)?.let { storedUser ->
|
||||
authenticationStore.putUser(server.id, user.id, storedUser.copy(lastUsed = Instant.now().toEpochMilli()))
|
||||
authenticationStore.putUser(server.id, user.id, storedUser.copy(lastUsed = Date().time))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,15 +205,12 @@ class AuthenticationRepositoryImpl(
|
||||
else false
|
||||
}
|
||||
|
||||
override fun getUserImageUrl(server: Server, user: User): String? = user.imageTag?.let { primaryImageTag ->
|
||||
JellyfinImage(
|
||||
item = user.id,
|
||||
source = JellyfinImageSource.USER,
|
||||
type = ImageType.PRIMARY,
|
||||
tag = primaryImageTag,
|
||||
blurHash = null,
|
||||
aspectRatio = null,
|
||||
index = null
|
||||
override fun getUserImageUrl(server: Server, user: User): String? = user.imageTag?.let { tag ->
|
||||
jellyfin.createApi(server.address).imageApi.getUserImageUrl(
|
||||
userId = user.id,
|
||||
tag = tag,
|
||||
imageType = ImageType.PRIMARY,
|
||||
maxHeight = ImageUtils.MAX_PRIMARY_IMAGE_HEIGHT
|
||||
)
|
||||
}?.getUrl(jellyfin.createApi(server.address))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package org.jellyfin.androidtv.auth.repository
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
|
||||
import org.jellyfin.androidtv.auth.model.ConnectedState
|
||||
import org.jellyfin.androidtv.auth.model.ConnectingState
|
||||
@@ -26,11 +23,11 @@ import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||
import org.jellyfin.sdk.discovery.RecommendedServerInfo
|
||||
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
||||
import org.jellyfin.sdk.model.ServerVersion
|
||||
import org.jellyfin.sdk.model.api.BrandingOptionsDto
|
||||
import org.jellyfin.sdk.model.api.BrandingOptions
|
||||
import org.jellyfin.sdk.model.api.ServerDiscoveryInfo
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
import timber.log.Timber
|
||||
import java.time.Instant
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -39,23 +36,20 @@ import java.util.UUID
|
||||
interface ServerRepository {
|
||||
val storedServers: StateFlow<List<Server>>
|
||||
val discoveredServers: StateFlow<List<Server>>
|
||||
val currentServer: StateFlow<Server?>
|
||||
|
||||
suspend fun loadStoredServers()
|
||||
suspend fun loadDiscoveryServers()
|
||||
|
||||
fun setCurrentServer(server: Server?)
|
||||
|
||||
fun addServer(address: String): Flow<ServerAdditionState>
|
||||
suspend fun getServer(id: UUID, eagerUpdate: Boolean = false): Server?
|
||||
suspend fun updateServer(server: Server, force: Boolean = false): Boolean
|
||||
suspend fun getServer(id: UUID): Server?
|
||||
suspend fun updateServer(server: Server): Boolean
|
||||
suspend fun deleteServer(server: UUID): Boolean
|
||||
|
||||
companion object {
|
||||
val minimumServerVersion = Jellyfin.minimumVersion.copy(build = null)
|
||||
val recommendedServerVersion = Jellyfin.apiVersion.copy(build = null)
|
||||
|
||||
val upcomingMinimumServerVersion = ServerVersion(10, 11, 0)
|
||||
val upcomingMinimumServerVersion = ServerVersion(10, 9, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,9 +64,6 @@ class ServerRepositoryImpl(
|
||||
private val _discoveredServers = MutableStateFlow(emptyList<Server>())
|
||||
override val discoveredServers = _discoveredServers.asStateFlow()
|
||||
|
||||
private val _currentServer = MutableStateFlow<Server?>(null)
|
||||
override val currentServer = _currentServer.asStateFlow()
|
||||
|
||||
// Loading data
|
||||
override suspend fun loadStoredServers() {
|
||||
authenticationStore.getServers()
|
||||
@@ -93,10 +84,6 @@ class ServerRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override fun setCurrentServer(server: Server?) {
|
||||
_currentServer.value = server
|
||||
}
|
||||
|
||||
// Mutating data
|
||||
override fun addServer(address: String): Flow<ServerAdditionState> = flow {
|
||||
Timber.i("Adding server %s", address)
|
||||
@@ -108,14 +95,13 @@ class ServerRepositoryImpl(
|
||||
|
||||
val goodRecommendations = mutableListOf<RecommendedServerInfo>()
|
||||
val badRecommendations = mutableListOf<RecommendedServerInfo>()
|
||||
val greatRecommendation = jellyfin.discovery.getRecommendedServers(addressCandidates).firstOrNull { recommendedServer ->
|
||||
val greatRecommendaton = jellyfin.discovery.getRecommendedServers(addressCandidates).firstOrNull { recommendedServer ->
|
||||
when (recommendedServer.score) {
|
||||
RecommendedServerInfoScore.GREAT -> true
|
||||
RecommendedServerInfoScore.GOOD -> {
|
||||
goodRecommendations += recommendedServer
|
||||
false
|
||||
}
|
||||
|
||||
else -> {
|
||||
badRecommendations += recommendedServer
|
||||
false
|
||||
@@ -123,9 +109,9 @@ class ServerRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
Timber.i(buildString {
|
||||
Timber.d(buildString {
|
||||
append("Recommendations: ")
|
||||
if (greatRecommendation == null) append(0)
|
||||
if (greatRecommendaton == null) append(0)
|
||||
else append(1)
|
||||
append(" great, ")
|
||||
append(goodRecommendations.size)
|
||||
@@ -134,7 +120,7 @@ class ServerRepositoryImpl(
|
||||
append(" bad")
|
||||
})
|
||||
|
||||
val chosenRecommendation = greatRecommendation ?: goodRecommendations.firstOrNull()
|
||||
val chosenRecommendation = greatRecommendaton ?: goodRecommendations.firstOrNull()
|
||||
if (chosenRecommendation != null && chosenRecommendation.systemInfo.isSuccess) {
|
||||
// Get system info
|
||||
val systemInfo = chosenRecommendation.systemInfo.getOrThrow()
|
||||
@@ -152,7 +138,7 @@ class ServerRepositoryImpl(
|
||||
loginDisclaimer = branding.loginDisclaimer,
|
||||
splashscreenEnabled = branding.splashscreenEnabled,
|
||||
setupCompleted = systemInfo.startupWizardCompleted ?: true,
|
||||
lastUsed = Instant.now().toEpochMilli()
|
||||
lastUsed = Date().time
|
||||
) ?: AuthenticationStoreServer(
|
||||
name = systemInfo.serverName ?: "Jellyfin Server",
|
||||
address = chosenRecommendation.address,
|
||||
@@ -173,17 +159,13 @@ class ServerRepositoryImpl(
|
||||
.mapValues { (_, entry) -> entry.flatMap { server -> server.issues } }
|
||||
emit(UnableToConnectState(addressCandidatesWithIssues))
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
override suspend fun getServer(id: UUID, eagerUpdate: Boolean): Server? {
|
||||
override suspend fun getServer(id: UUID): Server? {
|
||||
val server = authenticationStore.getServer(id) ?: return null
|
||||
|
||||
val updatedServer = try {
|
||||
val forceUpdate = eagerUpdate && server.version
|
||||
?.let(ServerVersion::fromString)
|
||||
?.let { version -> version < ServerRepository.minimumServerVersion } == true
|
||||
|
||||
updateServerInternal(id, server, forceUpdate)
|
||||
updateServerInternal(id, server)
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to update server")
|
||||
null
|
||||
@@ -192,12 +174,12 @@ class ServerRepositoryImpl(
|
||||
return (updatedServer ?: server).asServer(id)
|
||||
}
|
||||
|
||||
override suspend fun updateServer(server: Server, force: Boolean): Boolean {
|
||||
override suspend fun updateServer(server: Server): Boolean {
|
||||
// Only update existing servers
|
||||
val serverInfo = authenticationStore.getServer(server.id) ?: return false
|
||||
|
||||
return try {
|
||||
updateServerInternal(server.id, serverInfo, force) != null
|
||||
updateServerInternal(server.id, serverInfo) != null
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to update server")
|
||||
|
||||
@@ -205,32 +187,25 @@ class ServerRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateServerInternal(
|
||||
id: UUID,
|
||||
server: AuthenticationStoreServer,
|
||||
forceUpdate: Boolean
|
||||
): AuthenticationStoreServer? {
|
||||
val now = Instant.now().toEpochMilli()
|
||||
private suspend fun updateServerInternal(id: UUID, server: AuthenticationStoreServer): AuthenticationStoreServer? {
|
||||
val now = Date().time
|
||||
|
||||
// Only update every 10 minutes
|
||||
if (now - server.lastRefreshed < 600000 && server.version != null && !forceUpdate) return null
|
||||
if (now - server.lastRefreshed < 600000 && server.version != null) return null
|
||||
|
||||
val newServer = withContext(Dispatchers.IO) {
|
||||
val api = jellyfin.createApi(server.address)
|
||||
val api = jellyfin.createApi(server.address)
|
||||
// Get login disclaimer
|
||||
val branding = api.getBrandingOptionsOrDefault()
|
||||
val systemInfo by api.systemApi.getPublicSystemInfo()
|
||||
|
||||
// Get login disclaimer
|
||||
val branding = api.getBrandingOptionsOrDefault()
|
||||
val systemInfo by api.systemApi.getPublicSystemInfo()
|
||||
|
||||
server.copy(
|
||||
name = systemInfo.serverName ?: server.name,
|
||||
version = systemInfo.version ?: server.version,
|
||||
loginDisclaimer = branding.loginDisclaimer ?: server.loginDisclaimer,
|
||||
splashscreenEnabled = branding.splashscreenEnabled,
|
||||
setupCompleted = systemInfo.startupWizardCompleted ?: server.setupCompleted,
|
||||
lastRefreshed = now
|
||||
)
|
||||
}
|
||||
val newServer = server.copy(
|
||||
name = systemInfo.serverName ?: server.name,
|
||||
version = systemInfo.version ?: server.version,
|
||||
loginDisclaimer = branding.loginDisclaimer ?: server.loginDisclaimer,
|
||||
splashscreenEnabled = branding.splashscreenEnabled,
|
||||
setupCompleted = systemInfo.startupWizardCompleted ?: server.setupCompleted,
|
||||
lastRefreshed = now
|
||||
)
|
||||
authenticationStore.putServer(id, newServer)
|
||||
|
||||
return newServer
|
||||
@@ -251,7 +226,7 @@ class ServerRepositoryImpl(
|
||||
loginDisclaimer = loginDisclaimer,
|
||||
splashscreenEnabled = splashscreenEnabled,
|
||||
setupCompleted = setupCompleted,
|
||||
dateLastAccessed = Instant.ofEpochMilli(lastUsed),
|
||||
dateLastAccessed = Date(lastUsed),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -262,7 +237,7 @@ class ServerRepositoryImpl(
|
||||
brandingApi.getBrandingOptions().content
|
||||
} catch (exception: InvalidContentException) {
|
||||
Timber.w(exception, "Invalid branding options response, using default value")
|
||||
BrandingOptionsDto(
|
||||
BrandingOptions(
|
||||
loginDisclaimer = null,
|
||||
customCss = null,
|
||||
splashscreenEnabled = false,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package org.jellyfin.androidtv.auth.repository
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.auth.model.PrivateUser
|
||||
import org.jellyfin.androidtv.auth.model.PublicUser
|
||||
import org.jellyfin.androidtv.auth.model.Server
|
||||
@@ -37,6 +35,7 @@ class ServerUserRepositoryImpl(
|
||||
serverId = server.id,
|
||||
name = userInfo.name,
|
||||
accessToken = authInfo?.accessToken,
|
||||
requirePassword = userInfo.requirePassword,
|
||||
imageTag = userInfo.imageTag,
|
||||
lastUsed = userInfo.lastUsed,
|
||||
)
|
||||
@@ -49,9 +48,7 @@ class ServerUserRepositoryImpl(
|
||||
val api = jellyfin.createApi(server.address)
|
||||
|
||||
return try {
|
||||
val users = withContext(Dispatchers.IO) {
|
||||
api.userApi.getPublicUsers().content
|
||||
}
|
||||
val users by api.userApi.getPublicUsers()
|
||||
users.mapNotNull(UserDto::toPublicUser)
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to retrieve public users")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.jellyfin.androidtv.auth.repository
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -8,7 +7,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.auth.model.Server
|
||||
import org.jellyfin.androidtv.auth.apiclient.ApiBinder
|
||||
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
|
||||
import org.jellyfin.androidtv.auth.store.AuthenticationStore
|
||||
import org.jellyfin.androidtv.preference.PreferencesRepository
|
||||
@@ -49,6 +48,7 @@ interface SessionRepository {
|
||||
|
||||
class SessionRepositoryImpl(
|
||||
private val authenticationPreferences: AuthenticationPreferences,
|
||||
private val apiBinder: ApiBinder,
|
||||
private val authenticationStore: AuthenticationStore,
|
||||
private val userApiClient: ApiClient,
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
@@ -112,15 +112,13 @@ class SessionRepositoryImpl(
|
||||
override fun destroyCurrentSession() {
|
||||
Timber.i("Destroying current session")
|
||||
|
||||
userRepository.setCurrentUser(null)
|
||||
serverRepository.setCurrentServer(null)
|
||||
userRepository.updateCurrentUser(null)
|
||||
_currentSession.value = null
|
||||
apiBinder.updateSession(null, userApiClient.deviceInfo)
|
||||
_state.value = SessionRepositoryState.READY
|
||||
}
|
||||
|
||||
private suspend fun setCurrentSession(session: Session?): Boolean {
|
||||
var server: Server? = null
|
||||
|
||||
if (session != null) {
|
||||
// No change in session - don't switch
|
||||
if (currentSession.value?.userId == session.userId) return true
|
||||
@@ -130,40 +128,40 @@ class SessionRepositoryImpl(
|
||||
authenticationPreferences[AuthenticationPreferences.lastUserId] = session.userId.toString()
|
||||
|
||||
// Check if server version is supported
|
||||
server = serverRepository.getServer(session.serverId, true)
|
||||
val server = serverRepository.getServer(session.serverId)
|
||||
if (server == null || !server.versionSupported) return false
|
||||
}
|
||||
|
||||
// Update session after binding the apiclient settings
|
||||
val deviceInfo = session?.let { defaultDeviceInfo.forUser(it.userId) } ?: defaultDeviceInfo
|
||||
Timber.i("Updating current session. userId=${session?.userId} server=${server?.serverVersion}")
|
||||
val success = apiBinder.updateSession(session, deviceInfo)
|
||||
Timber.i("Updating current session. userId=${session?.userId} apiBindingSuccess=${success}")
|
||||
|
||||
val applied = userApiClient.applySession(session, deviceInfo)
|
||||
if (applied && session != null) {
|
||||
try {
|
||||
val user = withContext(Dispatchers.IO) {
|
||||
userApiClient.userApi.getCurrentUser().content
|
||||
if (success) {
|
||||
val applied = userApiClient.applySession(session, deviceInfo)
|
||||
|
||||
if (applied && session != null) {
|
||||
// Update crash reporting URL
|
||||
val crashReportUrl = userApiClient.clientLogApi.logFileUrl(includeCredentials = false)
|
||||
telemetryPreferences[TelemetryPreferences.crashReportUrl] = crashReportUrl
|
||||
telemetryPreferences[TelemetryPreferences.crashReportToken] = session.accessToken
|
||||
|
||||
try {
|
||||
val user by userApiClient.userApi.getCurrentUser()
|
||||
userRepository.updateCurrentUser(user)
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to authenticate: bad response when getting user info")
|
||||
destroyCurrentSession()
|
||||
return false
|
||||
}
|
||||
userRepository.setCurrentUser(user)
|
||||
serverRepository.setCurrentServer(server)
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to authenticate: bad response when getting user info")
|
||||
destroyCurrentSession()
|
||||
return false
|
||||
} else {
|
||||
userRepository.updateCurrentUser(null)
|
||||
}
|
||||
preferencesRepository.onSessionChanged()
|
||||
_currentSession.value = session
|
||||
} else destroyCurrentSession()
|
||||
|
||||
// Update crash reporting URL
|
||||
val crashReportUrl = userApiClient.clientLogApi.logFileUrl()
|
||||
telemetryPreferences[TelemetryPreferences.crashReportUrl] = crashReportUrl
|
||||
telemetryPreferences[TelemetryPreferences.crashReportToken] = session.accessToken
|
||||
} else {
|
||||
userRepository.setCurrentUser(null)
|
||||
serverRepository.setCurrentServer(null)
|
||||
}
|
||||
preferencesRepository.onSessionChanged()
|
||||
_currentSession.value = session
|
||||
|
||||
return true
|
||||
return success
|
||||
}
|
||||
|
||||
private fun createLastUserSession(): Session? {
|
||||
@@ -186,21 +184,18 @@ class SessionRepositoryImpl(
|
||||
}
|
||||
|
||||
private fun ApiClient.applySession(session: Session?, newDeviceInfo: DeviceInfo = defaultDeviceInfo): Boolean {
|
||||
deviceInfo = newDeviceInfo
|
||||
|
||||
if (session == null) {
|
||||
update(
|
||||
baseUrl = null,
|
||||
accessToken = null,
|
||||
deviceInfo = newDeviceInfo,
|
||||
)
|
||||
baseUrl = null
|
||||
accessToken = null
|
||||
userId = null
|
||||
} else {
|
||||
val server = authenticationStore.getServer(session.serverId)
|
||||
?: return false
|
||||
|
||||
update(
|
||||
baseUrl = server.address,
|
||||
accessToken = session.accessToken,
|
||||
deviceInfo = newDeviceInfo,
|
||||
)
|
||||
baseUrl = server.address
|
||||
accessToken = session.accessToken
|
||||
userId = session.userId
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
@@ -10,13 +10,13 @@ import org.jellyfin.sdk.model.api.UserDto
|
||||
interface UserRepository {
|
||||
val currentUser: StateFlow<UserDto?>
|
||||
|
||||
fun setCurrentUser(user: UserDto?)
|
||||
fun updateCurrentUser(user: UserDto?)
|
||||
}
|
||||
|
||||
class UserRepositoryImpl : UserRepository {
|
||||
override val currentUser = MutableStateFlow<UserDto?>(null)
|
||||
|
||||
override fun setCurrentUser(user: UserDto?) {
|
||||
currentUser.value = user
|
||||
override fun updateCurrentUser(user: UserDto?) {
|
||||
if (currentUser.value !== user) currentUser.value = user
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package org.jellyfin.androidtv.auth.service
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountAuthenticatorResponse
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import androidx.core.os.bundleOf
|
||||
import org.jellyfin.androidtv.ui.preference.PreferencesActivity
|
||||
|
||||
class AuthenticatorService : Service() {
|
||||
private val authenticator by lazy {
|
||||
Authenticator(this)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? = authenticator.iBinder
|
||||
|
||||
private inner class Authenticator(
|
||||
private val service: AuthenticatorService
|
||||
) : AbstractAccountAuthenticator(service) {
|
||||
private val unsupportedOperationBundle = bundleOf(
|
||||
AccountManager.KEY_ERROR_CODE to AccountManager.ERROR_CODE_REMOTE_EXCEPTION,
|
||||
AccountManager.KEY_ERROR_MESSAGE to "Unsupported operation"
|
||||
)
|
||||
|
||||
override fun addAccount(
|
||||
response: AccountAuthenticatorResponse,
|
||||
accountType: String,
|
||||
authTokenType: String?,
|
||||
requiredFeatures: Array<String>?,
|
||||
options: Bundle,
|
||||
): Bundle = unsupportedOperationBundle
|
||||
|
||||
override fun confirmCredentials(
|
||||
response: AccountAuthenticatorResponse,
|
||||
account: Account,
|
||||
options: Bundle?,
|
||||
): Bundle = unsupportedOperationBundle
|
||||
|
||||
override fun editProperties(
|
||||
response: AccountAuthenticatorResponse,
|
||||
accountType: String,
|
||||
): Bundle = bundleOf(
|
||||
AccountManager.KEY_INTENT to Intent(service, PreferencesActivity::class.java)
|
||||
)
|
||||
|
||||
override fun getAccountRemovalAllowed(
|
||||
response: AccountAuthenticatorResponse,
|
||||
account: Account,
|
||||
): Bundle = bundleOf(
|
||||
AccountManager.KEY_BOOLEAN_RESULT to true
|
||||
)
|
||||
|
||||
override fun getAuthToken(
|
||||
response: AccountAuthenticatorResponse,
|
||||
account: Account,
|
||||
authTokenType: String,
|
||||
options: Bundle,
|
||||
): Bundle = unsupportedOperationBundle
|
||||
|
||||
override fun getAuthTokenLabel(authTokenType: String): String? = null
|
||||
|
||||
override fun hasFeatures(
|
||||
response: AccountAuthenticatorResponse,
|
||||
account: Account,
|
||||
features: Array<String>,
|
||||
): Bundle = unsupportedOperationBundle
|
||||
|
||||
override fun updateCredentials(
|
||||
response: AccountAuthenticatorResponse,
|
||||
account: Account,
|
||||
authTokenType: String?,
|
||||
options: Bundle?,
|
||||
): Bundle = unsupportedOperationBundle
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.jellyfin.androidtv.auth.store
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
@@ -12,6 +13,7 @@ import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.contextual
|
||||
import org.jellyfin.androidtv.auth.AccountManagerMigration
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticationStoreUser
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
@@ -26,6 +28,7 @@ import java.util.UUID
|
||||
*/
|
||||
class AuthenticationStore(
|
||||
private val context: Context,
|
||||
private val accountManagerMigration: AccountManagerMigration,
|
||||
) {
|
||||
private val storePath
|
||||
get() = context.filesDir.resolve("authentication_store.json")
|
||||
@@ -56,13 +59,11 @@ class AuthenticationStore(
|
||||
|
||||
// Check for version
|
||||
return when (root["version"]?.jsonPrimitive?.intOrNull) {
|
||||
// Migration was removed, clear stored servers
|
||||
1 -> {
|
||||
Timber.e("Migrating from version 1 is no longer possible")
|
||||
emptyMap()
|
||||
}
|
||||
1 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
|
||||
// Add access tokens from account manager to stored users and save the migrated data
|
||||
.let { servers -> accountManagerMigration.migrate(servers) }
|
||||
.also { servers -> write(servers) }
|
||||
|
||||
// Current version, return as-is
|
||||
2 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
|
||||
|
||||
null -> {
|
||||
|
||||
@@ -4,6 +4,9 @@ enum class ChangeTriggerType {
|
||||
LibraryUpdated,
|
||||
MoviePlayback,
|
||||
TvPlayback,
|
||||
GuideNeedsLoad,
|
||||
MusicPlayback,
|
||||
Always,
|
||||
VideoQueueChange,
|
||||
FavoriteUpdate,
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ object Codec {
|
||||
const val ASF = "asf"
|
||||
const val AVI = "avi"
|
||||
const val DVR_MS = "dvr-ms"
|
||||
const val HLS = "hls"
|
||||
const val M2V = "m2v"
|
||||
const val M4V = "m4v"
|
||||
const val MKV = "mkv"
|
||||
@@ -48,10 +47,8 @@ object Codec {
|
||||
const val PCM_ALAW = "pcm_alaw"
|
||||
const val PCM_MULAW = "pcm_mulaw"
|
||||
const val PCM_S16LE = "pcm_s16le"
|
||||
const val PCM_S20LE = "pcm_s20le"
|
||||
const val PCM_S24LE = "pcm_s24le"
|
||||
const val TRUEHD = "truehd"
|
||||
const val VORBIS = "vorbis"
|
||||
const val WAV = "wav"
|
||||
const val WEBMA = "webma"
|
||||
const val WMA = "wma"
|
||||
@@ -66,7 +63,6 @@ object Codec {
|
||||
const val VP8 = "vp8"
|
||||
const val VP9 = "vp9"
|
||||
const val AV1 = "av1"
|
||||
const val VC1 = "vc1"
|
||||
}
|
||||
|
||||
object Subtitle {
|
||||
|
||||
@@ -2,6 +2,5 @@ package org.jellyfin.androidtv.constant
|
||||
|
||||
object Extras {
|
||||
const val Folder = "folder"
|
||||
const val IsLiveTvSeriesRecordings = "is_livetv_series_recordings"
|
||||
const val IncludeType = "type_include"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import org.jellyfin.androidtv.R
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private val qualityOptions = setOf(
|
||||
200.0, 180.0, 140.0, 120.0, 110.0, 100.0, // 100 >=
|
||||
0.0, // auto
|
||||
120.0, 110.0, 100.0, // 100 >=
|
||||
90.0, 80.0, 70.0, 60.0, 50.0, 40.0, 30.0, 20.0, 15.0, 10.0, // 10 >=
|
||||
5.0, 3.0, 2.0, 1.0, // 1 >=
|
||||
0.72, 0.42 // 0 >=
|
||||
@@ -16,6 +17,7 @@ fun getQualityProfiles(
|
||||
context: Context
|
||||
): Map<String, String> = qualityOptions.associate {
|
||||
val value = when {
|
||||
it == 0.0 -> context.getString(R.string.bitrate_auto)
|
||||
it >= 1.0 -> context.getString(R.string.bitrate_mbit, it)
|
||||
else -> context.getString(R.string.bitrate_kbit, it * 1000.0)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ enum class QueryType {
|
||||
LiveTvProgram,
|
||||
LiveTvRecording,
|
||||
StaticItems,
|
||||
Persons,
|
||||
StaticAudioQueueItems,
|
||||
Artists,
|
||||
AlbumArtists,
|
||||
AudioPlaylists,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package org.jellyfin.androidtv.data.compat
|
||||
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.apiclient.model.dlna.DeviceProfile
|
||||
import org.jellyfin.apiclient.model.dlna.EncodingContext
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import java.util.UUID
|
||||
|
||||
open class AudioOptions {
|
||||
var enableDirectPlay = true
|
||||
var enableDirectStream = true
|
||||
var itemId: UUID? = null
|
||||
var itemId: String? = null
|
||||
var mediaSources: List<MediaSourceInfo>? = null
|
||||
var profile: DeviceProfile? = null
|
||||
|
||||
@@ -21,4 +21,11 @@ open class AudioOptions {
|
||||
* Example: DeviceProfile supports five channel, but user only has stereo speakers
|
||||
*/
|
||||
var maxAudioChannels: Int? = null
|
||||
|
||||
/**
|
||||
* The application's configured quality setting
|
||||
*/
|
||||
var maxBitrate: Int? = null
|
||||
|
||||
var context = EncodingContext.Streaming
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.jellyfin.androidtv.data.compat
|
||||
|
||||
import org.jellyfin.sdk.model.api.PlaybackErrorCode
|
||||
import org.jellyfin.apiclient.model.dlna.PlaybackErrorCode
|
||||
|
||||
class PlaybackException : RuntimeException() {
|
||||
var errorCode = PlaybackErrorCode.NOT_ALLOWED
|
||||
var errorCode = PlaybackErrorCode.NotAllowed
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod;
|
||||
import org.jellyfin.apiclient.model.dlna.SubtitleProfile;
|
||||
import org.jellyfin.apiclient.model.entities.MediaStream;
|
||||
import org.jellyfin.apiclient.model.session.PlayMethod;
|
||||
|
||||
public class StreamBuilder
|
||||
{
|
||||
public static SubtitleProfile getSubtitleProfile(org.jellyfin.sdk.model.api.MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod)
|
||||
{
|
||||
if (playMethod != PlayMethod.Transcode && !subtitleStream.isExternal())
|
||||
{
|
||||
// Look for supported embedded subs
|
||||
for (SubtitleProfile profile : subtitleProfiles)
|
||||
{
|
||||
if (!profile.SupportsLanguage(subtitleStream.getLanguage()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (profile.getMethod() != SubtitleDeliveryMethod.Embed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (subtitleStream.isTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat()) && Utils.equalsIgnoreCase(profile.getFormat(), subtitleStream.getCodec()))
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for an external or hls profile that matches the stream type (text/graphical) and doesn't require conversion
|
||||
SubtitleProfile tempVar = new SubtitleProfile();
|
||||
tempVar.setMethod(SubtitleDeliveryMethod.Encode);
|
||||
tempVar.setFormat(subtitleStream.getCodec());
|
||||
SubtitleProfile tempVar2 = getExternalSubtitleProfile(subtitleStream, subtitleProfiles, playMethod, false);
|
||||
SubtitleProfile tempVar3 = getExternalSubtitleProfile(subtitleStream, subtitleProfiles, playMethod, true);
|
||||
return (tempVar2 != null) ? tempVar2 : (tempVar3 != null) ? tempVar3 : tempVar;
|
||||
}
|
||||
|
||||
private static SubtitleProfile getExternalSubtitleProfile(org.jellyfin.sdk.model.api.MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod, boolean allowConversion)
|
||||
{
|
||||
for (SubtitleProfile profile : subtitleProfiles)
|
||||
{
|
||||
if (profile.getMethod() != SubtitleDeliveryMethod.External && profile.getMethod() != SubtitleDeliveryMethod.Hls)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (profile.getMethod() == SubtitleDeliveryMethod.Hls && playMethod != PlayMethod.Transcode)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!profile.SupportsLanguage(subtitleStream.getLanguage()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((profile.getMethod() == SubtitleDeliveryMethod.External && subtitleStream.isTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat())) || (profile.getMethod() == SubtitleDeliveryMethod.Hls && subtitleStream.isTextSubtitleStream()))
|
||||
{
|
||||
boolean requiresConversion = !Utils.equalsIgnoreCase(subtitleStream.getCodec(), profile.getFormat());
|
||||
|
||||
if (!requiresConversion)
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
|
||||
if (!allowConversion)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (subtitleStream.isTextSubtitleStream() && subtitleStream.getSupportsExternalStream() && supportsSubtitleConversionTo(subtitleStream, profile.getFormat()))
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static boolean supportsSubtitleConversionTo(org.jellyfin.sdk.model.api.MediaStream mediaStream, String codec)
|
||||
{
|
||||
if (!mediaStream.isTextSubtitleStream())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Can't convert from this
|
||||
return !("ass".equalsIgnoreCase(mediaStream.getCodec()) || "ssa".equalsIgnoreCase(mediaStream.getCodec()) || "ass".equalsIgnoreCase(codec) || "ssa".equalsIgnoreCase(codec));
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,37 @@
|
||||
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.DlnaProfileType;
|
||||
import org.jellyfin.apiclient.model.dlna.EncodingContext;
|
||||
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod;
|
||||
import org.jellyfin.apiclient.model.dlna.SubtitleProfile;
|
||||
import org.jellyfin.apiclient.model.dlna.TranscodeSeekInfo;
|
||||
import org.jellyfin.apiclient.model.dto.NameValuePair;
|
||||
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.PlayMethod;
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
|
||||
public class StreamInfo {
|
||||
private UUID ItemId;
|
||||
private static final String START_TIME_TICKS = "StartTimeTicks";
|
||||
private static final String SUBTITLE_STREAM_INDEX = "SubtitleStreamIndex";
|
||||
private static final String STATIC = "Static";
|
||||
|
||||
public final UUID getItemId() {
|
||||
public StreamInfo() {
|
||||
setAudioCodecs(new String[]{});
|
||||
}
|
||||
|
||||
private String ItemId;
|
||||
|
||||
public final String getItemId() {
|
||||
return ItemId;
|
||||
}
|
||||
|
||||
public final void setItemId(UUID value) {
|
||||
public final void setItemId(String value) {
|
||||
ItemId = value;
|
||||
}
|
||||
|
||||
@@ -30,14 +45,34 @@ public class StreamInfo {
|
||||
MediaUrl = value;
|
||||
}
|
||||
|
||||
private PlayMethod playMethod = PlayMethod.DIRECT_PLAY;
|
||||
private PlayMethod PlayMethod = getPlayMethod().values()[0];
|
||||
|
||||
public final PlayMethod getPlayMethod() {
|
||||
return playMethod;
|
||||
return PlayMethod;
|
||||
}
|
||||
|
||||
public final void setPlayMethod(PlayMethod value) {
|
||||
playMethod = value;
|
||||
PlayMethod = value;
|
||||
}
|
||||
|
||||
private EncodingContext Context = EncodingContext.values()[0];
|
||||
|
||||
public final EncodingContext getContext() {
|
||||
return Context;
|
||||
}
|
||||
|
||||
public final void setContext(EncodingContext value) {
|
||||
Context = value;
|
||||
}
|
||||
|
||||
private DlnaProfileType MediaType = DlnaProfileType.values()[0];
|
||||
|
||||
public final DlnaProfileType getMediaType() {
|
||||
return MediaType;
|
||||
}
|
||||
|
||||
public final void setMediaType(DlnaProfileType value) {
|
||||
MediaType = value;
|
||||
}
|
||||
|
||||
private String Container;
|
||||
@@ -50,6 +85,46 @@ public class StreamInfo {
|
||||
Container = value;
|
||||
}
|
||||
|
||||
private long StartPositionTicks;
|
||||
|
||||
public final long getStartPositionTicks() {
|
||||
return StartPositionTicks;
|
||||
}
|
||||
|
||||
public final void setStartPositionTicks(long value) {
|
||||
StartPositionTicks = value;
|
||||
}
|
||||
|
||||
private String[] AudioCodecs;
|
||||
|
||||
public final String[] getAudioCodecs() {
|
||||
return AudioCodecs;
|
||||
}
|
||||
|
||||
public final void setAudioCodecs(String[] value) {
|
||||
AudioCodecs = value;
|
||||
}
|
||||
|
||||
private DeviceProfile DeviceProfile;
|
||||
|
||||
public final DeviceProfile getDeviceProfile() {
|
||||
return DeviceProfile;
|
||||
}
|
||||
|
||||
public final void setDeviceProfile(DeviceProfile value) {
|
||||
DeviceProfile = value;
|
||||
}
|
||||
|
||||
private String DeviceId;
|
||||
|
||||
public final String getDeviceId() {
|
||||
return DeviceId;
|
||||
}
|
||||
|
||||
public final void setDeviceId(String value) {
|
||||
DeviceId = value;
|
||||
}
|
||||
|
||||
private Long RunTimeTicks = null;
|
||||
|
||||
public final Long getRunTimeTicks() {
|
||||
@@ -60,6 +135,12 @@ public class StreamInfo {
|
||||
RunTimeTicks = value;
|
||||
}
|
||||
|
||||
private TranscodeSeekInfo TranscodeSeekInfo = getTranscodeSeekInfo().values()[0];
|
||||
|
||||
public final TranscodeSeekInfo getTranscodeSeekInfo() {
|
||||
return TranscodeSeekInfo;
|
||||
}
|
||||
|
||||
private MediaSourceInfo MediaSource;
|
||||
|
||||
public final MediaSourceInfo getMediaSource() {
|
||||
@@ -70,10 +151,10 @@ public class StreamInfo {
|
||||
MediaSource = value;
|
||||
}
|
||||
|
||||
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 SubtitleDeliveryMethod SubtitleDeliveryMethod = getSubtitleDeliveryMethod().values()[0];
|
||||
|
||||
public final SubtitleDeliveryMethod getSubtitleDeliveryMethod() {
|
||||
return SubtitleDeliveryMethod;
|
||||
}
|
||||
|
||||
private String PlaySessionId;
|
||||
@@ -90,6 +171,185 @@ public class StreamInfo {
|
||||
return getMediaSource() == null ? null : getMediaSource().getId();
|
||||
}
|
||||
|
||||
public final boolean getIsDirectStream() {
|
||||
return getPlayMethod() == PlayMethod.DirectStream || getPlayMethod() == PlayMethod.DirectPlay;
|
||||
}
|
||||
|
||||
public final String toUrl(String baseUrl, String accessToken) {
|
||||
if (!Utils.isEmpty(getMediaUrl())) {
|
||||
return getMediaUrl();
|
||||
}
|
||||
|
||||
if (getPlayMethod() == PlayMethod.DirectPlay) {
|
||||
return getMediaSource().getPath();
|
||||
}
|
||||
|
||||
if (Utils.isEmpty(baseUrl)) {
|
||||
throw new IllegalArgumentException(baseUrl);
|
||||
}
|
||||
|
||||
ArrayList<String> list = new ArrayList<String>();
|
||||
for (NameValuePair pair : buildParams(this, accessToken, false)) {
|
||||
if (Utils.isEmpty(pair.getValue())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to keep the url clean by omitting defaults
|
||||
if (START_TIME_TICKS.equalsIgnoreCase(pair.getName()) && "0".equalsIgnoreCase(pair.getValue())) {
|
||||
continue;
|
||||
}
|
||||
if (SUBTITLE_STREAM_INDEX.equalsIgnoreCase(pair.getName()) && "-1".equalsIgnoreCase(pair.getValue())) {
|
||||
continue;
|
||||
}
|
||||
if (STATIC.equalsIgnoreCase(pair.getName()) && "false".equalsIgnoreCase(pair.getValue())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
list.add(String.format("%1$s=%2$s", pair.getName(), pair.getValue()));
|
||||
}
|
||||
|
||||
String queryString = Utils.join("&", list);
|
||||
|
||||
return getUrl(baseUrl, queryString);
|
||||
}
|
||||
|
||||
private String getUrl(String baseUrl, String queryString) {
|
||||
if (Utils.isEmpty(baseUrl)) {
|
||||
throw new IllegalArgumentException(baseUrl);
|
||||
}
|
||||
|
||||
String extension = Utils.isEmpty(getContainer()) ? "" : "." + getContainer();
|
||||
|
||||
// remove trailing slashes
|
||||
baseUrl = baseUrl.replaceAll("[/]+$", "");
|
||||
|
||||
if (getMediaType() == DlnaProfileType.Audio) {
|
||||
return String.format("%1$s/audio/%2$s/stream%3$s?%4$s", baseUrl, getItemId(), extension, queryString);
|
||||
}
|
||||
|
||||
return String.format("%1$s/videos/%2$s/stream%3$s?%4$s", baseUrl, getItemId(), extension, queryString);
|
||||
}
|
||||
|
||||
private static ArrayList<NameValuePair> buildParams(StreamInfo item, String accessToken, boolean isDlna) {
|
||||
ArrayList<NameValuePair> list = new ArrayList<NameValuePair>();
|
||||
|
||||
String audioCodecs = item.getAudioCodecs().length == 0 ? "" : Utils.join(",", item.getAudioCodecs());
|
||||
|
||||
list.add(new NameValuePair("DeviceProfileId", ""));
|
||||
String tempVar2 = item.getDeviceId();
|
||||
list.add(new NameValuePair("DeviceId", (tempVar2 != null) ? tempVar2 : ""));
|
||||
String tempVar3 = item.getMediaSourceId();
|
||||
list.add(new NameValuePair("MediaSourceId", (tempVar3 != null) ? tempVar3 : ""));
|
||||
list.add(new NameValuePair(STATIC, Boolean.valueOf(item.getIsDirectStream()).toString().toLowerCase()));
|
||||
list.add(new NameValuePair("VideoCodec", ""));
|
||||
list.add(new NameValuePair("AudioCodec", audioCodecs));
|
||||
list.add(new NameValuePair("AudioStreamIndex", ""));
|
||||
list.add(new NameValuePair("SubtitleStreamIndex", ""));
|
||||
list.add(new NameValuePair("VideoBitrate", ""));
|
||||
list.add(new NameValuePair("AudioBitrate", ""));
|
||||
list.add(new NameValuePair("MaxAudioChannels", ""));
|
||||
list.add(new NameValuePair("MaxFramerate", ""));
|
||||
list.add(new NameValuePair("MaxWidth", ""));
|
||||
list.add(new NameValuePair("MaxHeight", ""));
|
||||
|
||||
list.add(new NameValuePair(START_TIME_TICKS, String.valueOf(item.getStartPositionTicks())));
|
||||
|
||||
list.add(new NameValuePair("Level", ""));
|
||||
|
||||
list.add(new NameValuePair("MaxRefFrames", ""));
|
||||
list.add(new NameValuePair("MaxVideoBitDepth", ""));
|
||||
list.add(new NameValuePair("Profile", ""));
|
||||
|
||||
// no longer used
|
||||
list.add(new NameValuePair("Cabac", ""));
|
||||
|
||||
String tempVar6 = item.getPlaySessionId();
|
||||
list.add(new NameValuePair("PlaySessionId", (tempVar6 != null) ? tempVar6 : ""));
|
||||
list.add(new NameValuePair("api_key", (accessToken != null) ? accessToken : ""));
|
||||
|
||||
String liveStreamId = item.getMediaSource() == null ? null : item.getMediaSource().getLiveStreamId();
|
||||
list.add(new NameValuePair("LiveStreamId", (liveStreamId != null) ? liveStreamId : ""));
|
||||
|
||||
if (isDlna) {
|
||||
list.add(new NameValuePair("ItemId", item.getItemId()));
|
||||
}
|
||||
|
||||
list.add(new NameValuePair("CopyTimestamps", "false"));
|
||||
list.add(new NameValuePair("ForceLiveStream", "false"));
|
||||
list.add(new NameValuePair("SubtitleMethod", ""));
|
||||
|
||||
list.add(new NameValuePair("TranscodingMaxAudioChannels", ""));
|
||||
list.add(new NameValuePair("EnableSubtitlesInManifest", "false"));
|
||||
|
||||
String tempVar7 = item.getMediaSource().getETag();
|
||||
list.add(new NameValuePair("Tag", (tempVar7 != null) ? tempVar7 : ""));
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public final ArrayList<SubtitleStreamInfo> getSubtitleProfiles(boolean includeSelectedTrackOnly, String baseUrl, String accessToken) {
|
||||
return getSubtitleProfiles(includeSelectedTrackOnly, false, baseUrl, accessToken);
|
||||
}
|
||||
|
||||
public final ArrayList<SubtitleStreamInfo> getSubtitleProfiles(boolean includeSelectedTrackOnly, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
SubtitleProfile subtitleProfile = StreamBuilder.getSubtitleProfile(stream, subtitleProfiles, getPlayMethod());
|
||||
SubtitleStreamInfo tempVar = 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());
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
public final ArrayList<MediaStream> getSelectableAudioStreams() {
|
||||
return getSelectableStreams(MediaStreamType.AUDIO);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.jellyfin.androidtv.data.compat
|
||||
|
||||
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod
|
||||
|
||||
class SubtitleStreamInfo {
|
||||
var url: String? = null
|
||||
var name: String? = null
|
||||
var format: String? = null
|
||||
var displayTitle: String? = null
|
||||
var index = 0
|
||||
var deliveryMethod = SubtitleDeliveryMethod.Encode
|
||||
}
|
||||
@@ -2,160 +2,114 @@ package org.jellyfin.androidtv.data.eventhandling
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.data.model.DataRefreshService
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
|
||||
import org.jellyfin.androidtv.ui.navigation.Destinations
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
|
||||
import org.jellyfin.androidtv.ui.playback.MediaManager
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
|
||||
import org.jellyfin.androidtv.ui.playback.setSubtitleIndex
|
||||
import org.jellyfin.androidtv.util.PlaybackHelper
|
||||
import org.jellyfin.androidtv.ui.playback.rewrite.RewriteMediaManager
|
||||
import org.jellyfin.androidtv.util.apiclient.PlaybackHelper
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.sessionApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.sockets.subscribe
|
||||
import org.jellyfin.sdk.api.sockets.subscribeGeneralCommand
|
||||
import org.jellyfin.sdk.api.sockets.subscribeGeneralCommands
|
||||
import org.jellyfin.sdk.api.sockets.SocketInstance
|
||||
import org.jellyfin.sdk.api.sockets.SocketInstanceState
|
||||
import org.jellyfin.sdk.api.sockets.addGeneralCommandsListener
|
||||
import org.jellyfin.sdk.api.sockets.addListener
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.GeneralCommandType
|
||||
import org.jellyfin.sdk.model.api.LibraryChangedMessage
|
||||
import org.jellyfin.sdk.model.api.LibraryUpdateInfo
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.api.PlayMessage
|
||||
import org.jellyfin.sdk.model.api.PlaystateCommand
|
||||
import org.jellyfin.sdk.model.api.PlaystateMessage
|
||||
import org.jellyfin.sdk.model.extensions.get
|
||||
import org.jellyfin.sdk.model.constant.MediaType
|
||||
import org.jellyfin.sdk.model.extensions.getValue
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import org.jellyfin.sdk.model.socket.LibraryChangedMessage
|
||||
import org.jellyfin.sdk.model.socket.PlayMessage
|
||||
import org.jellyfin.sdk.model.socket.PlayStateMessage
|
||||
import timber.log.Timber
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class SocketHandler(
|
||||
private val context: Context,
|
||||
private val api: ApiClient,
|
||||
private val socketInstance: SocketInstance,
|
||||
private val dataRefreshService: DataRefreshService,
|
||||
private val mediaManager: MediaManager,
|
||||
private val playbackControllerContainer: PlaybackControllerContainer,
|
||||
private val navigationRepository: NavigationRepository,
|
||||
private val audioManager: AudioManager,
|
||||
private val itemLauncher: ItemLauncher,
|
||||
private val playbackHelper: PlaybackHelper,
|
||||
private val lifecycle: Lifecycle,
|
||||
) {
|
||||
init {
|
||||
lifecycle.coroutineScope.launch(Dispatchers.IO) {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
subscribe(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.IO)
|
||||
val state = socketInstance.state
|
||||
|
||||
suspend fun updateSession() {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
api.sessionApi.postCapabilities(
|
||||
playableMediaTypes = listOf(MediaType.VIDEO, MediaType.AUDIO),
|
||||
supportsMediaControl = true,
|
||||
supportedCommands = buildList {
|
||||
add(GeneralCommandType.DISPLAY_CONTENT)
|
||||
add(GeneralCommandType.SET_SUBTITLE_STREAM_INDEX)
|
||||
add(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
|
||||
api.sessionApi.postCapabilities(
|
||||
playableMediaTypes = listOf(MediaType.Video, MediaType.Audio),
|
||||
supportsMediaControl = true,
|
||||
supportedCommands = buildList {
|
||||
add(GeneralCommandType.DISPLAY_CONTENT)
|
||||
|
||||
add(GeneralCommandType.DISPLAY_MESSAGE)
|
||||
add(GeneralCommandType.SEND_STRING)
|
||||
add(GeneralCommandType.DISPLAY_MESSAGE)
|
||||
add(GeneralCommandType.SEND_STRING)
|
||||
|
||||
// Note: These are used in the PlaySessionSocketService
|
||||
if (!audioManager.isVolumeFixed) {
|
||||
add(GeneralCommandType.VOLUME_UP)
|
||||
add(GeneralCommandType.VOLUME_DOWN)
|
||||
add(GeneralCommandType.SET_VOLUME)
|
||||
// Note: These are used in the PlaySessionSocketService
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !audioManager.isVolumeFixed) {
|
||||
add(GeneralCommandType.VOLUME_UP)
|
||||
add(GeneralCommandType.VOLUME_DOWN)
|
||||
add(GeneralCommandType.SET_VOLUME)
|
||||
|
||||
add(GeneralCommandType.MUTE)
|
||||
add(GeneralCommandType.UNMUTE)
|
||||
add(GeneralCommandType.TOGGLE_MUTE)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
add(GeneralCommandType.MUTE)
|
||||
add(GeneralCommandType.UNMUTE)
|
||||
add(GeneralCommandType.TOGGLE_MUTE)
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to update capabilities")
|
||||
return
|
||||
}
|
||||
|
||||
val isOffline = state.value == SocketInstanceState.DISCONNECTED || state.value == SocketInstanceState.ERROR
|
||||
if (isOffline) socketInstance.reconnect()
|
||||
else socketInstance.updateCredentials()
|
||||
}
|
||||
|
||||
private fun subscribe(coroutineScope: CoroutineScope) = api.webSocket.apply {
|
||||
// Library
|
||||
subscribe<LibraryChangedMessage>()
|
||||
.onEach { message -> message.data?.let(::onLibraryChanged) }
|
||||
.launchIn(coroutineScope)
|
||||
init {
|
||||
socketInstance.apply {
|
||||
// Library
|
||||
addListener<LibraryChangedMessage> { message -> onLibraryChanged(message.info) }
|
||||
|
||||
// Media playback
|
||||
subscribe<PlayMessage>()
|
||||
.onEach { message -> onPlayMessage(message) }
|
||||
.launchIn(coroutineScope)
|
||||
// Media playback
|
||||
addListener<PlayMessage> { message -> onPlayMessage(message) }
|
||||
addListener<PlayStateMessage> { message -> onPlayStateMessage(message) }
|
||||
|
||||
subscribe<PlaystateMessage>()
|
||||
.onEach { message -> onPlayStateMessage(message) }
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
subscribeGeneralCommand(GeneralCommandType.SET_SUBTITLE_STREAM_INDEX)
|
||||
.onEach { message ->
|
||||
val index = message["index"]?.toIntOrNull() ?: return@onEach
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
playbackControllerContainer.playbackController?.setSubtitleIndex(index)
|
||||
}
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
subscribeGeneralCommand(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
|
||||
.onEach { message ->
|
||||
val index = message["index"]?.toIntOrNull() ?: return@onEach
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
playbackControllerContainer.playbackController?.switchAudioStream(index)
|
||||
}
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
// General commands
|
||||
subscribeGeneralCommand(GeneralCommandType.DISPLAY_CONTENT)
|
||||
.onEach { message ->
|
||||
// General commands
|
||||
addGeneralCommandsListener(setOf(GeneralCommandType.DISPLAY_CONTENT)) { message ->
|
||||
val itemId by message
|
||||
val itemType by message
|
||||
|
||||
val itemUuid = itemId?.toUUIDOrNull()
|
||||
val itemKind = itemType?.let { type ->
|
||||
BaseItemKind.entries.find { value ->
|
||||
value.serialName.equals(type, true)
|
||||
}
|
||||
}
|
||||
val itemKind = itemType?.let { type -> BaseItemKind.values().find { value -> value.serialName.equals(type, true) } }
|
||||
|
||||
if (itemUuid != null && itemKind != null) onDisplayContent(itemUuid, itemKind)
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
subscribeGeneralCommands(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING))
|
||||
.onEach { message ->
|
||||
addGeneralCommandsListener(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING)) { message ->
|
||||
val header by message
|
||||
val text by message
|
||||
val string by message
|
||||
|
||||
onDisplayMessage(header, text ?: string)
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLibraryChanged(info: LibraryUpdateInfo) {
|
||||
@@ -167,70 +121,80 @@ class SocketHandler(
|
||||
})
|
||||
|
||||
if (info.itemsAdded.any() || info.itemsRemoved.any())
|
||||
dataRefreshService.lastLibraryChange = Instant.now()
|
||||
dataRefreshService.lastLibraryChange = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
private fun onPlayMessage(message: PlayMessage) {
|
||||
val itemIds = message.data?.itemIds ?: return
|
||||
val itemId = message.request.itemIds?.firstOrNull() ?: return
|
||||
|
||||
runCatching {
|
||||
playbackHelper.retrieveAndPlay(
|
||||
itemIds,
|
||||
PlaybackHelper.retrieveAndPlay(
|
||||
itemId.toString(),
|
||||
false,
|
||||
message.data?.startPositionTicks,
|
||||
message.data?.startIndex,
|
||||
message.request.startPositionTicks,
|
||||
context
|
||||
)
|
||||
}.onFailure { Timber.w(it, "Failed to start playback") }
|
||||
}
|
||||
|
||||
@Suppress("ComplexMethod")
|
||||
private suspend fun onPlayStateMessage(message: PlaystateMessage) = withContext(Dispatchers.Main) {
|
||||
Timber.i("Received PlayStateMessage with command ${message.data?.command}")
|
||||
private fun onPlayStateMessage(message: PlayStateMessage) = coroutineScope.launch(Dispatchers.Main) {
|
||||
Timber.i("Received PlayStateMessage with command ${message.request.command}")
|
||||
|
||||
// Audio playback uses (Rewrite)MediaManager, (legacy) video playback uses playbackController
|
||||
when {
|
||||
mediaManager.hasAudioQueueItems() -> {
|
||||
// Ignore RewriteMediaManager
|
||||
mediaManager is RewriteMediaManager && mediaManager.hasAudioQueueItems() -> {
|
||||
Timber.i("Ignoring PlayStateMessage: should be handled by PlaySessionSocketService")
|
||||
return@withContext
|
||||
return@launch
|
||||
}
|
||||
|
||||
// LegacyMediaManager
|
||||
mediaManager.hasAudioQueueItems() -> when (message.request.command) {
|
||||
PlaystateCommand.STOP -> mediaManager.stopAudio(true)
|
||||
PlaystateCommand.PAUSE, PlaystateCommand.UNPAUSE, PlaystateCommand.PLAY_PAUSE -> mediaManager.playPauseAudio()
|
||||
PlaystateCommand.NEXT_TRACK -> mediaManager.nextAudioItem()
|
||||
PlaystateCommand.PREVIOUS_TRACK -> mediaManager.prevAudioItem()
|
||||
// Not implemented
|
||||
PlaystateCommand.SEEK,
|
||||
PlaystateCommand.REWIND,
|
||||
PlaystateCommand.FAST_FORWARD -> Unit
|
||||
}
|
||||
|
||||
// PlaybackController
|
||||
else -> {
|
||||
val playbackController = playbackControllerContainer.playbackController
|
||||
when (message.data?.command) {
|
||||
when (message.request.command) {
|
||||
PlaystateCommand.STOP -> playbackController?.endPlayback(true)
|
||||
PlaystateCommand.PAUSE, PlaystateCommand.UNPAUSE, PlaystateCommand.PLAY_PAUSE -> playbackController?.playPause()
|
||||
PlaystateCommand.NEXT_TRACK -> playbackController?.next()
|
||||
PlaystateCommand.PREVIOUS_TRACK -> playbackController?.prev()
|
||||
PlaystateCommand.SEEK -> playbackController?.seek(
|
||||
(message.data?.seekPositionTicks ?: 0) / TICKS_TO_MS
|
||||
(message.request.seekPositionTicks ?: 0) / TICKS_TO_MS
|
||||
)
|
||||
|
||||
PlaystateCommand.REWIND -> playbackController?.rewind()
|
||||
PlaystateCommand.FAST_FORWARD -> playbackController?.fastForward()
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onDisplayContent(itemId: UUID, itemKind: BaseItemKind) = withContext(Dispatchers.Main) {
|
||||
private fun onDisplayContent(itemId: UUID, itemKind: BaseItemKind) = coroutineScope.launch(Dispatchers.Main) {
|
||||
val playbackController = playbackControllerContainer.playbackController
|
||||
|
||||
if (playbackController?.isPlaying == true || playbackController?.isPaused == true) {
|
||||
Timber.i("Not launching $itemId: playback in progress")
|
||||
return@withContext
|
||||
return@launch
|
||||
}
|
||||
|
||||
Timber.i("Launching $itemId")
|
||||
|
||||
when (itemKind) {
|
||||
BaseItemKind.USER_VIEW,
|
||||
BaseItemKind.COLLECTION_FOLDER -> {
|
||||
BaseItemKind.COLLECTION_FOLDER -> coroutineScope.launch {
|
||||
val item by api.userLibraryApi.getItem(itemId = itemId)
|
||||
itemLauncher.launchUserView(item)
|
||||
ItemLauncher.launchUserView(item)
|
||||
}
|
||||
|
||||
else -> navigationRepository.navigate(Destinations.itemDetails(itemId))
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package org.jellyfin.androidtv.data.model
|
||||
|
||||
import org.jellyfin.androidtv.util.apiclient.JellyfinImage
|
||||
import java.util.UUID
|
||||
|
||||
data class ChapterItemInfo(
|
||||
val itemId: UUID,
|
||||
val name: String?,
|
||||
val startPositionTicks: Long,
|
||||
val image: JellyfinImage?,
|
||||
val imagePath: String?,
|
||||
)
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
package org.jellyfin.androidtv.data.model
|
||||
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class DataRefreshService {
|
||||
var lastDeletedItemId: UUID? = null
|
||||
var lastPlayback: Instant? = null
|
||||
var lastMoviePlayback: Instant? = null
|
||||
var lastTvPlayback: Instant? = null
|
||||
var lastLibraryChange: Instant? = null
|
||||
var lastFavoriteUpdate: Instant? = null
|
||||
var lastPlayback: Long = 0
|
||||
var lastMoviePlayback: Long = 0
|
||||
var lastTvPlayback: Long = 0
|
||||
var lastMusicPlayback: Long = 0
|
||||
var lastLibraryChange: Long = 0
|
||||
var lastVideoQueueChange: Long = 0
|
||||
var lastFavoriteUpdate: Long = 0
|
||||
var lastPlayedItem: BaseItemDto? = null
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package org.jellyfin.androidtv.data.model
|
||||
|
||||
import org.jellyfin.sdk.model.api.ItemFilter
|
||||
|
||||
import org.jellyfin.apiclient.model.querying.ItemFilter
|
||||
|
||||
class FilterOptions {
|
||||
var isFavoriteOnly = false
|
||||
var isUnwatchedOnly = false
|
||||
|
||||
val filters: Set<ItemFilter>
|
||||
get() = buildSet {
|
||||
if (isFavoriteOnly) add(ItemFilter.IS_FAVORITE)
|
||||
if (isUnwatchedOnly) add(ItemFilter.IS_UNPLAYED)
|
||||
val filters: Array<ItemFilter>?
|
||||
get() {
|
||||
if (!isUnwatchedOnly && !isFavoriteOnly) return null
|
||||
|
||||
return buildList {
|
||||
if (isFavoriteOnly) add(ItemFilter.IsFavorite)
|
||||
if (isUnwatchedOnly) add(ItemFilter.IsUnplayed)
|
||||
}.toTypedArray()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package org.jellyfin.androidtv.data.querying;
|
||||
|
||||
data class AdditionalPartsQuery(val itemId: String)
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.jellyfin.androidtv.data.querying
|
||||
|
||||
import org.jellyfin.apiclient.model.querying.ArtistsQuery
|
||||
|
||||
class AlbumArtistsQuery : ArtistsQuery()
|
||||
@@ -1,7 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.querying;
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class GetAdditionalPartsRequest(
|
||||
val itemId: UUID,
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.querying
|
||||
|
||||
data object GetSeriesTimersRequest
|
||||
@@ -1,7 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.querying
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class GetSpecialsRequest(
|
||||
val itemId: UUID,
|
||||
)
|
||||
@@ -1,7 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.querying
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class GetTrailersRequest(
|
||||
val itemId: UUID,
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.jellyfin.androidtv.data.querying
|
||||
|
||||
data class SpecialsQuery(
|
||||
val itemId: String,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.jellyfin.androidtv.data.querying
|
||||
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
import org.jellyfin.apiclient.model.querying.ItemFields
|
||||
import org.jellyfin.apiclient.model.querying.ItemQuery
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
|
||||
class StdItemQuery @JvmOverloads constructor(
|
||||
fields: Array<ItemFields> = defaultFields
|
||||
) : ItemQuery(), KoinComponent {
|
||||
companion object {
|
||||
val defaultFields = arrayOf(
|
||||
ItemFields.PrimaryImageAspectRatio,
|
||||
ItemFields.Overview,
|
||||
ItemFields.ItemCounts,
|
||||
ItemFields.DisplayPreferencesId,
|
||||
ItemFields.ChildCount
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
userId = get<UserRepository>().currentUser.value!!.id.toString()
|
||||
setFields(fields)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.jellyfin.androidtv.data.querying
|
||||
|
||||
data class TrailersQuery(
|
||||
val itemId: String,
|
||||
)
|
||||
@@ -1,3 +1,3 @@
|
||||
package org.jellyfin.androidtv.data.querying
|
||||
|
||||
data object GetUserViewsRequest
|
||||
object ViewQuery
|
||||
@@ -1,62 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.repository
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.ResolveInfo
|
||||
import androidx.core.net.toUri
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.util.componentName
|
||||
|
||||
class ExternalAppRepository(
|
||||
private val userPreferences: UserPreferences,
|
||||
) {
|
||||
companion object {
|
||||
const val SAMPLE_VIDEO_URL = "http://jellyfin.local/query.mp4"
|
||||
const val MEDIA_TYPE_VIDEO = "video/*"
|
||||
}
|
||||
|
||||
private val externalPlayerAppIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndTypeAndNormalize(SAMPLE_VIDEO_URL.toUri(), MEDIA_TYPE_VIDEO)
|
||||
}
|
||||
|
||||
fun getExternalPlayerApps(context: Context): List<ResolveInfo> = context.packageManager
|
||||
.queryIntentActivities(externalPlayerAppIntent, 0)
|
||||
// Hide apps with priority below zero (system stubs)
|
||||
.filter { it.priority >= 0 }
|
||||
|
||||
fun getCurrentExternalPlayerApp(context: Context): ActivityInfo? {
|
||||
// Validate if external app should be used at all
|
||||
val useExternalPlayer = userPreferences[UserPreferences.useExternalPlayer]
|
||||
if (!useExternalPlayer) return null
|
||||
|
||||
// Resolve external app information
|
||||
val resolvedInfo = userPreferences[UserPreferences.externalPlayerComponentName]
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let(ComponentName::unflattenFromString)
|
||||
?.runCatching { context.packageManager.getActivityInfo(this, 0) }
|
||||
?.getOrNull()
|
||||
if (resolvedInfo != null) return resolvedInfo
|
||||
|
||||
// Fallback in case the app is uninstalled or unavailable for some other reason
|
||||
val externalApps = getExternalPlayerApps(context)
|
||||
|
||||
// System default
|
||||
val systemDefault = externalApps.find { it.isDefault }?.activityInfo
|
||||
if (systemDefault != null) return systemDefault
|
||||
|
||||
// First compatible, or none
|
||||
return externalApps.firstOrNull()?.activityInfo
|
||||
}
|
||||
|
||||
fun setExternalPlayerapp(activityInfo: ActivityInfo?) {
|
||||
if (activityInfo == null) {
|
||||
userPreferences[UserPreferences.useExternalPlayer] = false
|
||||
userPreferences[UserPreferences.externalPlayerComponentName] = ""
|
||||
} else {
|
||||
userPreferences[UserPreferences.useExternalPlayer] = true
|
||||
userPreferences[UserPreferences.externalPlayerComponentName] = activityInfo.componentName.flattenToShortString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
package org.jellyfin.androidtv.data.repository
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.data.model.DataRefreshService
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.UserItemDataDto
|
||||
import java.time.Instant
|
||||
|
||||
interface ItemMutationRepository {
|
||||
suspend fun setFavorite(item: UUID, favorite: Boolean): UserItemDataDto
|
||||
@@ -21,18 +18,18 @@ class ItemMutationRepositoryImpl(
|
||||
) : ItemMutationRepository {
|
||||
override suspend fun setFavorite(item: UUID, favorite: Boolean): UserItemDataDto {
|
||||
val response by when {
|
||||
favorite -> withContext(Dispatchers.IO) { api.userLibraryApi.markFavoriteItem(itemId = item) }
|
||||
else -> withContext(Dispatchers.IO) { api.userLibraryApi.unmarkFavoriteItem(itemId = item) }
|
||||
favorite -> api.userLibraryApi.markFavoriteItem(itemId = item)
|
||||
else -> api.userLibraryApi.unmarkFavoriteItem(itemId = item)
|
||||
}
|
||||
|
||||
dataRefreshService.lastFavoriteUpdate = Instant.now()
|
||||
dataRefreshService.lastFavoriteUpdate = System.currentTimeMillis()
|
||||
return response
|
||||
}
|
||||
|
||||
override suspend fun setPlayed(item: UUID, played: Boolean): UserItemDataDto {
|
||||
val response by when {
|
||||
played -> withContext(Dispatchers.IO) { api.playStateApi.markPlayedItem(itemId = item) }
|
||||
else -> withContext(Dispatchers.IO) { api.playStateApi.markUnplayedItem(itemId = item) }
|
||||
played -> api.playStateApi.markPlayedItem(itemId = item)
|
||||
else -> api.playStateApi.markUnplayedItem(itemId = item)
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.repository
|
||||
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
|
||||
object ItemRepository {
|
||||
val itemFields = setOf(
|
||||
ItemFields.CAN_DELETE,
|
||||
ItemFields.CHANNEL_INFO,
|
||||
ItemFields.CHAPTERS,
|
||||
ItemFields.CHILD_COUNT,
|
||||
ItemFields.CUMULATIVE_RUN_TIME_TICKS,
|
||||
ItemFields.DATE_CREATED,
|
||||
ItemFields.DISPLAY_PREFERENCES_ID,
|
||||
ItemFields.GENRES,
|
||||
ItemFields.ITEM_COUNTS,
|
||||
ItemFields.MEDIA_SOURCE_COUNT,
|
||||
ItemFields.MEDIA_SOURCES,
|
||||
ItemFields.MEDIA_STREAMS,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.PATH,
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.TAGLINES,
|
||||
ItemFields.TRICKPLAY,
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.jellyfin.androidtv.data.repository
|
||||
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -22,6 +23,7 @@ interface NotificationsRepository {
|
||||
|
||||
class NotificationsRepositoryImpl(
|
||||
private val context: Context,
|
||||
private val uiModeManager: UiModeManager,
|
||||
private val systemPreferences: SystemPreferences,
|
||||
) : NotificationsRepository {
|
||||
override val notifications = MutableStateFlow(emptyList<AppNotification>())
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
package org.jellyfin.androidtv.data.repository
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.constant.CollectionType
|
||||
|
||||
interface UserViewsRepository {
|
||||
val views: Flow<Collection<BaseItemDto>>
|
||||
|
||||
fun isSupported(collectionType: CollectionType?): Boolean
|
||||
fun allowViewSelection(collectionType: CollectionType?): Boolean
|
||||
fun allowGridView(collectionType: CollectionType?): Boolean
|
||||
fun isSupported(collectionType: String?): Boolean
|
||||
fun allowViewSelection(collectionType: String?): Boolean
|
||||
fun allowGridView(collectionType: String?): Boolean
|
||||
}
|
||||
|
||||
class UserViewsRepositoryImpl(
|
||||
@@ -23,29 +21,30 @@ class UserViewsRepositoryImpl(
|
||||
override val views = flow {
|
||||
val views by api.userViewsApi.getUserViews()
|
||||
val filteredViews = views.items
|
||||
.orEmpty()
|
||||
.filter { isSupported(it.collectionType) }
|
||||
emit(filteredViews)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
override fun isSupported(collectionType: CollectionType?) = collectionType !in unsupportedCollectionTypes
|
||||
override fun allowViewSelection(collectionType: CollectionType?) = collectionType !in disallowViewSelectionCollectionTypes
|
||||
override fun allowGridView(collectionType: CollectionType?) = collectionType !in disallowGridViewCollectionTypes
|
||||
override fun isSupported(collectionType: String?) = collectionType !in unsupportedCollectionTypes
|
||||
override fun allowViewSelection(collectionType: String?) = collectionType !in disallowViewSelectionCollectionTypes
|
||||
override fun allowGridView(collectionType: String?) = collectionType !in disallowGridViewCollectionTypes
|
||||
|
||||
private companion object {
|
||||
private val unsupportedCollectionTypes = arrayOf(
|
||||
CollectionType.BOOKS,
|
||||
CollectionType.FOLDERS
|
||||
CollectionType.Books,
|
||||
CollectionType.Folders
|
||||
)
|
||||
|
||||
private val disallowViewSelectionCollectionTypes = arrayOf(
|
||||
CollectionType.LIVETV,
|
||||
CollectionType.MUSIC,
|
||||
CollectionType.PHOTOS,
|
||||
CollectionType.LiveTv,
|
||||
CollectionType.Music,
|
||||
CollectionType.Photos,
|
||||
)
|
||||
|
||||
private val disallowGridViewCollectionTypes = arrayOf(
|
||||
CollectionType.LIVETV,
|
||||
CollectionType.MUSIC
|
||||
CollectionType.LiveTv,
|
||||
CollectionType.Music
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 coil3.ImageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.toBitmap
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.MainScope
|
||||
@@ -15,15 +15,12 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.auth.model.Server
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.preference.constant.BackdropBehavior
|
||||
import org.jellyfin.androidtv.util.apiclient.getUrl
|
||||
import org.jellyfin.androidtv.util.apiclient.itemBackdropImages
|
||||
import org.jellyfin.androidtv.util.apiclient.parentBackdropImages
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import java.time.Instant
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -50,29 +47,39 @@ class BackgroundService(
|
||||
private var _backgrounds = emptyList<ImageBitmap>()
|
||||
private var _currentIndex = 0
|
||||
private var _currentBackground = MutableStateFlow<ImageBitmap?>(null)
|
||||
private var _blurBackground = MutableStateFlow(false)
|
||||
private var _enabled = MutableStateFlow(true)
|
||||
val currentBackground get() = _currentBackground.asStateFlow()
|
||||
val blurBackground get() = _blurBackground.asStateFlow()
|
||||
val enabled get() = _enabled.asStateFlow()
|
||||
|
||||
// Helper function for [setBackground]
|
||||
private fun List<String>?.getUrls(itemId: UUID?): List<String> {
|
||||
// Check for nullability
|
||||
if (itemId == null || isNullOrEmpty()) return emptyList()
|
||||
|
||||
return mapIndexed { index, tag ->
|
||||
api.imageApi.getItemImageUrl(
|
||||
itemId = itemId,
|
||||
imageType = ImageType.BACKDROP,
|
||||
tag = tag,
|
||||
imageIndex = index,
|
||||
fillWidth = context.resources.displayMetrics.widthPixels,
|
||||
fillHeight = context.resources.displayMetrics.heightPixels,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use all available backdrops from [baseItem] as background.
|
||||
*/
|
||||
fun setBackground(baseItem: BaseItemDto?) {
|
||||
val backdropBehavior = userPreferences[UserPreferences.backdropBehavior]
|
||||
|
||||
// Check if item is set and backgrounds are enabled
|
||||
if (baseItem == null || backdropBehavior == BackdropBehavior.DISABLED)
|
||||
if (baseItem == null || !userPreferences[UserPreferences.backdropEnabled])
|
||||
return clearBackgrounds()
|
||||
|
||||
// Enable blur for backdrops
|
||||
_blurBackground.value = backdropBehavior == BackdropBehavior.BACKDROP_WITH_BLUR
|
||||
|
||||
// Get all backdrop urls
|
||||
val backdropUrls = (baseItem.itemBackdropImages + baseItem.parentBackdropImages)
|
||||
.map { it.getUrl(api) }
|
||||
.toSet()
|
||||
val itemBackdropUrls = baseItem.backdropImageTags.getUrls(baseItem.id)
|
||||
val parentBackdropUrls = baseItem.parentBackdropImageTags.getUrls(baseItem.parentBackdropItemId)
|
||||
val backdropUrls = itemBackdropUrls.union(parentBackdropUrls)
|
||||
|
||||
loadBackgrounds(backdropUrls)
|
||||
}
|
||||
@@ -82,16 +89,13 @@ class BackgroundService(
|
||||
*/
|
||||
fun setBackground(server: Server) {
|
||||
// Check if item is set and backgrounds are enabled
|
||||
if (userPreferences[UserPreferences.backdropBehavior] == BackdropBehavior.DISABLED)
|
||||
if (!userPreferences[UserPreferences.backdropEnabled])
|
||||
return clearBackgrounds()
|
||||
|
||||
// Check if splashscreen is enabled in (cached) branding options
|
||||
if (!server.splashscreenEnabled)
|
||||
return clearBackgrounds()
|
||||
|
||||
// Disable blur on splashscreen
|
||||
_blurBackground.value = false
|
||||
|
||||
// Manually grab the backdrop URL
|
||||
val api = jellyfin.createApi(baseUrl = server.address)
|
||||
val splashscreenUrl = api.imageApi.getSplashscreenUrl()
|
||||
@@ -111,7 +115,7 @@ class BackgroundService(
|
||||
_backgrounds = backdropUrls.mapNotNull { url ->
|
||||
imageLoader.execute(
|
||||
request = ImageRequest.Builder(context).data(url).build()
|
||||
).image?.toBitmap()?.asImageBitmap()
|
||||
).drawable?.toBitmap()?.asImageBitmap()
|
||||
}
|
||||
|
||||
// Go to first background
|
||||
@@ -140,7 +144,7 @@ class BackgroundService(
|
||||
}
|
||||
|
||||
internal fun update() {
|
||||
val now = Instant.now().toEpochMilli()
|
||||
val now = System.currentTimeMillis()
|
||||
if (lastBackgroundTimerUpdate > now - TRANSITION_DURATION.inWholeMilliseconds)
|
||||
return setTimer((lastBackgroundTimerUpdate - now).milliseconds + TRANSITION_DURATION, false)
|
||||
|
||||
|
||||
@@ -2,16 +2,10 @@ package org.jellyfin.androidtv.di
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import coil3.ImageLoader
|
||||
import coil3.annotation.ExperimentalCoilApi
|
||||
import coil3.gif.AnimatedImageDecoder
|
||||
import coil3.gif.GifDecoder
|
||||
import coil3.network.NetworkFetcher
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.serviceLoaderEnabled
|
||||
import coil3.svg.SvgDecoder
|
||||
import coil3.util.Logger
|
||||
import coil.ImageLoader
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.auth.repository.ServerRepository
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
@@ -20,7 +14,6 @@ import org.jellyfin.androidtv.data.eventhandling.SocketHandler
|
||||
import org.jellyfin.androidtv.data.model.DataRefreshService
|
||||
import org.jellyfin.androidtv.data.repository.CustomMessageRepository
|
||||
import org.jellyfin.androidtv.data.repository.CustomMessageRepositoryImpl
|
||||
import org.jellyfin.androidtv.data.repository.ExternalAppRepository
|
||||
import org.jellyfin.androidtv.data.repository.ItemMutationRepository
|
||||
import org.jellyfin.androidtv.data.repository.ItemMutationRepositoryImpl
|
||||
import org.jellyfin.androidtv.data.repository.NotificationsRepository
|
||||
@@ -28,100 +21,85 @@ import org.jellyfin.androidtv.data.repository.NotificationsRepositoryImpl
|
||||
import org.jellyfin.androidtv.data.repository.UserViewsRepository
|
||||
import org.jellyfin.androidtv.data.repository.UserViewsRepositoryImpl
|
||||
import org.jellyfin.androidtv.data.service.BackgroundService
|
||||
import org.jellyfin.androidtv.integration.dream.DreamViewModel
|
||||
import org.jellyfin.androidtv.ui.InteractionTrackerViewModel
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
|
||||
import org.jellyfin.androidtv.ui.ScreensaverViewModel
|
||||
import org.jellyfin.androidtv.ui.navigation.Destinations
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepositoryImpl
|
||||
import org.jellyfin.androidtv.ui.picture.PictureViewerViewModel
|
||||
import org.jellyfin.androidtv.ui.playback.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.playback.stillwatching.StillWatchingViewModel
|
||||
import org.jellyfin.androidtv.ui.player.photo.PhotoPlayerViewModel
|
||||
import org.jellyfin.androidtv.ui.search.SearchFragmentDelegate
|
||||
import org.jellyfin.androidtv.ui.search.SearchRepository
|
||||
import org.jellyfin.androidtv.ui.search.SearchRepositoryImpl
|
||||
import org.jellyfin.androidtv.ui.search.SearchViewModel
|
||||
import org.jellyfin.androidtv.ui.settings.compat.SettingsViewModel
|
||||
import org.jellyfin.androidtv.ui.startup.ServerAddViewModel
|
||||
import org.jellyfin.androidtv.ui.startup.StartupViewModel
|
||||
import org.jellyfin.androidtv.ui.startup.UserLoginViewModel
|
||||
import org.jellyfin.androidtv.util.KeyProcessor
|
||||
import org.jellyfin.androidtv.util.MarkdownRenderer
|
||||
import org.jellyfin.androidtv.util.PlaybackHelper
|
||||
import org.jellyfin.androidtv.util.apiclient.ReportingHelper
|
||||
import org.jellyfin.androidtv.util.coil.CoilTimberLogger
|
||||
import org.jellyfin.androidtv.util.coil.createCoilConnectivityChecker
|
||||
import org.jellyfin.androidtv.util.sdk.SdkPlaybackHelper
|
||||
import org.jellyfin.androidtv.util.sdk.legacy
|
||||
import org.jellyfin.apiclient.AppInfo
|
||||
import org.jellyfin.apiclient.android
|
||||
import org.jellyfin.apiclient.logging.AndroidLogger
|
||||
import org.jellyfin.sdk.android.androidDevice
|
||||
import org.jellyfin.sdk.api.client.HttpClientOptions
|
||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.createJellyfin
|
||||
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.core.module.dsl.viewModel
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
import org.jellyfin.apiclient.Jellyfin as JellyfinApiClient
|
||||
import org.jellyfin.sdk.Jellyfin as JellyfinSdk
|
||||
|
||||
val defaultDeviceInfo = named("defaultDeviceInfo")
|
||||
|
||||
val appModule = module {
|
||||
// SDK
|
||||
// New SDK
|
||||
single(defaultDeviceInfo) { androidDevice(get()) }
|
||||
single { OkHttpFactory() }
|
||||
single { HttpClientOptions() }
|
||||
single {
|
||||
createJellyfin {
|
||||
context = androidContext()
|
||||
|
||||
// Add client info
|
||||
val clientName = buildString {
|
||||
append("Jellyfin Android TV")
|
||||
if (BuildConfig.DEBUG) append(" (debug)")
|
||||
}
|
||||
clientInfo = ClientInfo(clientName, BuildConfig.VERSION_NAME)
|
||||
clientInfo = ClientInfo("Android TV", BuildConfig.VERSION_NAME)
|
||||
deviceInfo = get(defaultDeviceInfo)
|
||||
|
||||
// Change server version
|
||||
minimumServerVersion = ServerRepository.minimumServerVersion
|
||||
|
||||
// Use our own shared factory instance
|
||||
apiClientFactory = get<OkHttpFactory>()
|
||||
socketConnectionFactory = get<OkHttpFactory>()
|
||||
}
|
||||
}
|
||||
|
||||
single {
|
||||
// Create an empty API instance, the actual values are set by the SessionRepository
|
||||
get<JellyfinSdk>().createApi(httpClientOptions = get<HttpClientOptions>())
|
||||
get<JellyfinSdk>().createApi()
|
||||
}
|
||||
|
||||
single { SocketHandler(get(), get(), get(), get(), get(), get(), get(), get(), get(), ProcessLifecycleOwner.get().lifecycle) }
|
||||
single { get<ApiClient>().ws() }
|
||||
|
||||
// Coil (images)
|
||||
single { SocketHandler(get(), get(), get(), get(), get(), get(), get(), get()) }
|
||||
|
||||
// Old apiclient
|
||||
single {
|
||||
val okHttpFactory = get<OkHttpFactory>()
|
||||
val httpClientOptions = get<HttpClientOptions>()
|
||||
JellyfinApiClient {
|
||||
appInfo = AppInfo("Android TV", BuildConfig.VERSION_NAME)
|
||||
logger = AndroidLogger()
|
||||
android(androidApplication())
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
OkHttpNetworkFetcherFactory(
|
||||
callFactory = { okHttpFactory.createClient(httpClientOptions) },
|
||||
connectivityChecker = ::createCoilConnectivityChecker,
|
||||
single {
|
||||
get<JellyfinApiClient>().createApi(
|
||||
device = get<DeviceInfo>(defaultDeviceInfo).legacy()
|
||||
)
|
||||
}
|
||||
|
||||
// Coil (images)
|
||||
single {
|
||||
ImageLoader.Builder(androidContext()).apply {
|
||||
serviceLoaderEnabled(false)
|
||||
logger(CoilTimberLogger(if (BuildConfig.DEBUG) Logger.Level.Warn else Logger.Level.Error))
|
||||
|
||||
components {
|
||||
add(get<NetworkFetcher.Factory>())
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(AnimatedImageDecoder.Factory())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(ImageDecoderDecoder.Factory())
|
||||
else add(GifDecoder.Factory())
|
||||
add(SvgDecoder.Factory())
|
||||
}
|
||||
@@ -131,35 +109,26 @@ val appModule = module {
|
||||
// Non API related
|
||||
single { DataRefreshService() }
|
||||
single { PlaybackControllerContainer() }
|
||||
single { InteractionTrackerViewModel(get(), get()) }
|
||||
|
||||
single<UserRepository> { UserRepositoryImpl() }
|
||||
single<UserViewsRepository> { UserViewsRepositoryImpl(get()) }
|
||||
single<NotificationsRepository> { NotificationsRepositoryImpl(get(), get()) }
|
||||
single<NotificationsRepository> { NotificationsRepositoryImpl(get(), get(), get()) }
|
||||
single<ItemMutationRepository> { ItemMutationRepositoryImpl(get(), get()) }
|
||||
single<CustomMessageRepository> { CustomMessageRepositoryImpl() }
|
||||
single<NavigationRepository> { NavigationRepositoryImpl(Destinations.home) }
|
||||
single<SearchRepository> { SearchRepositoryImpl(get()) }
|
||||
single<MediaSegmentRepository> { MediaSegmentRepositoryImpl(get(), get()) }
|
||||
single<ExternalAppRepository> { ExternalAppRepository(get()) }
|
||||
|
||||
viewModel { StartupViewModel(get(), get(), get(), get()) }
|
||||
viewModel { UserLoginViewModel(get(), get(), get(), get(defaultDeviceInfo)) }
|
||||
viewModel { ServerAddViewModel(get()) }
|
||||
viewModel { NextUpViewModel(get(), get(), get()) }
|
||||
viewModel { StillWatchingViewModel(get(), get(), get(), get()) }
|
||||
viewModel { PhotoPlayerViewModel(get()) }
|
||||
viewModel { NextUpViewModel(get(), get(), get(), get()) }
|
||||
viewModel { PictureViewerViewModel(get()) }
|
||||
viewModel { ScreensaverViewModel(get()) }
|
||||
viewModel { SearchViewModel(get()) }
|
||||
viewModel { DreamViewModel(get(), get(), get(), get(), get()) }
|
||||
viewModel { SettingsViewModel() }
|
||||
|
||||
single { BackgroundService(get(), get(), get(), get(), get()) }
|
||||
|
||||
single { MarkdownRenderer(get()) }
|
||||
single { ItemLauncher() }
|
||||
single { KeyProcessor() }
|
||||
single { ReportingHelper(get(), get()) }
|
||||
single<PlaybackHelper> { SdkPlaybackHelper(get(), get(), get(), get()) }
|
||||
|
||||
factory { (context: Context) -> SearchFragmentDelegate(context, get(), get()) }
|
||||
factory { (context: Context) -> SearchFragmentDelegate(context, get()) }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.jellyfin.androidtv.di
|
||||
|
||||
import org.jellyfin.androidtv.auth.AccountManagerMigration
|
||||
import org.jellyfin.androidtv.auth.apiclient.ApiBinder
|
||||
import org.jellyfin.androidtv.auth.repository.AuthenticationRepository
|
||||
import org.jellyfin.androidtv.auth.repository.AuthenticationRepositoryImpl
|
||||
import org.jellyfin.androidtv.auth.repository.ServerRepository
|
||||
@@ -13,7 +15,8 @@ import org.jellyfin.androidtv.auth.store.AuthenticationStore
|
||||
import org.koin.dsl.module
|
||||
|
||||
val authModule = module {
|
||||
single { AuthenticationStore(get()) }
|
||||
single { AccountManagerMigration(get()) }
|
||||
single { AuthenticationStore(get(), get()) }
|
||||
single { AuthenticationPreferences(get()) }
|
||||
|
||||
single<AuthenticationRepository> {
|
||||
@@ -22,11 +25,8 @@ val authModule = module {
|
||||
single<ServerRepository> { ServerRepositoryImpl(get(), get()) }
|
||||
single<ServerUserRepository> { ServerUserRepositoryImpl(get(), get()) }
|
||||
single<SessionRepository> {
|
||||
SessionRepositoryImpl(get(), get(), get(), get(), get(defaultDeviceInfo), get(), get(), get())
|
||||
SessionRepositoryImpl(get(), get(), get(), get(), get(), get(defaultDeviceInfo), get(), get(), get())
|
||||
}
|
||||
|
||||
factory {
|
||||
val serverRepository = get<ServerRepository>()
|
||||
serverRepository.currentServer.value?.serverVersion ?: ServerRepository.minimumServerVersion
|
||||
}
|
||||
single { ApiBinder(get(), get()) }
|
||||
}
|
||||
|
||||
@@ -6,88 +6,75 @@ import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.media3.datasource.HttpDataSource
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.preference.UserSettingPreferences
|
||||
import org.jellyfin.androidtv.ui.browsing.MainActivity
|
||||
import org.jellyfin.androidtv.ui.playback.MediaManager
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackLauncher
|
||||
import org.jellyfin.androidtv.ui.playback.GarbagePlaybackLauncher
|
||||
import org.jellyfin.androidtv.ui.playback.LegacyMediaManager
|
||||
import org.jellyfin.androidtv.ui.playback.RewritePlaybackLauncher
|
||||
import org.jellyfin.androidtv.ui.playback.VideoQueueManager
|
||||
import org.jellyfin.androidtv.ui.playback.rewrite.RewriteMediaManager
|
||||
import org.jellyfin.androidtv.util.profile.createDeviceProfile
|
||||
import org.jellyfin.playback.core.mediasession.MediaSessionOptions
|
||||
import org.jellyfin.playback.core.mediasession.mediaSessionPlugin
|
||||
import org.jellyfin.playback.core.playbackManager
|
||||
import org.jellyfin.playback.exoplayer.exoPlayerPlugin
|
||||
import org.jellyfin.playback.jellyfin.jellyfinPlugin
|
||||
import org.jellyfin.playback.media3.exoplayer.ExoPlayerOptions
|
||||
import org.jellyfin.playback.media3.exoplayer.exoPlayerPlugin
|
||||
import org.jellyfin.playback.media3.session.MediaSessionOptions
|
||||
import org.jellyfin.playback.media3.session.media3SessionPlugin
|
||||
import org.jellyfin.sdk.api.client.HttpClientOptions
|
||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.scope.Scope
|
||||
import org.koin.dsl.module
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackManager as LegacyPlaybackManager
|
||||
|
||||
val playbackModule = module {
|
||||
single { LegacyPlaybackManager(get()) }
|
||||
single { VideoQueueManager() }
|
||||
single<MediaManager> { RewriteMediaManager(get(), get()) }
|
||||
single { LegacyMediaManager(get()) }
|
||||
single { RewriteMediaManager(get(), get(), get(), get()) }
|
||||
|
||||
single { PlaybackLauncher(get(), get(), get(), get()) }
|
||||
factory {
|
||||
val preferences = get<UserPreferences>()
|
||||
val useRewrite = preferences[UserPreferences.playbackRewriteAudioEnabled]
|
||||
|
||||
single<HttpDataSource.Factory> {
|
||||
val okHttpFactory = get<OkHttpFactory>()
|
||||
val httpClientOptions = get<HttpClientOptions>().copy(
|
||||
// Disable request timeout for media playback as this causes issues with Live TV
|
||||
requestTimeout = Duration.ZERO
|
||||
)
|
||||
if (useRewrite) get<RewriteMediaManager>()
|
||||
else get<LegacyMediaManager>()
|
||||
}
|
||||
|
||||
OkHttpDataSource.Factory(okHttpFactory.createClient(httpClientOptions))
|
||||
factory {
|
||||
val preferences = get<UserPreferences>()
|
||||
val useRewrite = preferences[UserPreferences.playbackRewriteVideoEnabled] && BuildConfig.DEVELOPMENT
|
||||
|
||||
if (useRewrite) RewritePlaybackLauncher()
|
||||
else GarbagePlaybackLauncher(get())
|
||||
}
|
||||
|
||||
single { createPlaybackManager() }
|
||||
}
|
||||
|
||||
fun Scope.createPlaybackManager() = playbackManager(androidContext()) {
|
||||
install(exoPlayerPlugin(get()))
|
||||
install(jellyfinPlugin(get(), get()))
|
||||
|
||||
val activityIntent = Intent(get(), MainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(get(), 0, activityIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val notificationChannelId = "session"
|
||||
val notificationChannelId = "mediasession"
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
notificationChannelId,
|
||||
notificationChannelId,
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
channel.setShowBadge(false)
|
||||
NotificationManagerCompat.from(get()).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
val userPreferences = get<UserPreferences>()
|
||||
val exoPlayerOptions = ExoPlayerOptions(
|
||||
preferFfmpeg = userPreferences[UserPreferences.preferExoPlayerFfmpeg],
|
||||
enableLibass = userPreferences[UserPreferences.assDirectPlay],
|
||||
enableDebugLogging = userPreferences[UserPreferences.debuggingEnabled],
|
||||
baseDataSourceFactory = get<HttpDataSource.Factory>(),
|
||||
)
|
||||
install(exoPlayerPlugin(get(), exoPlayerOptions))
|
||||
|
||||
val mediaSessionOptions = MediaSessionOptions(
|
||||
install(mediaSessionPlugin(get(), MediaSessionOptions(
|
||||
channelId = notificationChannelId,
|
||||
notificationId = 1,
|
||||
iconSmall = R.drawable.app_icon_foreground,
|
||||
openIntent = pendingIntent,
|
||||
)
|
||||
install(media3SessionPlugin(get(), mediaSessionOptions))
|
||||
|
||||
val deviceProfileBuilder = { createDeviceProfile(androidContext(), userPreferences, get()) }
|
||||
install(jellyfinPlugin(get(), deviceProfileBuilder, setOf(MediaSegmentType.INTRO), ProcessLifecycleOwner.get().lifecycle))
|
||||
)))
|
||||
|
||||
// Options
|
||||
val userSettingPreferences = get<UserSettingPreferences>()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package org.jellyfin.androidtv.di
|
||||
|
||||
import org.jellyfin.androidtv.util.AutoBitrate
|
||||
import org.jellyfin.androidtv.util.ImageHelper
|
||||
import org.koin.dsl.module
|
||||
|
||||
val utilsModule = module {
|
||||
single { AutoBitrate(get()) }
|
||||
single { ImageHelper(get()) }
|
||||
}
|
||||
|
||||
@@ -7,10 +7,8 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.net.toUri
|
||||
import androidx.tvprovider.media.tv.Channel
|
||||
import androidx.tvprovider.media.tv.ChannelLogoUtils
|
||||
import androidx.tvprovider.media.tv.PreviewProgram
|
||||
@@ -23,38 +21,36 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.data.repository.ItemRepository
|
||||
import org.jellyfin.androidtv.data.repository.UserViewsRepository
|
||||
import org.jellyfin.androidtv.integration.provider.ImageProvider
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.ui.startup.StartupActivity
|
||||
import org.jellyfin.androidtv.util.ImageHelper
|
||||
import org.jellyfin.androidtv.util.apiclient.getUrl
|
||||
import org.jellyfin.androidtv.util.apiclient.itemImages
|
||||
import org.jellyfin.androidtv.util.apiclient.parentImages
|
||||
import org.jellyfin.androidtv.util.ImageUtils
|
||||
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
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageFormat
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.constant.MediaType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import timber.log.Timber
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.time.Duration
|
||||
|
||||
|
||||
/**
|
||||
* Manages channels on the android tv home screen.
|
||||
*
|
||||
@@ -71,7 +67,6 @@ class LeanbackChannelWorker(
|
||||
private val api by inject<ApiClient>()
|
||||
private val userPreferences by inject<UserPreferences>()
|
||||
private val userViewsRepository by inject<UserViewsRepository>()
|
||||
private val imageHelper by inject<ImageHelper>()
|
||||
|
||||
/**
|
||||
* Check if the app can use Leanback features and is API level 26 or higher.
|
||||
@@ -102,39 +97,39 @@ class LeanbackChannelWorker(
|
||||
// Get channel URIs
|
||||
val latestMediaChannel = getChannelUri(
|
||||
"latest_media", Channel.Builder()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.home_section_latest_media))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build(),
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.home_section_latest_media))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build(),
|
||||
default = true
|
||||
)
|
||||
val myMediaChannel = getChannelUri(
|
||||
"my_media", Channel.Builder()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.lbl_my_media))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.lbl_my_media))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build()
|
||||
)
|
||||
val nextUpChannel = getChannelUri(
|
||||
"next_up", Channel.Builder()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.lbl_next_up))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.lbl_next_up))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build()
|
||||
)
|
||||
val latestMoviesChannel = getChannelUri(
|
||||
"latest_movies", Channel.Builder()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.lbl_movies))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.lbl_movies))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build()
|
||||
)
|
||||
val latestEpisodesChannel = getChannelUri(
|
||||
"latest_episodes", Channel.Builder()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.lbl_new_episodes))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(context.getString(R.string.lbl_new_episodes))
|
||||
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||
.build()
|
||||
)
|
||||
val preferParentThumb = userPreferences[UserPreferences.seriesThumbnailsEnabled]
|
||||
|
||||
@@ -146,21 +141,18 @@ class LeanbackChannelWorker(
|
||||
latestEpisodes to latestEpisodesChannel,
|
||||
myMedia to myMediaChannel,
|
||||
).forEach { (items, channel) ->
|
||||
if (channel == null) {
|
||||
Timber.e("Skipping channel because it was not available")
|
||||
} else {
|
||||
items.map { item ->
|
||||
createPreviewProgram(
|
||||
channel,
|
||||
item,
|
||||
preferParentThumb
|
||||
)
|
||||
}.let {
|
||||
context.contentResolver.bulkInsert(
|
||||
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
||||
it.toTypedArray()
|
||||
)
|
||||
}
|
||||
Timber.d("Updating channel %s", channel)
|
||||
items.map { item ->
|
||||
createPreviewProgram(
|
||||
channel,
|
||||
item,
|
||||
preferParentThumb
|
||||
)
|
||||
}.let {
|
||||
context.contentResolver.bulkInsert(
|
||||
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
||||
it.toTypedArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
updateWatchNext(resumeItems + nextUpItems)
|
||||
@@ -183,46 +175,35 @@ class LeanbackChannelWorker(
|
||||
* update or create the channel. The [name] parameter is used to store the id and should be
|
||||
* unique.
|
||||
*/
|
||||
private fun getChannelUri(name: String, settings: Channel, default: Boolean = false): Uri? {
|
||||
private fun getChannelUri(name: String, settings: Channel, default: Boolean = false): Uri {
|
||||
val store = context.getSharedPreferences("leanback_channels", Context.MODE_PRIVATE)
|
||||
var uri: Uri? = null
|
||||
|
||||
// Try and re-use our existing channel definition
|
||||
if (store.contains(name)) {
|
||||
uri = store.getString(name, null)?.toUri()
|
||||
|
||||
if (uri != null) {
|
||||
val result = context.contentResolver.update(uri, settings.toContentValues(), null, null)
|
||||
// If we did not affect exactly 1 row there might be something wrong, so recreate it
|
||||
if (result != 1) uri = null
|
||||
val uri = if (store.contains(name)) {
|
||||
// Retrieve uri and update content resolver
|
||||
Uri.parse(store.getString(name, null)).also { uri ->
|
||||
context.contentResolver.update(uri, settings.toContentValues(), null, null)
|
||||
}
|
||||
}
|
||||
|
||||
if (uri == null) {
|
||||
// Create new channel
|
||||
uri = context.contentResolver.insert(
|
||||
} else {
|
||||
// Create new channel and save uri
|
||||
context.contentResolver.insert(
|
||||
TvContractCompat.Channels.CONTENT_URI,
|
||||
settings.toContentValues()
|
||||
)
|
||||
|
||||
// Set as default row to display (we can request one row to automatically be added to the home screen)
|
||||
if (uri != null && default) {
|
||||
TvContractCompat.requestChannelBrowsable(context, ContentUris.parseId(uri))
|
||||
)!!.also { uri ->
|
||||
store.edit().putString(name, uri.toString()).apply()
|
||||
if (default) {
|
||||
// Set as default row to display (we can request one row to automatically be added to the home screen)
|
||||
TvContractCompat.requestChannelBrowsable(context, ContentUris.parseId(uri))
|
||||
}
|
||||
}
|
||||
|
||||
// Save uri to shared preferences
|
||||
store.edit { putString(name, uri?.toString()) }
|
||||
}
|
||||
|
||||
// Update logo
|
||||
if (uri != null) {
|
||||
ResourcesCompat.getDrawable(context.resources, R.mipmap.app_icon, context.theme)?.let {
|
||||
ChannelLogoUtils.storeChannelLogo(
|
||||
context,
|
||||
ContentUris.parseId(uri),
|
||||
it.toBitmap(80.dp(context), 80.dp(context))
|
||||
)
|
||||
}
|
||||
ResourcesCompat.getDrawable(context.resources, R.mipmap.app_icon, context.theme)?.let {
|
||||
ChannelLogoUtils.storeChannelLogo(
|
||||
context,
|
||||
ContentUris.parseId(uri),
|
||||
it.toBitmap(80.dp(context), 80.dp(context))
|
||||
)
|
||||
}
|
||||
|
||||
return uri
|
||||
@@ -237,6 +218,7 @@ class LeanbackChannelWorker(
|
||||
|
||||
// Add new items
|
||||
return response.items
|
||||
.orEmpty()
|
||||
.filter { userViewsRepository.isSupported(it.collectionType) }
|
||||
}
|
||||
|
||||
@@ -247,12 +229,35 @@ class LeanbackChannelWorker(
|
||||
private fun BaseItemDto.getPosterArtImageUrl(
|
||||
preferParentThumb: Boolean
|
||||
): Uri = when {
|
||||
type == BaseItemKind.MOVIE || type == BaseItemKind.SERIES -> itemImages[ImageType.PRIMARY]
|
||||
(preferParentThumb || !itemImages.contains(ImageType.PRIMARY)) && parentImages.contains(ImageType.THUMB) -> parentImages[ImageType.THUMB]
|
||||
else -> itemImages[ImageType.PRIMARY]
|
||||
}.let { image ->
|
||||
ImageProvider.getImageUri(image?.getUrl(api) ?: imageHelper.getResourceUrl(context, R.drawable.tile_land_tv))
|
||||
}
|
||||
type == BaseItemKind.MOVIE || type == BaseItemKind.SERIES -> api.imageApi.getItemImageUrl(
|
||||
itemId = id,
|
||||
imageType = ImageType.PRIMARY,
|
||||
format = ImageFormat.WEBP,
|
||||
width = 106.dp(context),
|
||||
height = 153.dp(context),
|
||||
tag = imageTags?.get(ImageType.PRIMARY),
|
||||
)
|
||||
|
||||
(preferParentThumb || imageTags?.contains(ImageType.PRIMARY) != true) && parentThumbItemId != null -> api.imageApi.getItemImageUrl(
|
||||
itemId = parentThumbItemId!!,
|
||||
imageType = ImageType.THUMB,
|
||||
format = ImageFormat.WEBP,
|
||||
width = 272.dp(context),
|
||||
height = 153.dp(context),
|
||||
tag = imageTags?.get(ImageType.THUMB),
|
||||
)
|
||||
|
||||
imageTags?.containsKey(ImageType.PRIMARY) == true -> api.imageApi.getItemImageUrl(
|
||||
itemId = id,
|
||||
imageType = ImageType.PRIMARY,
|
||||
format = ImageFormat.WEBP,
|
||||
width = 272.dp(context),
|
||||
height = 153.dp(context),
|
||||
tag = imageTags?.get(ImageType.PRIMARY),
|
||||
)
|
||||
|
||||
else -> ImageUtils.getResourceUrl(context, R.drawable.tile_land_tv)
|
||||
}.let(ImageProvider::getImageUri)
|
||||
|
||||
/**
|
||||
* Gets the resume and next up episodes. The returned pair contains two lists:
|
||||
@@ -263,22 +268,22 @@ class LeanbackChannelWorker(
|
||||
withContext(Dispatchers.IO) {
|
||||
val resume = async {
|
||||
api.itemsApi.getResumeItems(
|
||||
fields = ItemRepository.itemFields,
|
||||
fields = listOf(ItemFields.DATE_CREATED),
|
||||
imageTypeLimit = 1,
|
||||
limit = 10,
|
||||
mediaTypes = listOf(MediaType.VIDEO),
|
||||
mediaTypes = listOf(MediaType.Video),
|
||||
includeItemTypes = listOf(BaseItemKind.EPISODE, BaseItemKind.MOVIE),
|
||||
excludeActiveSessions = true,
|
||||
).content.items
|
||||
).content.items.orEmpty()
|
||||
}
|
||||
|
||||
val nextUp = async {
|
||||
api.tvShowsApi.getNextUp(
|
||||
userId = api.userId,
|
||||
imageTypeLimit = 1,
|
||||
limit = 10,
|
||||
enableResumable = false,
|
||||
fields = ItemRepository.itemFields,
|
||||
).content.items
|
||||
fields = listOf(ItemFields.DATE_CREATED),
|
||||
).content.items.orEmpty()
|
||||
}
|
||||
|
||||
// Concat
|
||||
@@ -289,7 +294,9 @@ class LeanbackChannelWorker(
|
||||
withContext(Dispatchers.IO) {
|
||||
val latestEpisodes = async {
|
||||
api.userLibraryApi.getLatestMedia(
|
||||
fields = ItemRepository.itemFields,
|
||||
fields = listOf(
|
||||
ItemFields.OVERVIEW,
|
||||
),
|
||||
limit = 50,
|
||||
includeItemTypes = listOf(BaseItemKind.EPISODE),
|
||||
isPlayed = false
|
||||
@@ -298,7 +305,9 @@ class LeanbackChannelWorker(
|
||||
|
||||
val latestMovies = async {
|
||||
api.userLibraryApi.getLatestMedia(
|
||||
fields = ItemRepository.itemFields,
|
||||
fields = listOf(
|
||||
ItemFields.OVERVIEW,
|
||||
),
|
||||
limit = 50,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
||||
isPlayed = false
|
||||
@@ -307,7 +316,9 @@ class LeanbackChannelWorker(
|
||||
|
||||
val latestMedia = async {
|
||||
api.userLibraryApi.getLatestMedia(
|
||||
fields = ItemRepository.itemFields,
|
||||
fields = listOf(
|
||||
ItemFields.OVERVIEW,
|
||||
),
|
||||
limit = 50,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
isPlayed = false
|
||||
@@ -348,11 +359,23 @@ class LeanbackChannelWorker(
|
||||
)
|
||||
.setTitle(item.seriesName ?: item.name)
|
||||
.setEpisodeTitle(if (item.type == BaseItemKind.EPISODE) item.name else null)
|
||||
.setDescription(item.overview?.stripHtml())
|
||||
.setSeasonNumber(seasonString, item.parentIndexNumber ?: 0)
|
||||
.setEpisodeNumber(episodeString, item.indexNumber ?: 0)
|
||||
.setDescription(item.overview)
|
||||
.setReleaseDate(
|
||||
if (item.premiereDate != null) DateTimeFormatter.ISO_DATE.format(item.premiereDate)
|
||||
else null
|
||||
)
|
||||
.setDurationMillis(
|
||||
if (item.runTimeTicks?.ticks != null) {
|
||||
// If we are resuming, we need to show remaining time, cause GoogleTV
|
||||
// ignores setLastPlaybackPositionMillis
|
||||
val duration = item.runTimeTicks?.ticks ?: Duration.ZERO
|
||||
val playbackPosition = item.userData?.playbackPositionTicks?.ticks
|
||||
?: Duration.ZERO
|
||||
(duration - playbackPosition).inWholeMilliseconds.toInt()
|
||||
} else 0
|
||||
)
|
||||
.setPosterArtUri(imageUri)
|
||||
.setPosterArtAspectRatio(
|
||||
when (item.type) {
|
||||
@@ -366,22 +389,8 @@ class LeanbackChannelWorker(
|
||||
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
|
||||
putExtra(StartupActivity.EXTRA_ITEM_IS_USER_VIEW, item.type == BaseItemKind.COLLECTION_FOLDER)
|
||||
})
|
||||
.setDurationMillis(
|
||||
if (item.runTimeTicks?.ticks != null) {
|
||||
// If we are resuming, we need to show remaining time, cause GoogleTV
|
||||
// ignores setLastPlaybackPositionMillis
|
||||
val duration = item.runTimeTicks?.ticks ?: Duration.ZERO
|
||||
val playbackPosition = item.userData?.playbackPositionTicks?.ticks
|
||||
?: Duration.ZERO
|
||||
(duration - playbackPosition).inWholeMilliseconds.toInt()
|
||||
} else 0
|
||||
)
|
||||
.apply {
|
||||
if ((item.parentIndexNumber ?: 0) > 0)
|
||||
setSeasonNumber(seasonString, item.parentIndexNumber!!)
|
||||
if ((item.indexNumber ?: 0) > 0)
|
||||
setEpisodeNumber(episodeString, item.indexNumber!!)
|
||||
}.build().toContentValues()
|
||||
.build()
|
||||
.toContentValues()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,57 +398,16 @@ class LeanbackChannelWorker(
|
||||
* or other types of media. Uses the [nextUpItems] parameter to store items returned by a
|
||||
* NextUpQuery().
|
||||
*/
|
||||
@SuppressLint("RestrictedApi")
|
||||
private fun updateWatchNext(nextUpItems: List<BaseItemDto>) {
|
||||
deletePrograms(nextUpItems)
|
||||
// Delete current items
|
||||
context.contentResolver.delete(WatchNextPrograms.CONTENT_URI, null, null)
|
||||
|
||||
// Get current watch next state
|
||||
val currentWatchNextPrograms = getCurrentWatchNext()
|
||||
|
||||
// Create all programs in nextUpItems but not in watch next
|
||||
val programsToAdd = nextUpItems
|
||||
.filter { next -> currentWatchNextPrograms.none { it.internalProviderId == next.id.toString() } }
|
||||
// Add new items
|
||||
context.contentResolver.bulkInsert(
|
||||
WatchNextPrograms.CONTENT_URI,
|
||||
programsToAdd.map { item -> getBaseItemAsWatchNextProgram(item).toContentValues() }
|
||||
.toTypedArray())
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete stale programs from the watch next row. Items that don't need to be touched are
|
||||
* kept as is, so they keep their ordering in the watch next row.
|
||||
*/
|
||||
@SuppressLint("RestrictedApi")
|
||||
private fun deletePrograms(nextUpItems: List<BaseItemDto>) {
|
||||
// Retrieve current watch next row
|
||||
val currentWatchNextPrograms = getCurrentWatchNext()
|
||||
|
||||
// Find all stale programs to delete
|
||||
val deletedByUser = currentWatchNextPrograms.filter { !it.isBrowsable }
|
||||
val noLongerInWatchNext =
|
||||
currentWatchNextPrograms.filter { (nextUpItems).none { next -> it.internalProviderId == next.id.toString() } }
|
||||
val continueWatching = currentWatchNextPrograms.filter { it.watchNextType == WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE }
|
||||
|
||||
// Delete the programs
|
||||
(deletedByUser + noLongerInWatchNext + continueWatching)
|
||||
.forEach { context.contentResolver.delete(TvContractCompat.buildWatchNextProgramUri(it.id), null, null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current watch next row state.
|
||||
*/
|
||||
@SuppressLint("RestrictedApi")
|
||||
private fun getCurrentWatchNext(): MutableList<WatchNextProgram> {
|
||||
val currentWatchNextPrograms: MutableList<WatchNextProgram> = mutableListOf()
|
||||
context.contentResolver.query(WatchNextPrograms.CONTENT_URI, WatchNextProgram.PROJECTION, null, null, null)
|
||||
.use { cursor ->
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
do {
|
||||
currentWatchNextPrograms.add(WatchNextProgram.fromCursor(cursor))
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
return currentWatchNextPrograms
|
||||
nextUpItems.map { item -> getBaseItemAsWatchNextProgram(item).toContentValues() }
|
||||
.toTypedArray()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -461,51 +429,39 @@ class LeanbackChannelWorker(
|
||||
setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_MOVIE_POSTER)
|
||||
}
|
||||
|
||||
// Name and episode details
|
||||
if (item.seriesName != null) {
|
||||
setTitle(item.seriesName)
|
||||
setEpisodeTitle(item.name)
|
||||
|
||||
item.indexNumber?.takeIf { it > 0 }?.let { setEpisodeNumber(it) }
|
||||
item.parentIndexNumber?.takeIf { it > 0 }?.let { setSeasonNumber(it) }
|
||||
} else {
|
||||
setTitle(item.name)
|
||||
}
|
||||
|
||||
setDescription(item.overview?.stripHtml())
|
||||
// Name
|
||||
if (item.seriesName != null) setTitle("${item.seriesName} - ${item.name}")
|
||||
else setTitle(item.name)
|
||||
|
||||
// Poster
|
||||
setPosterArtUri(item.getPosterArtImageUrl(preferParentThumb))
|
||||
|
||||
// Use date created or fallback to current time if unavailable
|
||||
var engagement = item.dateCreated
|
||||
|
||||
when {
|
||||
// User has started playing the episode
|
||||
(item.userData?.playbackPositionTicks ?: 0) > 0 -> {
|
||||
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE)
|
||||
setLastPlaybackPositionMillis(item.userData!!.playbackPositionTicks.ticks.inWholeMilliseconds.toInt())
|
||||
// Use last played date to prioritize
|
||||
|
||||
setLastEngagementTimeUtcMillis(
|
||||
item.userData?.lastPlayedDate?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
|
||||
?: Instant.now().toEpochMilli()
|
||||
)
|
||||
engagement = item.userData?.lastPlayedDate
|
||||
}
|
||||
// First episode of the season
|
||||
item.indexNumber == 1 -> {
|
||||
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEW)
|
||||
setLastEngagementTimeUtcMillis(
|
||||
item.dateCreated?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
|
||||
?: Instant.now().toEpochMilli()
|
||||
)
|
||||
}
|
||||
item.indexNumber == 1 -> setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEW)
|
||||
// Default
|
||||
else -> {
|
||||
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
|
||||
setLastEngagementTimeUtcMillis(Instant.now().toEpochMilli())
|
||||
}
|
||||
else -> setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
|
||||
}
|
||||
|
||||
// Runtime has been determined
|
||||
item.runTimeTicks?.ticks?.let { setDurationMillis(it.inWholeMilliseconds.toInt()) }
|
||||
setLastEngagementTimeUtcMillis(
|
||||
engagement?.toInstant(ZoneOffset.UTC)?.toEpochMilli()
|
||||
?: System.currentTimeMillis()
|
||||
)
|
||||
|
||||
// Episode runtime has been determined
|
||||
item.runTimeTicks?.let { runTimeTicks ->
|
||||
setDurationMillis(runTimeTicks.ticks.inWholeMilliseconds.toInt())
|
||||
}
|
||||
|
||||
// Set intent to open the episode
|
||||
setIntent(Intent(context, StartupActivity::class.java).apply {
|
||||
|
||||
@@ -9,22 +9,19 @@ import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.provider.BaseColumns
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.data.repository.ItemRepository
|
||||
import org.jellyfin.androidtv.integration.provider.ImageProvider
|
||||
import org.jellyfin.androidtv.util.ImageHelper
|
||||
import org.jellyfin.androidtv.util.apiclient.getUrl
|
||||
import org.jellyfin.androidtv.util.apiclient.itemImages
|
||||
import org.jellyfin.androidtv.util.ImageUtils
|
||||
import org.jellyfin.androidtv.util.sdk.isUsable
|
||||
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.BaseItemDtoQueryResult
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import timber.log.Timber
|
||||
@@ -44,7 +41,6 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
|
||||
}
|
||||
|
||||
private val api by inject<ApiClient>()
|
||||
private val imageHelper by inject<ImageHelper>()
|
||||
|
||||
override fun onCreate(): Boolean = api.isUsable
|
||||
|
||||
@@ -64,9 +60,8 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
|
||||
|
||||
val limit = uri.getQueryParameter(SearchManager.SUGGEST_PARAMETER_LIMIT)?.toIntOrNull()
|
||||
?: DEFAULT_LIMIT
|
||||
return runBlocking { getSuggestions(query, limit) }
|
||||
return getSuggestions(query, limit)
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException("Unknown Uri: $uri")
|
||||
}
|
||||
}
|
||||
@@ -75,11 +70,11 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
|
||||
* Gets the resumable items or returns null
|
||||
*/
|
||||
private suspend fun searchItems(query: String, limit: Int): BaseItemDtoQueryResult? = try {
|
||||
val items by api.itemsApi.getItems(
|
||||
val items by api.itemsApi.getItemsByUserId(
|
||||
searchTerm = query,
|
||||
recursive = true,
|
||||
limit = limit,
|
||||
fields = ItemRepository.itemFields
|
||||
fields = setOf(ItemFields.TAGLINES)
|
||||
)
|
||||
|
||||
items
|
||||
@@ -88,7 +83,7 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun getSuggestions(query: String, limit: Int) = withContext(Dispatchers.IO) {
|
||||
private fun getSuggestions(query: String, limit: Int) = runBlocking {
|
||||
val searchResult = searchItems(query, limit)
|
||||
if (searchResult != null) Timber.d("Query resulted in %d items", searchResult.totalRecordCount)
|
||||
|
||||
@@ -108,10 +103,10 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
|
||||
|
||||
MatrixCursor(columns).also { cursor ->
|
||||
searchResult?.items?.forEach { item ->
|
||||
val imageUri = ImageProvider.getImageUri(
|
||||
item.itemImages[ImageType.PRIMARY]?.getUrl(api)
|
||||
?: imageHelper.getResourceUrl(context!!, R.drawable.tile_land_tv)
|
||||
)
|
||||
val imageUri = if (item.imageTags?.contains(ImageType.PRIMARY) == true)
|
||||
ImageProvider.getImageUri(api.imageApi.getItemImageUrl(item.id, ImageType.PRIMARY))
|
||||
else
|
||||
ImageUtils.getResourceUrl(context, R.drawable.tile_land_tv)
|
||||
|
||||
cursor.newRow().apply {
|
||||
add(BaseColumns._ID, item.id)
|
||||
|
||||
@@ -4,19 +4,15 @@ import android.service.dreams.DreamService
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.setViewTreeViewModelStoreOwner
|
||||
import androidx.savedstate.SavedStateRegistry
|
||||
import androidx.savedstate.SavedStateRegistryController
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
|
||||
abstract class DreamServiceCompat : DreamService(), SavedStateRegistryOwner, ViewModelStoreOwner {
|
||||
abstract class DreamServiceCompat : DreamService(), SavedStateRegistryOwner {
|
||||
@Suppress("LeakingThis")
|
||||
private val lifecycleRegistry = LifecycleRegistry(this)
|
||||
|
||||
@@ -26,7 +22,6 @@ abstract class DreamServiceCompat : DreamService(), SavedStateRegistryOwner, Vie
|
||||
}
|
||||
|
||||
override val lifecycle: Lifecycle get() = lifecycleRegistry
|
||||
override val viewModelStore = ViewModelStore()
|
||||
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
|
||||
|
||||
@CallSuper
|
||||
@@ -51,12 +46,9 @@ abstract class DreamServiceCompat : DreamService(), SavedStateRegistryOwner, Vie
|
||||
|
||||
fun setContent(content: @Composable () -> Unit) {
|
||||
val view = ComposeView(this)
|
||||
// Set composition strategy
|
||||
view.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
|
||||
// Inject dependencies normally added by appcompat activities
|
||||
view.setViewTreeLifecycleOwner(this)
|
||||
view.setViewTreeViewModelStoreOwner(this)
|
||||
view.setViewTreeSavedStateRegistryOwner(this)
|
||||
|
||||
// Set content composable
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
package org.jellyfin.androidtv.integration.dream
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.cancellable
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
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.util.apiclient.getUrl
|
||||
import org.jellyfin.androidtv.util.apiclient.itemBackdropImages
|
||||
import org.jellyfin.androidtv.util.apiclient.itemImages
|
||||
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.itemsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
class DreamViewModel(
|
||||
private val api: ApiClient,
|
||||
private val imageLoader: ImageLoader,
|
||||
private val context: Context,
|
||||
playbackManager: PlaybackManager,
|
||||
private val userPreferences: UserPreferences,
|
||||
) : ViewModel() {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val _mediaContent = playbackManager.queue.entry
|
||||
.map { entry ->
|
||||
entry
|
||||
?.takeIf { it.visibleInScreensaver }
|
||||
?.baseItem
|
||||
?.let { baseItem -> DreamContent.NowPlaying(entry, baseItem) }
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
|
||||
|
||||
private val _libraryContent = flow {
|
||||
// Load first library item after 2 seconds
|
||||
// to force the logo at the start of the screensaver
|
||||
emit(null)
|
||||
delay(2.seconds)
|
||||
|
||||
val requireParentalRating = userPreferences[UserPreferences.screensaverAgeRatingRequired]
|
||||
val maxParentalRating = userPreferences[UserPreferences.screensaverAgeRatingMax]
|
||||
emitAll(
|
||||
getRandomLibraryShowcaseItems(
|
||||
requireParentalRating = requireParentalRating,
|
||||
maxParentalRating = maxParentalRating,
|
||||
// A batch size of 60 should be equal to 30 minutes of items
|
||||
batchSize = 60,
|
||||
emitDelay = 30.seconds,
|
||||
noItemsDelay = 2.minutes,
|
||||
errorDelay = 3.seconds,
|
||||
)
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
|
||||
|
||||
val content = combine(_mediaContent, _libraryContent) { mediaContent, libraryContent ->
|
||||
mediaContent ?: libraryContent ?: DreamContent.Logo
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = _mediaContent.value ?: _libraryContent.value ?: DreamContent.Logo,
|
||||
)
|
||||
|
||||
private fun getRandomLibraryShowcaseItems(
|
||||
requireParentalRating: Boolean,
|
||||
maxParentalRating: Int,
|
||||
batchSize: Int,
|
||||
emitDelay: Duration,
|
||||
noItemsDelay: Duration,
|
||||
errorDelay: Duration,
|
||||
): Flow<DreamContent.LibraryShowcase?> = flow {
|
||||
while (true) {
|
||||
val items = try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val response by api.itemsApi.getItems(
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
recursive = true,
|
||||
sortBy = listOf(ItemSortBy.RANDOM),
|
||||
limit = batchSize,
|
||||
imageTypes = listOf(ImageType.BACKDROP),
|
||||
maxOfficialRating = if (maxParentalRating == -1) null else maxParentalRating.toString(),
|
||||
hasParentalRating = if (requireParentalRating) true else null,
|
||||
)
|
||||
response.items
|
||||
}
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err)
|
||||
null
|
||||
}
|
||||
|
||||
if (items == null) {
|
||||
emit(null)
|
||||
delay(errorDelay)
|
||||
} else if (items.isEmpty()) {
|
||||
emit(null)
|
||||
delay(noItemsDelay)
|
||||
} else {
|
||||
for (item in items) {
|
||||
if (item.itemBackdropImages.isEmpty()) continue
|
||||
val showcase = item.asLibraryShowcase() ?: continue
|
||||
emit(showcase)
|
||||
delay(emitDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.cancellable()
|
||||
|
||||
private suspend fun BaseItemDto.asLibraryShowcase(): DreamContent.LibraryShowcase? = withContext(Dispatchers.IO) {
|
||||
val logoUrl = itemImages[ImageType.LOGO]?.getUrl(api)
|
||||
val backdropUrl = itemBackdropImages.randomOrNull()?.getUrl(api)
|
||||
|
||||
// Require a backdrop
|
||||
if (backdropUrl == null) return@withContext null
|
||||
|
||||
// Only attempt to load logo if there is one, wrap in async {} to load it parallel with the backdrop
|
||||
val logo = logoUrl?.let { url ->
|
||||
async {
|
||||
imageLoader.execute(
|
||||
request = ImageRequest.Builder(context).data(url).build()
|
||||
).image?.toBitmap()
|
||||
}
|
||||
}
|
||||
|
||||
val backdrop = imageLoader.execute(
|
||||
request = ImageRequest.Builder(context).data(backdropUrl).build()
|
||||
).image?.toBitmap()
|
||||
|
||||
if (backdrop == null) null
|
||||
else DreamContent.LibraryShowcase(this@asLibraryShowcase, backdrop, logo?.await())
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package org.jellyfin.androidtv.integration.dream
|
||||
|
||||
import org.jellyfin.playback.core.element.ElementKey
|
||||
import org.jellyfin.playback.core.element.requiredElement
|
||||
import org.jellyfin.playback.core.queue.QueueEntry
|
||||
|
||||
private val visibleInScreensaverKey = ElementKey<Boolean>("visibleInScreensaver")
|
||||
|
||||
/**
|
||||
* Get or set whether this [QueueEntry] is shown in the screensaver.
|
||||
*/
|
||||
var QueueEntry.visibleInScreensaver by requiredElement(visibleInScreensaverKey) { false }
|
||||
@@ -2,10 +2,9 @@ package org.jellyfin.androidtv.integration.dream.composable
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -13,12 +12,10 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.jellyfin.androidtv.integration.dream.model.DreamContent
|
||||
import org.jellyfin.androidtv.ui.base.Text
|
||||
import org.jellyfin.androidtv.ui.composable.ZoomBox
|
||||
import org.jellyfin.androidtv.ui.composable.modifier.overscan
|
||||
import org.jellyfin.androidtv.ui.composable.overscan
|
||||
|
||||
@Composable
|
||||
fun DreamContentLibraryShowcase(
|
||||
@@ -50,26 +47,12 @@ fun DreamContentLibraryShowcase(
|
||||
.align(Alignment.BottomStart)
|
||||
.overscan(),
|
||||
) {
|
||||
if (content.logo != null) {
|
||||
BoxWithConstraints {
|
||||
Image(
|
||||
bitmap = content.logo.asImageBitmap(),
|
||||
contentDescription = content.item.name,
|
||||
modifier = Modifier
|
||||
.sizeIn(
|
||||
maxWidth = maxWidth * 0.35f,
|
||||
maxHeight = 75.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = content.item.name.orEmpty(),
|
||||
style = TextStyle(
|
||||
color = Color.White,
|
||||
fontSize = 32.sp
|
||||
),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = content.item.name.orEmpty(),
|
||||
style = TextStyle(
|
||||
color = Color.White,
|
||||
fontSize = 32.sp
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,40 +13,38 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.LinearProgressIndicator
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
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
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.jellyfin.androidtv.integration.dream.model.DreamContent
|
||||
import org.jellyfin.androidtv.ui.base.SeekbarDefaults
|
||||
import org.jellyfin.androidtv.ui.base.Text
|
||||
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.modifier.fadingEdges
|
||||
import org.jellyfin.androidtv.ui.composable.modifier.overscan
|
||||
import org.jellyfin.androidtv.ui.composable.rememberPlayerProgress
|
||||
import org.jellyfin.androidtv.ui.player.base.PlayerSeekbar
|
||||
import org.jellyfin.androidtv.util.apiclient.albumPrimaryImage
|
||||
import org.jellyfin.androidtv.util.apiclient.getUrl
|
||||
import org.jellyfin.androidtv.util.apiclient.itemImages
|
||||
import org.jellyfin.androidtv.util.apiclient.parentImages
|
||||
import org.jellyfin.playback.core.PlaybackManager
|
||||
import org.jellyfin.playback.core.model.PlayState
|
||||
import org.jellyfin.playback.jellyfin.lyrics.lyrics
|
||||
import org.jellyfin.playback.jellyfin.lyrics.lyricsFlow
|
||||
import org.jellyfin.androidtv.ui.composable.overscan
|
||||
import org.jellyfin.androidtv.ui.playback.AudioEventListener
|
||||
import org.jellyfin.androidtv.ui.playback.MediaManager
|
||||
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.koin.compose.koinInject
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import org.koin.compose.rememberKoinInject
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
fun DreamContentNowPlaying(
|
||||
@@ -54,18 +52,21 @@ fun DreamContentNowPlaying(
|
||||
) = Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
val api = koinInject<ApiClient>()
|
||||
val playbackManager = koinInject<PlaybackManager>()
|
||||
val lyrics = content.entry.run { lyricsFlow.collectAsState(lyrics) }.value
|
||||
val api = rememberKoinInject<ApiClient>()
|
||||
val mediaManager = rememberKoinInject<MediaManager>()
|
||||
val item = content.item ?: return@Box
|
||||
|
||||
val primaryImage = content.item.itemImages[ImageType.PRIMARY]
|
||||
?: content.item.albumPrimaryImage
|
||||
?: content.item.parentImages[ImageType.PRIMARY]
|
||||
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
|
||||
}
|
||||
|
||||
// Background
|
||||
if (primaryImage?.blurHash != null) {
|
||||
val imageBlurHash = imageTag?.let { tag -> item.imageBlurHashes?.get(ImageType.PRIMARY)?.get(tag) }
|
||||
if (imageBlurHash != null) {
|
||||
Image(
|
||||
painter = blurHashPainter(primaryImage.blurHash, IntSize(32, 32)),
|
||||
painter = blurHashPainter(imageBlurHash, IntSize(32, 32)),
|
||||
contentDescription = null,
|
||||
alignment = Alignment.Center,
|
||||
contentScale = ContentScale.Crop,
|
||||
@@ -75,29 +76,7 @@ fun DreamContentNowPlaying(
|
||||
DreamContentVignette()
|
||||
}
|
||||
|
||||
// Lyrics overlay (on top of background)
|
||||
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 = 22.sp,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.fadingEdges(vertical = 250.dp)
|
||||
.padding(horizontal = 50.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Metadata overlay (includes title / progress)
|
||||
// Overlay
|
||||
Row(
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||
@@ -105,10 +84,15 @@ fun DreamContentNowPlaying(
|
||||
.align(Alignment.BottomStart)
|
||||
.overscan(),
|
||||
) {
|
||||
if (primaryImage != null) {
|
||||
if (imageItemId != null) {
|
||||
AsyncImage(
|
||||
url = primaryImage.getUrl(api),
|
||||
blurHash = primaryImage.blurHash,
|
||||
url = api.imageApi.getItemImageUrl(
|
||||
itemId = imageItemId,
|
||||
imageType = ImageType.PRIMARY,
|
||||
tag = imageTag,
|
||||
format = ImageFormat.WEBP,
|
||||
),
|
||||
blurHash = imageBlurHash,
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP,
|
||||
modifier = Modifier
|
||||
.size(128.dp)
|
||||
@@ -121,7 +105,7 @@ fun DreamContentNowPlaying(
|
||||
.padding(bottom = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = content.item.name.orEmpty(),
|
||||
text = item.name.orEmpty(),
|
||||
style = TextStyle(
|
||||
color = Color.White,
|
||||
fontSize = 26.sp,
|
||||
@@ -129,16 +113,12 @@ fun DreamContentNowPlaying(
|
||||
)
|
||||
|
||||
Text(
|
||||
text = content.item.run {
|
||||
val artistNames = artists.orEmpty()
|
||||
val albumArtistNames = albumArtists?.mapNotNull { it.name }.orEmpty()
|
||||
|
||||
when {
|
||||
artistNames.isNotEmpty() -> artistNames
|
||||
albumArtistNames.isNotEmpty() -> albumArtistNames
|
||||
else -> listOfNotNull(albumArtist)
|
||||
}.joinToString(", ")
|
||||
},
|
||||
text = item.run {
|
||||
if (!artists.isNullOrEmpty()) return@run artists?.joinToString(", ")
|
||||
val albumArtistNames = albumArtists?.mapNotNull { it.name }
|
||||
if (!albumArtistNames.isNullOrEmpty()) return@run albumArtistNames.joinToString(", ")
|
||||
return@run albumArtist
|
||||
}.orEmpty(),
|
||||
style = TextStyle(
|
||||
color = Color(0.8f, 0.8f, 0.8f),
|
||||
fontSize = 18.sp,
|
||||
@@ -147,16 +127,28 @@ fun DreamContentNowPlaying(
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
PlayerSeekbar(
|
||||
playbackManager = playbackManager,
|
||||
colors = SeekbarDefaults.colors(
|
||||
backgroundColor = Color.White.copy(alpha = 0.2f),
|
||||
progressColor = Color.White,
|
||||
bufferColor = Color.Transparent,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.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)
|
||||
}
|
||||
}
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = progress,
|
||||
color = Color.White,
|
||||
backgroundColor = Color.White.copy(alpha = 0.2f),
|
||||
strokeCap = StrokeCap.Round,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,72 @@
|
||||
package org.jellyfin.androidtv.integration.dream.composable
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.jellyfin.androidtv.ui.composable.modifier.overscan
|
||||
import org.jellyfin.androidtv.ui.shared.toolbar.ToolbarClock
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.composable.overscan
|
||||
import org.jellyfin.androidtv.ui.composable.rememberCurrentTime
|
||||
|
||||
@Composable
|
||||
fun DreamHeader(
|
||||
showLogo: Boolean,
|
||||
showClock: Boolean,
|
||||
) = Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.overscan(),
|
||||
) {
|
||||
// Clock
|
||||
AnimatedVisibility(
|
||||
visible = showClock,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd),
|
||||
.fillMaxWidth()
|
||||
.overscan(),
|
||||
) {
|
||||
ToolbarClock()
|
||||
// Logo
|
||||
AnimatedVisibility(
|
||||
visible = showLogo,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier.height(41.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.app_logo),
|
||||
contentDescription = stringResource(R.string.app_name),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0f)
|
||||
)
|
||||
|
||||
// Clock
|
||||
AnimatedVisibility(
|
||||
visible = showClock,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
val time = rememberCurrentTime()
|
||||
Text(
|
||||
text = DateUtils.formatDateTime(LocalContext.current, time, DateUtils.FORMAT_SHOW_TIME),
|
||||
style = TextStyle(
|
||||
color = Color.White,
|
||||
fontSize = 20.sp
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,111 @@
|
||||
package org.jellyfin.androidtv.integration.dream.composable
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import org.jellyfin.androidtv.integration.dream.DreamViewModel
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.integration.dream.model.DreamContent
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.preference.constant.ClockBehavior
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.compose.koinInject
|
||||
import org.jellyfin.androidtv.ui.composable.rememberMediaItem
|
||||
import org.jellyfin.androidtv.ui.playback.MediaManager
|
||||
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.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageFormat
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.constant.ItemSortBy
|
||||
import org.koin.compose.rememberKoinInject
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Composable
|
||||
fun DreamHost() {
|
||||
val viewModel = koinViewModel<DreamViewModel>()
|
||||
val userPreferences = koinInject<UserPreferences>()
|
||||
val content by viewModel.content.collectAsState()
|
||||
val api = rememberKoinInject<ApiClient>()
|
||||
val userPreferences = rememberKoinInject<UserPreferences>()
|
||||
val mediaManager = rememberKoinInject<MediaManager>()
|
||||
val imageLoader = rememberKoinInject<ImageLoader>()
|
||||
val context = LocalContext.current
|
||||
|
||||
var libraryShowcase by remember { mutableStateOf<DreamContent.LibraryShowcase?>(null) }
|
||||
val mediaItem by rememberMediaItem(mediaManager)
|
||||
|
||||
LaunchedEffect(true) {
|
||||
delay(2.seconds)
|
||||
|
||||
while (true) {
|
||||
libraryShowcase = getRandomLibraryShowcase(api, imageLoader, context)
|
||||
delay(30.seconds)
|
||||
}
|
||||
}
|
||||
|
||||
DreamView(
|
||||
content = content,
|
||||
content = when {
|
||||
mediaItem != null -> DreamContent.NowPlaying(mediaItem)
|
||||
libraryShowcase != null -> libraryShowcase!!
|
||||
else -> DreamContent.Logo
|
||||
},
|
||||
showClock = when (userPreferences[UserPreferences.clockBehavior]) {
|
||||
ClockBehavior.ALWAYS, ClockBehavior.IN_MENUS -> true
|
||||
else -> false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getRandomLibraryShowcase(
|
||||
api: ApiClient,
|
||||
imageLoader: ImageLoader,
|
||||
context: Context
|
||||
): DreamContent.LibraryShowcase? {
|
||||
try {
|
||||
val response by api.itemsApi.getItemsByUserId(
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
recursive = true,
|
||||
sortBy = listOf(ItemSortBy.Random),
|
||||
limit = 5,
|
||||
imageTypes = listOf(ImageType.BACKDROP),
|
||||
// TODO: Add preferences for these two settings
|
||||
maxOfficialRating = "PG-13",
|
||||
// hasParentalRating = true,
|
||||
)
|
||||
|
||||
val item = response.items?.firstOrNull { item ->
|
||||
!item.backdropImageTags.isNullOrEmpty()
|
||||
} ?: return null
|
||||
|
||||
Timber.i("Loading random library showcase item ${item.id}")
|
||||
|
||||
val tag = item.backdropImageTags!!.randomOrNull()
|
||||
?: item.imageTags?.get(ImageType.BACKDROP)
|
||||
|
||||
val backdropUrl = api.imageApi.getItemImageUrl(
|
||||
itemId = item.id,
|
||||
imageType = ImageType.BACKDROP,
|
||||
tag = tag,
|
||||
format = ImageFormat.WEBP,
|
||||
)
|
||||
|
||||
val backdrop = withContext(Dispatchers.IO) {
|
||||
imageLoader.execute(
|
||||
request = ImageRequest.Builder(context).data(backdropUrl).build()
|
||||
).drawable?.toBitmap()
|
||||
} ?: return null
|
||||
|
||||
return DreamContent.LibraryShowcase(item, backdrop)
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.jellyfin.androidtv.integration.dream.composable
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -12,6 +13,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.jellyfin.androidtv.integration.dream.model.DreamContent
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
fun DreamView(
|
||||
content: DreamContent,
|
||||
@@ -36,6 +38,7 @@ fun DreamView(
|
||||
|
||||
// Header overlay
|
||||
DreamHeader(
|
||||
showLogo = content != DreamContent.Logo,
|
||||
showClock = showClock,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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 entry: QueueEntry, val item: BaseItemDto) : DreamContent
|
||||
data class LibraryShowcase(val item: BaseItemDto, val backdrop: Bitmap) : DreamContent
|
||||
data class NowPlaying(val item: BaseItemDto?) : DreamContent
|
||||
}
|
||||
|
||||
@@ -9,10 +9,8 @@ import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.net.toUri
|
||||
import coil3.ImageLoader
|
||||
import coil3.asDrawable
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.error
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.koin.android.ext.android.inject
|
||||
@@ -39,8 +37,8 @@ class ImageProvider : ContentProvider() {
|
||||
data(src)
|
||||
error(R.drawable.placeholder_icon)
|
||||
target(
|
||||
onSuccess = { image -> writeDrawable(image.asDrawable(context!!.resources), outputStream) },
|
||||
onError = { image -> writeDrawable(requireNotNull(image?.asDrawable(context!!.resources)), outputStream) }
|
||||
onSuccess = { drawable -> writeDrawable(drawable, outputStream) },
|
||||
onError = { drawable -> writeDrawable(requireNotNull(drawable), outputStream) }
|
||||
)
|
||||
}.build())
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@ import org.jellyfin.androidtv.constant.PosterSize
|
||||
import org.jellyfin.androidtv.preference.store.DisplayPreferencesStore
|
||||
import org.jellyfin.preference.booleanPreference
|
||||
import org.jellyfin.preference.enumPreference
|
||||
import org.jellyfin.preference.stringPreference
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.constant.ItemSortBy
|
||||
|
||||
class LibraryPreferences(
|
||||
displayPreferencesId: String,
|
||||
@@ -28,7 +29,7 @@ class LibraryPreferences(
|
||||
val filterUnwatchedOnly = booleanPreference("FilterUnwatchedOnly", false)
|
||||
|
||||
// Item sorting
|
||||
val sortBy = enumPreference("SortBy", ItemSortBy.SORT_NAME)
|
||||
val sortBy = stringPreference("SortBy", ItemSortBy.SortName)
|
||||
val sortOrder = enumPreference("SortOrder", SortOrder.ASCENDING)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import org.jellyfin.androidtv.preference.store.DisplayPreferencesStore
|
||||
import org.jellyfin.preference.booleanPreference
|
||||
import org.jellyfin.preference.stringPreference
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.constant.ItemSortBy
|
||||
|
||||
class LiveTvPreferences(
|
||||
api: ApiClient,
|
||||
@@ -13,7 +13,7 @@ class LiveTvPreferences(
|
||||
api = api,
|
||||
) {
|
||||
companion object {
|
||||
val channelOrder = stringPreference("livetv-channelorder", ItemSortBy.DATE_PLAYED.name)
|
||||
val channelOrder = stringPreference("livetv-channelorder", ItemSortBy.DatePlayed)
|
||||
val colorCodeGuide = booleanPreference("guide-colorcodedbackgrounds", false)
|
||||
val favsAtTop = booleanPreference("livetv-favoritechannelsattop", true)
|
||||
val showHDIndicator = booleanPreference("guide-indicator-hd", false)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package org.jellyfin.androidtv.preference
|
||||
|
||||
import android.content.Context
|
||||
import org.jellyfin.androidtv.preference.constant.PreferredVideoPlayer
|
||||
import org.jellyfin.preference.booleanPreference
|
||||
import org.jellyfin.preference.enumPreference
|
||||
import org.jellyfin.preference.store.SharedPreferenceStore
|
||||
import org.jellyfin.preference.stringPreference
|
||||
|
||||
@@ -58,6 +60,11 @@ class SystemPreferences(context: Context) : SharedPreferenceStore(
|
||||
val liveTvGuideFilterSports = booleanPreference("guide_filter_sports", false)
|
||||
|
||||
// Other persistent variables
|
||||
/**
|
||||
* Chosen player for play with button. Changes every time user chooses a player with "play with" button.
|
||||
*/
|
||||
var chosenPlayer = enumPreference("chosen_player", PreferredVideoPlayer.VLC)
|
||||
|
||||
/**
|
||||
* The version name for the latest dismissed beta notification or empty if none.
|
||||
*/
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
package org.jellyfin.androidtv.preference
|
||||
|
||||
import android.content.Context
|
||||
import android.view.KeyEvent
|
||||
import androidx.preference.PreferenceManager
|
||||
import org.jellyfin.androidtv.preference.UserPreferences.Companion.screensaverInAppEnabled
|
||||
import org.jellyfin.androidtv.preference.constant.AppTheme
|
||||
import org.jellyfin.androidtv.preference.constant.AudioBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.BackdropBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.ClockBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.NextUpBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.PreferredVideoPlayer
|
||||
import org.jellyfin.androidtv.preference.constant.RatingType
|
||||
import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.StillWatchingBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.WatchedIndicatorBehavior
|
||||
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.androidtv.preference.constant.defaultAudioBehavior
|
||||
import org.jellyfin.androidtv.util.DeviceUtils
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -34,6 +30,11 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* The value used for automatic detection in [maxBitrate].
|
||||
*/
|
||||
const val MAX_BITRATE_AUTO = "0"
|
||||
|
||||
/* Display */
|
||||
/**
|
||||
* Select the app theme
|
||||
@@ -41,13 +42,24 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
var appTheme = enumPreference("app_theme", AppTheme.DARK)
|
||||
|
||||
/**
|
||||
* Behavior of app background while browsing
|
||||
* Enable background images while browsing
|
||||
*/
|
||||
var backdropBehavior = enumPreference("backdrop_behavior", BackdropBehavior.BACKDROP_WITH_BLUR)
|
||||
var backdropEnabled = booleanPreference("pref_show_backdrop", true)
|
||||
|
||||
/**
|
||||
* Show premieres on home screen
|
||||
*/
|
||||
var premieresEnabled = booleanPreference("pref_enable_premieres", false)
|
||||
|
||||
/**
|
||||
* Enable management of media like deleting items when the user has sufficient permisisons.
|
||||
*/
|
||||
var mediaManagementEnabled = booleanPreference("enable_media_management", false)
|
||||
|
||||
/* Playback - General*/
|
||||
/**
|
||||
* Maximum bitrate in megabit for playback.
|
||||
* Maximum bitrate in megabit for playback. A value of [MAX_BITRATE_AUTO] is used when
|
||||
* the bitrate should be automatically detected.
|
||||
*/
|
||||
var maxBitrate = stringPreference("pref_max_bitrate", "100")
|
||||
|
||||
@@ -77,47 +89,47 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
*/
|
||||
var cinemaModeEnabled = booleanPreference("pref_enable_cinema_mode", true)
|
||||
|
||||
/**
|
||||
* Enable still watching
|
||||
*/
|
||||
var stillWatchingBehavior = enumPreference("enable_still_watching", StillWatchingBehavior.DISABLED)
|
||||
|
||||
/* Playback - Video */
|
||||
/**
|
||||
* Whether to use an external playback application or not.
|
||||
* Preferred video player.
|
||||
*/
|
||||
var useExternalPlayer = booleanPreference("external_player", false)
|
||||
|
||||
/**
|
||||
* Component name for the external playback application.
|
||||
*/
|
||||
var externalPlayerComponentName = stringPreference("external_player_component", "")
|
||||
var videoPlayer = enumPreference("video_player", PreferredVideoPlayer.EXOPLAYER)
|
||||
|
||||
/**
|
||||
* Change refresh rate to match media when device supports it
|
||||
*/
|
||||
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.DIRECT_STREAM)
|
||||
var audioBehaviour = enumPreference("audio_behavior", defaultAudioBehavior)
|
||||
|
||||
/**
|
||||
* Preferred behavior for audio streaming.
|
||||
*/
|
||||
var audioNightMode = enumPreference("audio_night_mode", false)
|
||||
|
||||
/**
|
||||
* Enable DTS
|
||||
*/
|
||||
var dtsEnabled = booleanPreference("pref_bitstream_dts", false)
|
||||
|
||||
/**
|
||||
* Enable AC3
|
||||
*/
|
||||
var ac3Enabled = booleanPreference("pref_bitstream_ac3", true)
|
||||
var ac3Enabled = booleanPreference("pref_bitstream_ac3", !DeviceUtils.isFireTvStickGen1)
|
||||
|
||||
/**
|
||||
* Default audio delay in milliseconds for libVLC
|
||||
*/
|
||||
var libVLCAudioDelay = intPreference("libvlc_audio_delay", 0)
|
||||
|
||||
/**
|
||||
* Use playback rewrite module for audio
|
||||
*/
|
||||
var playbackRewriteAudioEnabled = booleanPreference("playback_new_audio", true)
|
||||
|
||||
/* Live TV */
|
||||
/**
|
||||
@@ -125,6 +137,21 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
*/
|
||||
var liveTvDirectPlayEnabled = booleanPreference("pref_live_direct", true)
|
||||
|
||||
/**
|
||||
* Preferred video player for live TV
|
||||
*/
|
||||
var liveTvVideoPlayer = enumPreference("live_tv_video_player", PreferredVideoPlayer.AUTO)
|
||||
|
||||
/**
|
||||
* Shortcut used for changing the audio track
|
||||
*/
|
||||
var shortcutAudioTrack = intPreference("shortcut_audio_track", KeyEvent.KEYCODE_MEDIA_AUDIO_TRACK)
|
||||
|
||||
/**
|
||||
* Shortcut used for changing the subtitle track
|
||||
*/
|
||||
var shortcutSubtitleTrack = intPreference("shortcut_subtitle_track", KeyEvent.KEYCODE_CAPTIONS)
|
||||
|
||||
/* Developer options */
|
||||
/**
|
||||
* Show additional debug information
|
||||
@@ -141,6 +168,11 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
*/
|
||||
var clockBehavior = enumPreference("pref_clock_behavior", ClockBehavior.ALWAYS)
|
||||
|
||||
/**
|
||||
* Set which ratings provider should show on MyImageCardViews
|
||||
*/
|
||||
var defaultRatingType = enumPreference("pref_rating_type", RatingType.RATING_TOMATOES)
|
||||
|
||||
/**
|
||||
* Set when watched indicators should show on MyImageCardViews
|
||||
*/
|
||||
@@ -152,35 +184,30 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
var seriesThumbnailsEnabled = booleanPreference("pref_enable_series_thumbnails", true)
|
||||
|
||||
/**
|
||||
* Subtitles foreground color
|
||||
* Enable subtitles background
|
||||
*/
|
||||
var subtitlesBackgroundColor = longPreference("subtitles_background_color", 0x00FFFFFF)
|
||||
var subtitlesBackgroundEnabled = booleanPreference("subtitles_background_enabled", true)
|
||||
|
||||
/**
|
||||
* Subtitles bold text
|
||||
* Subtitles font size
|
||||
*/
|
||||
var subtitlesTextWeight = intPreference("subtitles_text_weight", 400)
|
||||
var subtitlesSize = intPreference("subtitles_size", 28)
|
||||
|
||||
/**
|
||||
* Subtitles stroke size
|
||||
*/
|
||||
var subtitleStrokeSize = intPreference("subtitles_stroke_size", 0)
|
||||
|
||||
/**
|
||||
* Subtitles position
|
||||
*/
|
||||
var subtitlePosition = intPreference("subtitles_position", 40)
|
||||
|
||||
/**
|
||||
* Subtitles foreground color
|
||||
*/
|
||||
var subtitlesTextColor = longPreference("subtitles_text_color", 0xFFFFFFFF)
|
||||
|
||||
/**
|
||||
* Subtitles stroke color
|
||||
*/
|
||||
var subtitleTextStrokeColor = longPreference("subtitles_text_stroke_color", 0xFF000000)
|
||||
|
||||
/**
|
||||
* Subtitles font size
|
||||
*/
|
||||
var subtitlesTextSize = floatPreference("subtitles_text_size", 24f)
|
||||
|
||||
/**
|
||||
* Subtitles offset
|
||||
*/
|
||||
var subtitlesOffsetPosition = floatPreference("subtitles_offset_position", 0.08f)
|
||||
|
||||
/**
|
||||
* Show screensaver in app
|
||||
*/
|
||||
@@ -192,50 +219,9 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
var screensaverInAppTimeout = longPreference("screensaver_inapp_timeout", 5.minutes.inWholeMilliseconds)
|
||||
|
||||
/**
|
||||
* Age rating used to filter items in the screensaver. Use -1 to disable (omits parameter from requests).
|
||||
* Enable reactive homepage
|
||||
*/
|
||||
var screensaverAgeRatingMax = intPreference("screensaver_agerating_max", 13)
|
||||
|
||||
/**
|
||||
* Whether items shown in the screensaver are required to have an age rating set.
|
||||
*/
|
||||
var screensaverAgeRatingRequired = booleanPreference("screensaver_agerating_required", true)
|
||||
|
||||
/**
|
||||
* Delay when starting video playback after loading the video player.
|
||||
*/
|
||||
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)
|
||||
|
||||
/**
|
||||
* Enable libass.
|
||||
*/
|
||||
var assDirectPlay = booleanPreference("libass_enabled", false)
|
||||
|
||||
/**
|
||||
* Enable PGS subtitle direct-play.
|
||||
*/
|
||||
var pgsDirectPlay = booleanPreference("pgs_enabled", true)
|
||||
var homeReactive = booleanPreference("home_reactive", false)
|
||||
}
|
||||
|
||||
init {
|
||||
@@ -248,30 +234,6 @@ 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)
|
||||
}
|
||||
|
||||
// v0.19.0 to v0.20.0
|
||||
migration(toVersion = 9) {
|
||||
// Reset subtitle text size as we changed from fractional sizing to absolute sizing
|
||||
remove("subtitles_text_size")
|
||||
|
||||
// Set the BackdropBehavior if it was enabled in a previous version
|
||||
val backdropEnabled = it.getBoolean("pref_show_backdrop", true)
|
||||
putString(
|
||||
"backdrop_behavior",
|
||||
if (backdropEnabled) BackdropBehavior.BACKDROP_WITH_BLUR.name else BackdropBehavior.DISABLED.name
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,26 +24,10 @@ 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 = listOf(
|
||||
homesection0,
|
||||
homesection1,
|
||||
homesection2,
|
||||
homesection3,
|
||||
homesection4,
|
||||
homesection5,
|
||||
homesection6,
|
||||
homesection7,
|
||||
homesection8,
|
||||
homesection9,
|
||||
)
|
||||
|
||||
val activeHomesections
|
||||
get() = homesections
|
||||
val homesections
|
||||
get() = listOf(homesection0, homesection1, homesection2, homesection3, homesection4, homesection5, homesection6)
|
||||
.map(::get)
|
||||
.filterNot { it == HomeSectionType.NONE }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.jellyfin.androidtv.preference.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.util.DeviceUtils
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
|
||||
enum class AudioBehavior(
|
||||
@@ -16,3 +17,6 @@ enum class AudioBehavior(
|
||||
*/
|
||||
DOWNMIX_TO_STEREO(R.string.pref_audio_compat),
|
||||
}
|
||||
|
||||
val defaultAudioBehavior = if (DeviceUtils.isChromecastWithGoogleTV) AudioBehavior.DOWNMIX_TO_STEREO
|
||||
else AudioBehavior.DIRECT_STREAM
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package org.jellyfin.androidtv.preference.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
|
||||
enum class BackdropBehavior(
|
||||
override val nameRes: Int,
|
||||
) : PreferenceEnum {
|
||||
DISABLED(R.string.state_disabled),
|
||||
|
||||
/**
|
||||
* When selected, backdrop is shown with a blur effect.
|
||||
*/
|
||||
BACKDROP_WITH_BLUR(R.string.backdrop_behavior_with_blur),
|
||||
|
||||
/**
|
||||
* When selected, backdrop is shown without any blur effect.
|
||||
*/
|
||||
BACKDROP_WITHOUT_BLUR(R.string.backdrop_behavior_without_blur),
|
||||
}
|
||||
@@ -2,17 +2,17 @@ package org.jellyfin.androidtv.preference.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.constant.ItemSortBy
|
||||
|
||||
enum class LiveTvChannelOrder(
|
||||
override val nameRes: Int,
|
||||
val stringValue: String,
|
||||
) : PreferenceEnum {
|
||||
LAST_PLAYED(R.string.lbl_guide_option_played, ItemSortBy.DATE_PLAYED.serialName),
|
||||
CHANNEL_NUMBER(R.string.lbl_guide_option_number, ItemSortBy.SORT_NAME.serialName);
|
||||
LAST_PLAYED(R.string.lbl_guide_option_played, ItemSortBy.DatePlayed),
|
||||
CHANNEL_NUMBER(R.string.lbl_guide_option_number, ItemSortBy.SortName);
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String) = entries
|
||||
fun fromString(value: String) = values()
|
||||
.firstOrNull { it.stringValue.equals(value, ignoreCase = true) }
|
||||
?: LAST_PLAYED
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.jellyfin.androidtv.preference.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
|
||||
enum class PreferredVideoPlayer(
|
||||
override val nameRes: Int,
|
||||
) : PreferenceEnum {
|
||||
/**
|
||||
* Automatically selects between exoplayer and vlc
|
||||
*/
|
||||
AUTO(R.string.pref_video_player_auto),
|
||||
|
||||
/**
|
||||
* Force ExoPlayer
|
||||
*/
|
||||
EXOPLAYER(R.string.pref_video_player_exoplayer),
|
||||
|
||||
/**
|
||||
* Force libVLC
|
||||
*/
|
||||
VLC(R.string.pref_video_player_vlc),
|
||||
|
||||
/**
|
||||
* Use external player
|
||||
*/
|
||||
EXTERNAL(R.string.pref_video_player_external),
|
||||
|
||||
/**
|
||||
* Choose a player - play with button
|
||||
*/
|
||||
CHOOSE(R.string.pref_video_player_choose),
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.jellyfin.androidtv.preference.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
|
||||
enum class RatingType(
|
||||
override val nameRes: Int,
|
||||
) : PreferenceEnum {
|
||||
/**
|
||||
* Sets default rating type to tomatoes.
|
||||
*/
|
||||
RATING_TOMATOES(R.string.lbl_tomatoes),
|
||||
|
||||
/**
|
||||
* Sets the default rating type to stars.
|
||||
*/
|
||||
RATING_STARS(R.string.lbl_stars),
|
||||
|
||||
/**
|
||||
* Sets the default rating type to hidden.
|
||||
*/
|
||||
RATING_HIDDEN(R.string.lbl_hidden),
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package org.jellyfin.androidtv.preference.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
enum class StillWatchingBehavior(
|
||||
override val nameRes: Int,
|
||||
val enabled: Boolean,
|
||||
val episodeCount: Int,
|
||||
val minDuration: Duration,
|
||||
) : PreferenceEnum {
|
||||
/**
|
||||
* Shorter than default. Show screen at 2 episodes or 60 minutes of uninterrupted watch time.
|
||||
*/
|
||||
SHORT(
|
||||
nameRes = R.string.lbl_still_watching_short,
|
||||
enabled = true,
|
||||
episodeCount = 2,
|
||||
minDuration = 1.hours,
|
||||
),
|
||||
|
||||
/**
|
||||
* Default behavior for still watching. Show screen at 3 episodes or 90 minutes of uninterrupted watch time.
|
||||
*/
|
||||
DEFAULT(
|
||||
nameRes = R.string.lbl_still_watching_default,
|
||||
enabled = true,
|
||||
episodeCount = 3,
|
||||
minDuration = 1.5.hours,
|
||||
),
|
||||
|
||||
/**
|
||||
* Longer than default. Show screen at 5 episodes or 150 minutes of uninterrupted watch time.
|
||||
*/
|
||||
LONG(
|
||||
nameRes = R.string.lbl_still_watching_long,
|
||||
enabled = true,
|
||||
episodeCount = 5,
|
||||
minDuration = 2.5.hours,
|
||||
),
|
||||
|
||||
/**
|
||||
* Much longer than default. Show screen at 8 episodes or 240 minutes of uninterrupted watch time.
|
||||
*/
|
||||
VERY_LONG(
|
||||
nameRes = R.string.lbl_still_watching_very_long,
|
||||
enabled = true,
|
||||
episodeCount = 8,
|
||||
minDuration = 4.hours,
|
||||
),
|
||||
|
||||
/**
|
||||
* Disables still watching screen.
|
||||
*/
|
||||
DISABLED(
|
||||
nameRes = R.string.state_disabled,
|
||||
enabled = false,
|
||||
episodeCount = 0,
|
||||
minDuration = Duration.ZERO,
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ enum class WatchedIndicatorBehavior(
|
||||
/**
|
||||
* Hide unwatched count indicator, show watched check mark on individual episodes only.
|
||||
*/
|
||||
EPISODES_ONLY(R.string.lbl_hide_watched_checkmark),
|
||||
EPISODES_ONLY(R.string.lbl_episode_marks),
|
||||
|
||||
/**
|
||||
* Never show watched indicators.
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package org.jellyfin.androidtv.preference.store
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.preference.Preference
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
import org.jellyfin.preference.migration.MigrationContext
|
||||
@@ -58,12 +56,10 @@ abstract class DisplayPreferencesStore(
|
||||
|
||||
override suspend fun update(): Boolean {
|
||||
try {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
api.displayPreferencesApi.getDisplayPreferences(
|
||||
displayPreferencesId = displayPreferencesId,
|
||||
client = app
|
||||
).content
|
||||
}
|
||||
val result by api.displayPreferencesApi.getDisplayPreferences(
|
||||
displayPreferencesId = displayPreferencesId,
|
||||
client = app
|
||||
)
|
||||
displayPreferencesDto = result
|
||||
cachedPreferences = result.customPrefs.toMutableMap()
|
||||
|
||||
@@ -86,9 +82,6 @@ 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
|
||||
|
||||
@@ -103,10 +96,6 @@ 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()
|
||||
}
|
||||
@@ -130,8 +119,8 @@ abstract class DisplayPreferencesStore(
|
||||
override fun <V : Enum<V>> setEnum(preference: Preference<*>, value: Enum<V>) =
|
||||
setString(
|
||||
preference.key, when (value) {
|
||||
is PreferenceEnum -> value.serializedName ?: value.name
|
||||
else -> value.name
|
||||
is PreferenceEnum -> value.serializedName ?: value.toString()
|
||||
else -> value.toString()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -18,11 +18,6 @@ import org.acra.sender.ReportSenderFactory
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.preference.TelemetryPreferences
|
||||
import org.jellyfin.androidtv.util.appendCodeBlock
|
||||
import org.jellyfin.androidtv.util.appendItem
|
||||
import org.jellyfin.androidtv.util.appendSection
|
||||
import org.jellyfin.androidtv.util.appendValue
|
||||
import org.jellyfin.androidtv.util.buildMarkdown
|
||||
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
@@ -68,12 +63,8 @@ object TelemetryService {
|
||||
// Create connection
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
// Add authorization
|
||||
val clientName = buildString {
|
||||
append("Jellyfin Android TV")
|
||||
if (BuildConfig.DEBUG) append(" (debug)")
|
||||
}
|
||||
val authorization = AuthorizationHeaderBuilder.buildHeader(
|
||||
clientName = clientName,
|
||||
clientName = BuildConfig.APPLICATION_ID,
|
||||
clientVersion = BuildConfig.VERSION_NAME,
|
||||
deviceId = "",
|
||||
deviceName = "",
|
||||
@@ -94,7 +85,31 @@ object TelemetryService {
|
||||
throw ReportSenderException("Unable to send crash report to server", e)
|
||||
}
|
||||
|
||||
private fun CrashReportData.toReport(): String = buildMarkdown {
|
||||
private fun StringBuilder.appendSection(name: String, content: StringBuilder.() -> Unit) {
|
||||
appendLine("### $name")
|
||||
appendLine()
|
||||
content()
|
||||
appendLine()
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendItem(name: String, value: StringBuilder.() -> Unit) {
|
||||
append("***$name***: ")
|
||||
value()
|
||||
appendLine(" ")
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendCodeBlock(language: String, code: String?) {
|
||||
appendLine()
|
||||
appendLine("```$language")
|
||||
appendLine(code ?: "<null>")
|
||||
append("```")
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendValue(value: String?) {
|
||||
append("`", value ?: "<null>", "`")
|
||||
}
|
||||
|
||||
private fun CrashReportData.toReport(): String = buildString {
|
||||
// Header
|
||||
appendLine("---")
|
||||
appendLine("client: Jellyfin for Android TV")
|
||||
@@ -103,7 +118,6 @@ object TelemetryService {
|
||||
appendLine("type: crash_report")
|
||||
appendLine("format: markdown")
|
||||
appendLine("---")
|
||||
appendLine()
|
||||
|
||||
// Content
|
||||
appendSection("Logs") {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user