Compare commits
17 Commits
master
...
release-0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3da856ec23 | ||
|
|
8148850206 | ||
|
|
eb676a4ff5 | ||
|
|
7a478a77a4 | ||
|
|
9723da673e | ||
|
|
72e45a7b47 | ||
|
|
4e82f10f18 | ||
|
|
c1b103e385 | ||
|
|
0fc8daf45e | ||
|
|
bd98065766 | ||
|
|
4d68e3d31e | ||
|
|
9c33bb34ff | ||
|
|
9f7891f129 | ||
|
|
11e5b9526d | ||
|
|
a0fb27491c | ||
|
|
b1babfe477 | ||
|
|
ed3b92df08 |
@@ -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 # -->
|
||||
|
||||
25
.github/workflows/app-build.yaml
vendored
25
.github/workflows/app-build.yaml
vendored
@@ -1,10 +1,9 @@
|
||||
name: App / Build
|
||||
name: App Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- release-*
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
@@ -12,26 +11,22 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@2c7a4878f5d120bd643426d54ae1209b29cc01a3 # tag=v3
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version-file: .tools-versions
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
|
||||
distribution: adopt
|
||||
java-version: 11
|
||||
cache: gradle
|
||||
- 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/ \;
|
||||
run: ./gradlew --build-cache --no-daemon --info assembleDebug
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # tag=v3
|
||||
with:
|
||||
name: build-artifacts
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
path: build/gh-app-publish/
|
||||
path: app/build/outputs/apk/debug/*.apk
|
||||
|
||||
26
.github/workflows/app-lint.yaml
vendored
26
.github/workflows/app-lint.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: App / Lint
|
||||
name: App Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -9,26 +9,26 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read # for actions/checkout to fetch code
|
||||
security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@2c7a4878f5d120bd643426d54ae1209b29cc01a3 # tag=v3
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version-file: .tools-versions
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
|
||||
- name: Run detekt and lint tasks
|
||||
run: ./gradlew detekt lint
|
||||
distribution: adopt
|
||||
java-version: 11
|
||||
cache: gradle
|
||||
- name: Run detekt and lint task
|
||||
run: ./gradlew --build-cache --no-daemon --info detekt lint
|
||||
- name: Upload SARIF files
|
||||
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
uses: github/codeql-action/upload-sarif@2ca79b6fa8d3ec278944088b4aa5f46912db5d63 # tag=v2
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
sarif_file: .
|
||||
|
||||
57
.github/workflows/app-publish.yaml
vendored
57
.github/workflows/app-publish.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: App / Publish
|
||||
name: App Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -6,40 +6,33 @@ on:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish
|
||||
runs-on: ubuntu-24.04
|
||||
if: ${{ contains(github.repository_owner, 'jellyfin') }}
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin-androidtv' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@2c7a4878f5d120bd643426d54ae1209b29cc01a3 # tag=v3
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version-file: .tools-versions
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
|
||||
distribution: adopt
|
||||
java-version: 11
|
||||
- name: Set JELLYFIN_VERSION
|
||||
run: echo "JELLYFIN_VERSION=$(echo ${GITHUB_REF#refs/tags/v} | tr / -)" >> $GITHUB_ENV
|
||||
- name: Assemble release files
|
||||
run: ./gradlew assemble bundleRelease versionTxt
|
||||
run: ./gradlew --no-daemon --info assemble bundleRelease versionTxt
|
||||
- name: Sign APK
|
||||
id: signApk
|
||||
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # tag=v1
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: "34.0.0"
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/apk/release
|
||||
signingKeyBase64: ${{ secrets.KEYSTORE }}
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
- name: Sign app bundle
|
||||
- name: Sign AAB
|
||||
id: signAab
|
||||
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # tag=v1
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: "34.0.0"
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/bundle/release
|
||||
signingKeyBase64: ${{ secrets.KEYSTORE }}
|
||||
@@ -55,7 +48,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@5586bc227f8525a5e3525e6edf64da5350bfb5b1 # tag=0.3.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
with:
|
||||
@@ -65,24 +58,20 @@ jobs:
|
||||
with:
|
||||
switches: -vrptz
|
||||
path: build/jellyfin-publish/
|
||||
remote_path: /srv/incoming/androidtv/v${{ env.JELLYFIN_VERSION }}/
|
||||
remote_host: ${{ secrets.REPO_HOST }}
|
||||
remote_user: ${{ secrets.REPO_USER }}
|
||||
remote_key: ${{ secrets.REPO_KEY }}
|
||||
remote_path: /srv/repository/releases/client/androidtv/versions/v${{ env.JELLYFIN_VERSION }}
|
||||
remote_host: ${{ secrets.DEPLOY_HOST }}
|
||||
remote_user: ${{ secrets.DEPLOY_USER }}
|
||||
remote_key: ${{ secrets.DEPLOY_KEY }}
|
||||
- name: Update repo.jellyfin.org symlinks
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
uses: appleboy/ssh-action@f9010ff7f1bbd7db1a0b4bab661437550cea20c0 # tag=v0.1.5
|
||||
with:
|
||||
host: ${{ secrets.REPO_HOST }}
|
||||
username: ${{ secrets.REPO_USER }}
|
||||
key: ${{ secrets.REPO_KEY }}
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_KEY }}
|
||||
envs: JELLYFIN_VERSION
|
||||
script_stop: true
|
||||
script: |
|
||||
if [ -d "/srv/repository/main/client/androidtv/versions/v${{ env.JELLYFIN_VERSION }}" ] && [ -n "${{ env.JELLYFIN_VERSION }}" ]; then
|
||||
sudo rm -r /srv/repository/main/client/androidtv/versions/v${{ env.JELLYFIN_VERSION }}
|
||||
fi
|
||||
sudo mv /srv/incoming/androidtv/v${{ env.JELLYFIN_VERSION }} /srv/repository/main/client/androidtv/versions/v${{ env.JELLYFIN_VERSION }}
|
||||
cd /srv/repository/main/client/androidtv;
|
||||
sudo rm -rf *.apk version.txt;
|
||||
sudo ln -s versions/v${JELLYFIN_VERSION}/jellyfin-androidtv-v${JELLYFIN_VERSION}-*.apk .;
|
||||
sudo ln -s versions/v${JELLYFIN_VERSION}/version.txt .;
|
||||
cd /srv/repository/releases/client/androidtv;
|
||||
rm -rf *.apk version.txt;
|
||||
ln -s versions/v${JELLYFIN_VERSION}/jellyfin-androidtv-v${JELLYFIN_VERSION}-*.apk .;
|
||||
ln -s versions/v${JELLYFIN_VERSION}/version.txt .;
|
||||
|
||||
18
.github/workflows/app-test.yaml
vendored
18
.github/workflows/app-test.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: App / Test
|
||||
name: App Test
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -12,17 +12,15 @@ permissions:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@2c7a4878f5d120bd643426d54ae1209b29cc01a3 # tag=v3
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version-file: .tools-versions
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
|
||||
distribution: adopt
|
||||
java-version: 11
|
||||
cache: gradle
|
||||
- name: Run test task
|
||||
run: ./gradlew test
|
||||
run: ./gradlew --build-cache --no-daemon --info test
|
||||
|
||||
17
.github/workflows/gradlew-update.yaml
vendored
Normal file
17
.github/workflows/gradlew-update.yaml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Gradle update
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 4 * * *'
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-20.04
|
||||
if: github.repository == 'jellyfin/jellyfin-androidtv'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
|
||||
- name: Update Gradle Wrapper
|
||||
uses: gradle-update/update-gradle-wrapper-action@981c551b17dbcf1940b1b4435afdb79babb7c13a # tag=v1
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
9
.github/workflows/gradlew-validate.yaml
vendored
9
.github/workflows/gradlew-validate.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Gradle / Validate wrapper
|
||||
name: Gradle validate
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -13,10 +13,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
|
||||
uses: gradle/wrapper-validation-action@e6e38bacfdf1a337459f332974bb2327a31aaf4b # tag=v1
|
||||
|
||||
17
.github/workflows/merge-conflict-labeler.yaml
vendored
Normal file
17
.github/workflows/merge-conflict-labeler.yaml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Merge conflict labeler
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request_target:
|
||||
types:
|
||||
- synchronize
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-20.04
|
||||
if: github.repository == 'jellyfin/jellyfin-androidtv'
|
||||
steps:
|
||||
- uses: eps1lon/actions-label-merge-conflict@b8bf8341285ec9a4567d4318ba474fee998a6919 # tag=v2.0.1
|
||||
with:
|
||||
dirtyLabel: merge conflict
|
||||
repoToken: ${{ secrets.JF_BOT_TOKEN }}
|
||||
18
.github/workflows/repo-merge-conflict.yaml
vendored
18
.github/workflows/repo-merge-conflict.yaml
vendored
@@ -1,18 +0,0 @@
|
||||
name: Repo / Label merge conflict
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request_target:
|
||||
types:
|
||||
- synchronize
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
name: Triage
|
||||
runs-on: ubuntu-24.04
|
||||
if: ${{ contains(github.repository_owner, 'jellyfin') }}
|
||||
steps:
|
||||
- uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||
with:
|
||||
dirtyLabel: merge conflict
|
||||
repoToken: ${{ secrets.JF_BOT_TOKEN }}
|
||||
15
.github/workflows/repo-stale.yaml
vendored
15
.github/workflows/repo-stale.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Repo / Reply stale issue
|
||||
name: Issue stale check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -10,12 +10,11 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
name: Triage
|
||||
runs-on: ubuntu-24.04
|
||||
if: ${{ contains(github.repository_owner, 'jellyfin') }}
|
||||
stale:
|
||||
runs-on: ubuntu-20.04
|
||||
if: github.repository == 'jellyfin/jellyfin-androidtv'
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
- uses: actions/stale@9c1b1c6e115ca2af09755448e0dbba24e5061cc8 # tag=v5
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
days-before-stale: 120
|
||||
@@ -26,7 +25,7 @@ jobs:
|
||||
stale-issue-label: stale
|
||||
stale-issue-message: |-
|
||||
This issue has gone 120 days without comment. To avoid abandoned issues, it will be closed in 21 days if there are no new comments.
|
||||
|
||||
|
||||
If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or master branch, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label.
|
||||
|
||||
|
||||
This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://docs.jellyfin.org/general/getting-help.html).
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,5 @@ local.properties
|
||||
|
||||
/.idea
|
||||
.gradle
|
||||
.kotlin/
|
||||
build/
|
||||
captures/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
java 21
|
||||
@@ -10,12 +10,6 @@
|
||||
- [joern-h](https://github.com/joern-h)
|
||||
- [Niels van Velzen](https://github.com/nielsvanvelzen)
|
||||
- [GodTamIt](https://github.com/GodTamIt)
|
||||
- [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
|
||||
|
||||
|
||||
73
README.md
73
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"/>
|
||||
@@ -32,46 +35,52 @@
|
||||
<a href="https://www.amazon.com/gp/aw/d/B07TX7Z725">
|
||||
<img width="153" alt="Jellyfin on Amazon Appstore" src="https://jellyfin.org/images/store-icons/amazon.png"/>
|
||||
</a>
|
||||
<a href="https://f-droid.org/en/packages/org.jellyfin.androidtv/">
|
||||
<img width="153" alt="Jellyfin on F-Droid" src="https://jellyfin.org/images/store-icons/fdroid.png"/>
|
||||
</a>
|
||||
<br/>
|
||||
<a href="https://repo.jellyfin.org/releases/client/androidtv/">Download archive</a>
|
||||
</p>
|
||||
|
||||
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.*
|
||||
|
||||
@@ -18,8 +18,4 @@
|
||||
<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>
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
plugins {
|
||||
alias(libs.plugins.aboutlibraries)
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
id("com.android.application")
|
||||
kotlin("android")
|
||||
kotlin("kapt")
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.aboutlibraries)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.jellyfin.androidtv"
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||
compileSdk = 31
|
||||
|
||||
defaultConfig {
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
minSdk = 21
|
||||
targetSdk = 31
|
||||
|
||||
// Release version
|
||||
applicationId = namespace
|
||||
versionName = project.getVersionName()
|
||||
versionCode = getVersionCode(versionName!!)
|
||||
setProperty("archivesBaseName", "jellyfin-androidtv-v$versionName")
|
||||
}
|
||||
|
||||
sourceSets["main"].java.srcDirs("src/main/kotlin")
|
||||
sourceSets["test"].java.srcDirs("src/test/kotlin")
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
viewBinding = true
|
||||
compose = true
|
||||
resValues = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -31,8 +33,8 @@ android {
|
||||
}
|
||||
|
||||
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 +47,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 +75,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 +92,9 @@ tasks.register("versionTxt") {
|
||||
|
||||
dependencies {
|
||||
// Jellyfin
|
||||
implementation(projects.design)
|
||||
implementation(projects.playback.core)
|
||||
implementation(projects.playback.jellyfin)
|
||||
implementation(projects.playback.media3.exoplayer)
|
||||
implementation(projects.playback.media3.session)
|
||||
implementation(projects.playback)
|
||||
implementation(projects.preference)
|
||||
implementation(libs.jellyfin.apiclient)
|
||||
implementation(libs.jellyfin.sdk) {
|
||||
// Change version if desired
|
||||
val sdkVersion = findProperty("sdk.version")?.toString()
|
||||
@@ -110,12 +112,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)
|
||||
@@ -126,25 +125,25 @@ dependencies {
|
||||
implementation(libs.androidx.window)
|
||||
implementation(libs.androidx.cardview)
|
||||
implementation(libs.androidx.startup)
|
||||
implementation(libs.bundles.androidx.compose)
|
||||
implementation(libs.accompanist.permissions)
|
||||
|
||||
// Dependency Injection
|
||||
implementation(libs.bundles.koin)
|
||||
|
||||
// GSON
|
||||
implementation(libs.gson)
|
||||
|
||||
// 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.exoplayer)
|
||||
implementation(libs.jellyfin.exoplayer.ffmpegextension)
|
||||
implementation(libs.libvlc)
|
||||
|
||||
// Markdown
|
||||
implementation(libs.bundles.markwon)
|
||||
|
||||
// Image utility
|
||||
implementation(libs.bundles.coil)
|
||||
implementation(libs.glide.core)
|
||||
kapt(libs.glide.compiler)
|
||||
implementation(libs.kenburnsview)
|
||||
|
||||
// Crash Reporting
|
||||
implementation(libs.bundles.acra)
|
||||
@@ -156,6 +155,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
|
||||
@@ -1,26 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="org.jellyfin.androidtv"
|
||||
android:installLocation="auto">
|
||||
|
||||
<!-- Android TV Integration -->
|
||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
|
||||
|
||||
<!-- AccountManager permissions -->
|
||||
<uses-permission
|
||||
android:name="android.permission.AUTHENTICATE_ACCOUNTS"
|
||||
android:maxSdkVersion="22" />
|
||||
<uses-permission
|
||||
android:name="android.permission.GET_ACCOUNTS"
|
||||
android:maxSdkVersion="22" />
|
||||
<uses-permission
|
||||
android:name="android.permission.MANAGE_ACCOUNTS"
|
||||
android:maxSdkVersion="22" />
|
||||
|
||||
<!-- Generic permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_WIFI_STATE"
|
||||
tools:ignore="LeanbackUsesWifi" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
||||
<!-- Search permissions (voice to text) -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<!-- Device feature requirements -->
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
@@ -28,36 +38,31 @@
|
||||
android:name="android.hardware.microphone"
|
||||
android:required="false" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data
|
||||
android:host="youtube.com"
|
||||
android:scheme="https" />
|
||||
</intent>
|
||||
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".JellyfinApplication"
|
||||
android:allowBackup="true"
|
||||
android:appCategory="video"
|
||||
android:banner="@mipmap/app_banner"
|
||||
android:banner="@drawable/app_banner"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:fullBackupContent="@xml/backup_content"
|
||||
android:icon="@mipmap/app_icon"
|
||||
android:icon="@drawable/app_icon"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:roundIcon="@drawable/app_icon_round"
|
||||
android:theme="@style/Theme.Jellyfin"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<!-- Screensaver -->
|
||||
<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>
|
||||
|
||||
<service
|
||||
android:name=".integration.dream.LibraryDreamService"
|
||||
android:exported="true"
|
||||
@@ -84,6 +89,9 @@
|
||||
<meta-data
|
||||
android:name="org.jellyfin.androidtv.SessionInitializer"
|
||||
android:value="androidx.startup" />
|
||||
<meta-data
|
||||
android:name="org.jellyfin.androidtv.ProcessLifecycleInitializer"
|
||||
android:value="androidx.startup" />
|
||||
</provider>
|
||||
|
||||
<provider
|
||||
@@ -92,23 +100,19 @@
|
||||
android:exported="true"
|
||||
android:initOrder="10" />
|
||||
|
||||
<provider
|
||||
android:name=".integration.provider.ImageProvider"
|
||||
android:authorities="${applicationId}.integration.provider.ImageProvider"
|
||||
android:exported="true"
|
||||
android:initOrder="10" />
|
||||
<activity
|
||||
android:name=".ui.browsing.MainActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<!-- Authentication -->
|
||||
<activity
|
||||
android:name=".ui.startup.StartupActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:noHistory="true"
|
||||
android:screenOrientation="landscape"
|
||||
android:windowSoftInputMode="adjustNothing">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<action android:name="android.intent.action.SEARCH" />
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
@@ -118,16 +122,83 @@
|
||||
android:name="android.app.searchable"
|
||||
android:resource="@xml/searchable" />
|
||||
</activity>
|
||||
|
||||
<activity-alias
|
||||
android:name=".startup.StartupActivity"
|
||||
android:targetActivity=".ui.startup.StartupActivity" />
|
||||
|
||||
<!-- Main application -->
|
||||
<activity
|
||||
android:name=".ui.browsing.MainActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustNothing" />
|
||||
android:name=".ui.playback.PlaybackOverlayActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.browsing.GenericFolderActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.browsing.UserViewActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.browsing.CollectionActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.browsing.GroupedItemsActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.preference.PreferencesActivity"
|
||||
android:theme="@style/Theme.Jellyfin.Preferences" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.browsing.SuggestedMoviesActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.search.SearchActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.itemdetail.FullDetailsActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.livetv.LiveTvGuideActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.browsing.GenericGridActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.browsing.BrowseRecordingsActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.browsing.BrowseScheduleActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.itemdetail.PhotoPlayerActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.itemdetail.ItemListActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.playback.AudioNowPlayingActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity android:name=".ui.playback.ExternalPlayerActivity" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.playback.nextup.NextUpActivity"
|
||||
android:noHistory="true"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<!-- Playback Rewrite - Temporary -->
|
||||
<activity android:name=".ui.playback.rewrite.PlaybackForwardingActivity" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -2,6 +2,8 @@ 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
|
||||
@@ -15,6 +17,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 +38,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
|
||||
@@ -45,7 +49,7 @@ class JellyfinApplication : Application() {
|
||||
// Recreate periodic workers
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
LeanbackChannelWorker.PERIODIC_UPDATE_REQUEST_NAME,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
ExistingPeriodicWorkPolicy.REPLACE,
|
||||
PeriodicWorkRequestBuilder<LeanbackChannelWorker>(1, TimeUnit.HOURS)
|
||||
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES)
|
||||
.build()
|
||||
@@ -54,6 +58,10 @@ 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 attachBaseContext(base: Context?) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.jellyfin.androidtv
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bumptech.glide.GlideBuilder
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.module.AppGlideModule
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
|
||||
@GlideModule
|
||||
class JellyfinGlideModule : AppGlideModule() {
|
||||
override fun applyOptions(context: Context, builder: GlideBuilder): Unit = with(builder) {
|
||||
setDefaultRequestOptions(
|
||||
// Set default disk cache strategy
|
||||
RequestOptions().diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||
)
|
||||
|
||||
// Silence image load errors
|
||||
setLogLevel(Log.ERROR)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.jellyfin.androidtv
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.startup.AppInitializer
|
||||
import androidx.startup.Initializer
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.auth.repository.SessionRepository
|
||||
import org.jellyfin.androidtv.di.KoinInitializer
|
||||
import timber.log.Timber
|
||||
|
||||
@Suppress("unused")
|
||||
class ProcessLifecycleInitializer : Initializer<Unit> {
|
||||
override fun create(context: Context) {
|
||||
val koin = AppInitializer.getInstance(context)
|
||||
.initializeComponent(KoinInitializer::class.java)
|
||||
.koin
|
||||
|
||||
// Register application lifecycle events
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
/**
|
||||
* Called by the Process Lifecycle when the app is created. It is called after [onCreate].
|
||||
*/
|
||||
override fun onCreate(owner: LifecycleOwner) {
|
||||
// Register activity lifecycle callbacks
|
||||
val callbacks = koin.getAll<Application.ActivityLifecycleCallbacks>()
|
||||
Timber.i("Registering ${callbacks.size} ActivityLifecycleCallbacks")
|
||||
val app = context.applicationContext as Application
|
||||
callbacks.forEach { callback -> app.registerActivityLifecycleCallbacks(callback) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the Process Lifecycle when the app is activated in the foreground (activity opened).
|
||||
*/
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
Timber.i("Process lifecycle started")
|
||||
|
||||
owner.lifecycleScope.launch {
|
||||
koin.get<SessionRepository>().restoreSession()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun dependencies() = listOf(KoinInitializer::class.java)
|
||||
}
|
||||
@@ -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,8 +17,8 @@ class SessionInitializer : Initializer<Unit> {
|
||||
.koin
|
||||
|
||||
// Restore system session
|
||||
ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.IO) {
|
||||
koin.get<SessionRepository>().restoreSession(destroyOnly = false)
|
||||
ProcessLifecycleOwner.get().lifecycleScope.launch {
|
||||
koin.get<SessionRepository>().restoreSession()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.jellyfin.androidtv.auth.model
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class AccountManagerAccount(
|
||||
val id: UUID,
|
||||
val server: UUID,
|
||||
val name: String,
|
||||
val accessToken: String? = null,
|
||||
)
|
||||
@@ -1,11 +1,11 @@
|
||||
package org.jellyfin.androidtv.auth.model
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
|
||||
|
||||
enum class AuthenticationSortBy(
|
||||
override val nameRes: Int
|
||||
) : PreferenceEnum {
|
||||
LAST_USE(R.string.last_use),
|
||||
ALPHABETICAL(R.string.alphabetical);
|
||||
enum class AuthenticationSortBy {
|
||||
@EnumDisplayOptions(name = R.string.last_use)
|
||||
LAST_USE,
|
||||
@EnumDisplayOptions(name = R.string.alphabetical)
|
||||
ALPHABETICAL
|
||||
}
|
||||
|
||||
@@ -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,7 @@ import java.time.Instant
|
||||
@Serializable
|
||||
data class AuthenticationStoreUser(
|
||||
val name: String,
|
||||
@SerialName("last_used") val lastUsed: Long = Instant.now().toEpochMilli(),
|
||||
@SerialName("image_tag") val imageTag: String? = null,
|
||||
@SerialName("access_token") val accessToken: String? = null,
|
||||
@SerialName("last_used") val lastUsed: Long = Date().time,
|
||||
@SerialName("require_password") val requirePassword: Boolean = true,
|
||||
@SerialName("image_tag") val imageTag: String? = null
|
||||
)
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
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,12 +1,10 @@
|
||||
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.AccountManagerAccount
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticateMethod
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticatedState
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticatingState
|
||||
@@ -21,12 +19,10 @@ import org.jellyfin.androidtv.auth.model.Server
|
||||
import org.jellyfin.androidtv.auth.model.ServerUnavailableState
|
||||
import org.jellyfin.androidtv.auth.model.ServerVersionNotSupported
|
||||
import org.jellyfin.androidtv.auth.model.User
|
||||
import org.jellyfin.androidtv.auth.store.AccountManagerStore
|
||||
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 +30,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.
|
||||
@@ -54,12 +51,16 @@ interface AuthenticationRepository {
|
||||
class AuthenticationRepositoryImpl(
|
||||
private val jellyfin: Jellyfin,
|
||||
private val sessionRepository: SessionRepository,
|
||||
private val accountManagerStore: AccountManagerStore,
|
||||
private val authenticationStore: AuthenticationStore,
|
||||
private val userApiClient: ApiClient,
|
||||
private val authenticationPreferences: AuthenticationPreferences,
|
||||
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,14 +69,16 @@ class AuthenticationRepositoryImpl(
|
||||
}
|
||||
|
||||
private fun authenticateAutomatic(server: Server, user: User): Flow<LoginState> {
|
||||
Timber.i("Authenticating user ${user.id}")
|
||||
Timber.d("Authenticating user %s", user)
|
||||
|
||||
// Automatic logic is disabled when the always authenticate preference is enabled
|
||||
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return flowOf(RequireSignInState)
|
||||
|
||||
val authStoreUser = authenticationStore.getUser(server.id, user.id)
|
||||
val account = accountManagerStore.getAccount(user.id)
|
||||
// Try login with access token
|
||||
return if (authStoreUser?.accessToken != null) authenticateToken(server, user.withToken(authStoreUser.accessToken))
|
||||
return if (account?.accessToken != null) authenticateToken(server, user.withToken(account.accessToken))
|
||||
// Try login without password
|
||||
else if (!user.requirePassword) authenticateCredential(server, user.name, "")
|
||||
// Require login
|
||||
else flowOf(RequireSignInState)
|
||||
}
|
||||
@@ -91,103 +94,93 @@ class AuthenticationRepositoryImpl(
|
||||
return@flow
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to sign in as $username")
|
||||
emit(ApiClientErrorLoginState(err))
|
||||
emit(RequireSignInState)
|
||||
return@flow
|
||||
}
|
||||
|
||||
emitAll(authenticateAuthenticationResult(server, result))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
private fun authenticateQuickConnect(server: Server, secret: String) = flow {
|
||||
val api = jellyfin.createApi(server.address, deviceInfo = defaultDeviceInfo)
|
||||
val result = try {
|
||||
val response = api.userApi.authenticateWithQuickConnect(secret)
|
||||
response.content
|
||||
} catch (err: TimeoutException) {
|
||||
Timber.e(err, "Failed to connect to server")
|
||||
emit(ServerUnavailableState)
|
||||
return@flow
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to sign in with Quick Connect secret")
|
||||
emit(ApiClientErrorLoginState(err))
|
||||
emit(RequireSignInState)
|
||||
return@flow
|
||||
}
|
||||
|
||||
emitAll(authenticateAuthenticationResult(server, result))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
private fun authenticateAuthenticationResult(server: Server, result: AuthenticationResult) = flow {
|
||||
val accessToken = result.accessToken ?: return@flow emit(RequireSignInState)
|
||||
val accessToken = result.accessToken ?:return@flow emit(RequireSignInState)
|
||||
val userInfo = result.user ?: return@flow emit(RequireSignInState)
|
||||
val user = PrivateUser(
|
||||
id = userInfo.id,
|
||||
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)
|
||||
val success = setActiveSession(user, server)
|
||||
if (success) {
|
||||
emit(AuthenticatedState)
|
||||
} else {
|
||||
Timber.w("Failed to set active session after authenticating")
|
||||
if (!server.versionSupported) emit(ServerVersionNotSupported(server))
|
||||
else emit(RequireSignInState)
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
if (success) emit(AuthenticatedState)
|
||||
else emit(RequireSignInState)
|
||||
}
|
||||
|
||||
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()
|
||||
authenticateFinish(server, userInfo, user.accessToken.orEmpty())
|
||||
emit(AuthenticatedState)
|
||||
} catch (err: TimeoutException) {
|
||||
Timber.e(err, "Failed to connect to server")
|
||||
emit(ServerUnavailableState)
|
||||
return@flow
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to get current user data")
|
||||
emit(ApiClientErrorLoginState(err))
|
||||
emit(RequireSignInState)
|
||||
}
|
||||
}.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,
|
||||
accessToken = accessToken,
|
||||
lastUsed = Date().time,
|
||||
requirePassword = userInfo.hasPassword,
|
||||
imageTag = userInfo.primaryImageTag
|
||||
) ?: AuthenticationStoreUser(
|
||||
name = userInfo.name!!,
|
||||
imageTag = userInfo.primaryImage?.tag,
|
||||
accessToken = accessToken,
|
||||
requirePassword = userInfo.hasPassword,
|
||||
imageTag = userInfo.primaryImageTag
|
||||
)
|
||||
authenticationStore.putUser(server.id, userInfo.id, updatedUser)
|
||||
|
||||
val accountManagerAccount = AccountManagerAccount(userInfo.id, server.id, updatedUser.name, accessToken)
|
||||
accountManagerStore.putAccount(accountManagerAccount)
|
||||
}
|
||||
|
||||
private suspend fun setActiveSession(user: User, server: Server): Boolean {
|
||||
val authenticated = sessionRepository.switchCurrentSession(server.id, user.id)
|
||||
val authenticated = sessionRepository.switchCurrentSession(user.id)
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,23 +188,16 @@ class AuthenticationRepositoryImpl(
|
||||
}
|
||||
|
||||
override fun logout(user: User): Boolean {
|
||||
val authStoreUser = authenticationStore
|
||||
.getUser(user.serverId, user.id)
|
||||
?.copy(accessToken = null)
|
||||
|
||||
return if (authStoreUser != null) authenticationStore.putUser(user.serverId, user.id, authStoreUser)
|
||||
else false
|
||||
val authInfo = accountManagerStore.getAccount(user.id) ?: return false
|
||||
return accountManagerStore.removeAccount(authInfo)
|
||||
}
|
||||
|
||||
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,12 @@
|
||||
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.firstOrNull
|
||||
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
|
||||
@@ -25,12 +23,11 @@ import org.jellyfin.sdk.api.client.extensions.brandingApi
|
||||
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,17 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,9 +61,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,29 +81,24 @@ 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)
|
||||
Timber.d("Adding server %s", address)
|
||||
|
||||
emit(ConnectingState(address))
|
||||
|
||||
val addressCandidates = jellyfin.discovery.getAddressCandidates(address)
|
||||
Timber.i("Found ${addressCandidates.size} candidates")
|
||||
Timber.d("Found ${addressCandidates.size} candidates")
|
||||
|
||||
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 +106,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 +117,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 +135,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 +156,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 +171,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 +184,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 +223,7 @@ class ServerRepositoryImpl(
|
||||
loginDisclaimer = loginDisclaimer,
|
||||
splashscreenEnabled = splashscreenEnabled,
|
||||
setupCompleted = setupCompleted,
|
||||
dateLastAccessed = Instant.ofEpochMilli(lastUsed),
|
||||
dateLastAccessed = Date(lastUsed),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -262,7 +234,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,10 +1,9 @@
|
||||
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
|
||||
import org.jellyfin.androidtv.auth.store.AccountManagerStore
|
||||
import org.jellyfin.androidtv.auth.store.AuthenticationStore
|
||||
import org.jellyfin.androidtv.util.sdk.toPublicUser
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
@@ -28,15 +27,17 @@ interface ServerUserRepository {
|
||||
class ServerUserRepositoryImpl(
|
||||
private val jellyfin: Jellyfin,
|
||||
private val authenticationStore: AuthenticationStore,
|
||||
private val accountManagerStore: AccountManagerStore,
|
||||
) : ServerUserRepository {
|
||||
override fun getStoredServerUsers(server: Server) = authenticationStore.getUsers(server.id)
|
||||
?.mapNotNull { (userId, userInfo) ->
|
||||
val authInfo = authenticationStore.getUser(server.id, userId)
|
||||
val authInfo = accountManagerStore.getAccount(userId)
|
||||
PrivateUser(
|
||||
id = userId,
|
||||
serverId = server.id,
|
||||
id = authInfo?.id ?: userId,
|
||||
serverId = authInfo?.server ?: server.id,
|
||||
name = userInfo.name,
|
||||
accessToken = authInfo?.accessToken,
|
||||
requirePassword = userInfo.requirePassword,
|
||||
imageTag = userInfo.imageTag,
|
||||
lastUsed = userInfo.lastUsed,
|
||||
)
|
||||
@@ -49,9 +50,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")
|
||||
@@ -63,5 +62,10 @@ class ServerUserRepositoryImpl(
|
||||
override fun deleteStoredUser(user: PrivateUser) {
|
||||
// Remove user info from store
|
||||
authenticationStore.removeUser(user.serverId, user.id)
|
||||
|
||||
// Remove authentication info from system account manager
|
||||
accountManagerStore.getAccount(user.id)?.let { accountManagerAccount ->
|
||||
accountManagerStore.removeAccount(accountManagerAccount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
package org.jellyfin.androidtv.auth.repository
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
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.AccountManagerStore
|
||||
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
|
||||
import org.jellyfin.androidtv.auth.store.AuthenticationStore
|
||||
import org.jellyfin.androidtv.preference.PreferencesRepository
|
||||
@@ -42,13 +40,15 @@ interface SessionRepository {
|
||||
val currentSession: StateFlow<Session?>
|
||||
val state: StateFlow<SessionRepositoryState>
|
||||
|
||||
suspend fun restoreSession(destroyOnly: Boolean)
|
||||
suspend fun switchCurrentSession(serverId: UUID, userId: UUID): Boolean
|
||||
suspend fun restoreSession()
|
||||
suspend fun switchCurrentSession(userId: UUID): Boolean
|
||||
fun destroyCurrentSession()
|
||||
}
|
||||
|
||||
class SessionRepositoryImpl(
|
||||
private val authenticationPreferences: AuthenticationPreferences,
|
||||
private val accountManagerStore: AccountManagerStore,
|
||||
private val apiBinder: ApiBinder,
|
||||
private val authenticationStore: AuthenticationStore,
|
||||
private val userApiClient: ApiClient,
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
@@ -63,44 +63,34 @@ class SessionRepositoryImpl(
|
||||
private val _state = MutableStateFlow(SessionRepositoryState.READY)
|
||||
override val state = _state.asStateFlow()
|
||||
|
||||
override suspend fun restoreSession(destroyOnly: Boolean): Unit = withContext(NonCancellable) {
|
||||
currentSessionMutex.withLock {
|
||||
Timber.i("Restoring session")
|
||||
override suspend fun restoreSession(): Unit = currentSessionMutex.withLock {
|
||||
Timber.d("Restoring session")
|
||||
_state.value = SessionRepositoryState.RESTORING_SESSION
|
||||
|
||||
_state.value = SessionRepositoryState.RESTORING_SESSION
|
||||
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return destroyCurrentSession()
|
||||
|
||||
val alwaysAuthenticate = authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]
|
||||
val autoLoginBehavior = authenticationPreferences[AuthenticationPreferences.autoLoginUserBehavior]
|
||||
val behavior = authenticationPreferences[AuthenticationPreferences.autoLoginUserBehavior]
|
||||
val userId = authenticationPreferences[AuthenticationPreferences.autoLoginUserId].toUUIDOrNull()
|
||||
|
||||
when {
|
||||
alwaysAuthenticate -> destroyCurrentSession()
|
||||
autoLoginBehavior == DISABLED -> destroyCurrentSession()
|
||||
autoLoginBehavior == LAST_USER && !destroyOnly -> setCurrentSession(createLastUserSession())
|
||||
autoLoginBehavior == SPECIFIC_USER && !destroyOnly -> {
|
||||
val serverId = authenticationPreferences[AuthenticationPreferences.autoLoginServerId].toUUIDOrNull()
|
||||
val userId = authenticationPreferences[AuthenticationPreferences.autoLoginUserId].toUUIDOrNull()
|
||||
if (serverId != null && userId != null) setCurrentSession(createUserSession(serverId, userId))
|
||||
}
|
||||
}
|
||||
|
||||
_state.value = SessionRepositoryState.READY
|
||||
when (behavior) {
|
||||
DISABLED -> destroyCurrentSession()
|
||||
LAST_USER -> setCurrentSession(createLastUserSession())
|
||||
SPECIFIC_USER -> setCurrentSession(createUserSession(userId))
|
||||
}
|
||||
|
||||
_state.value = SessionRepositoryState.READY
|
||||
}
|
||||
|
||||
override suspend fun switchCurrentSession(serverId: UUID, userId: UUID): Boolean {
|
||||
override suspend fun switchCurrentSession(userId: UUID): Boolean {
|
||||
// No change in user - don't switch
|
||||
if (currentSession.value?.userId == userId) {
|
||||
Timber.d("Current session user is the same as the requested user")
|
||||
return false
|
||||
}
|
||||
if (currentSession.value?.userId == userId) return false
|
||||
|
||||
_state.value = SessionRepositoryState.SWITCHING_SESSION
|
||||
Timber.i("Switching current session to user $userId")
|
||||
Timber.d("Switching current session to user $userId")
|
||||
|
||||
val session = createUserSession(serverId, userId)
|
||||
val session = createUserSession(userId)
|
||||
if (session == null) {
|
||||
Timber.w("Could not switch to non-existing session for user $userId")
|
||||
_state.value = SessionRepositoryState.READY
|
||||
Timber.d("Could not switch to non-existing session for user $userId")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -110,99 +100,91 @@ class SessionRepositoryImpl(
|
||||
}
|
||||
|
||||
override fun destroyCurrentSession() {
|
||||
Timber.i("Destroying current session")
|
||||
Timber.d("Destroying current session")
|
||||
|
||||
userRepository.setCurrentUser(null)
|
||||
serverRepository.setCurrentServer(null)
|
||||
userRepository.updateCurrentUser(null)
|
||||
_currentSession.value = null
|
||||
userApiClient.applySession(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
|
||||
|
||||
// Update last active user
|
||||
authenticationPreferences[AuthenticationPreferences.lastServerId] = session.serverId.toString()
|
||||
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.d("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) {
|
||||
userApiClient.applySession(session, deviceInfo)
|
||||
|
||||
if (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? {
|
||||
val lastUserId = authenticationPreferences[AuthenticationPreferences.lastUserId].toUUIDOrNull()
|
||||
val lastServerId = authenticationPreferences[AuthenticationPreferences.lastServerId].toUUIDOrNull()
|
||||
|
||||
return if (lastUserId != null && lastServerId != null) createUserSession(lastServerId, lastUserId)
|
||||
else null
|
||||
return createUserSession(lastUserId)
|
||||
}
|
||||
|
||||
private fun createUserSession(serverId: UUID, userId: UUID): Session? {
|
||||
val account = authenticationStore.getUser(serverId, userId)
|
||||
private fun createUserSession(userId: UUID?): Session? {
|
||||
if (userId == null) return null
|
||||
|
||||
val account = accountManagerStore.getAccount(userId)
|
||||
if (account?.accessToken == null) return null
|
||||
|
||||
return Session(
|
||||
userId = userId,
|
||||
serverId = serverId,
|
||||
userId = account.id,
|
||||
serverId = account.server,
|
||||
accessToken = account.accessToken
|
||||
)
|
||||
}
|
||||
|
||||
private fun ApiClient.applySession(session: Session?, newDeviceInfo: DeviceInfo = defaultDeviceInfo): Boolean {
|
||||
private fun ApiClient.applySession(session: Session?, newDeviceInfo: DeviceInfo = defaultDeviceInfo) {
|
||||
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,
|
||||
)
|
||||
?: return applySession(null)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package org.jellyfin.androidtv.auth.store
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.os.Bundle
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.auth.model.AccountManagerAccount
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import java.util.UUID
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class AccountManagerStore(
|
||||
private val accountManager: AccountManager
|
||||
) {
|
||||
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_DATA_NAME = "$ACCOUNT_TYPE.name"
|
||||
const val ACCOUNT_ACCESS_TOKEN_TYPE = "$ACCOUNT_TYPE.access_token"
|
||||
}
|
||||
|
||||
private fun getAccountData(account: Account): AccountManagerAccount = AccountManagerAccount(
|
||||
id = accountManager.getUserData(account, ACCOUNT_DATA_ID).toUUID(),
|
||||
server = accountManager.getUserData(account, ACCOUNT_DATA_SERVER).toUUID(),
|
||||
name = accountManager.getUserData(account, ACCOUNT_DATA_NAME),
|
||||
accessToken = accountManager.peekAuthToken(account, ACCOUNT_ACCESS_TOKEN_TYPE)
|
||||
)
|
||||
|
||||
suspend fun putAccount(accountManagerAccount: AccountManagerAccount) {
|
||||
var androidAccount = accountManager.getAccountsByType(ACCOUNT_TYPE)
|
||||
.firstOrNull { accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == accountManagerAccount.id }
|
||||
|
||||
// Update credentials
|
||||
if (androidAccount == null) {
|
||||
androidAccount = Account(accountManagerAccount.name, ACCOUNT_TYPE)
|
||||
|
||||
accountManager.addAccountExplicitly(
|
||||
androidAccount,
|
||||
"", // Leave password empty
|
||||
Bundle()
|
||||
)
|
||||
accountManager.setUserData(androidAccount, ACCOUNT_DATA_ID, accountManagerAccount.id.toString())
|
||||
}
|
||||
|
||||
// Update name
|
||||
if (androidAccount.name != accountManagerAccount.name) {
|
||||
androidAccount = suspendCoroutine { continuation ->
|
||||
accountManager.renameAccount(androidAccount, accountManagerAccount.name, { continuation.resume(it.result) }, null)
|
||||
}
|
||||
}
|
||||
|
||||
accountManager.setUserData(androidAccount, ACCOUNT_DATA_NAME, accountManagerAccount.name)
|
||||
accountManager.setUserData(androidAccount, ACCOUNT_DATA_SERVER, accountManagerAccount.server.toString())
|
||||
accountManager.setAuthToken(androidAccount, ACCOUNT_ACCESS_TOKEN_TYPE, accountManagerAccount.accessToken)
|
||||
}
|
||||
|
||||
fun removeAccount(accountManagerAccount: AccountManagerAccount): Boolean {
|
||||
val androidAccount = accountManager.getAccountsByType(ACCOUNT_TYPE)
|
||||
.firstOrNull { accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == accountManagerAccount.id }
|
||||
?: return false
|
||||
|
||||
// Remove current account info
|
||||
@Suppress("DEPRECATION")
|
||||
return if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
accountManager.removeAccount(androidAccount, null, null)
|
||||
true
|
||||
} else accountManager.removeAccountExplicitly(androidAccount)
|
||||
}
|
||||
|
||||
fun getAccounts() = accountManager.getAccountsByType(ACCOUNT_TYPE).map(::getAccountData)
|
||||
|
||||
fun getAccountsByServer(server: UUID) = accountManager.getAccountsByType(ACCOUNT_TYPE).filter { account ->
|
||||
accountManager.getUserData(account, ACCOUNT_DATA_SERVER)?.toUUIDOrNull() == server
|
||||
}.map(::getAccountData)
|
||||
|
||||
fun getAccount(id: UUID) = accountManager.getAccountsByType(ACCOUNT_TYPE).firstOrNull { account ->
|
||||
accountManager.getUserData(account, ACCOUNT_DATA_ID)?.toUUIDOrNull() == id
|
||||
}?.let(::getAccountData)
|
||||
}
|
||||
@@ -14,30 +14,12 @@ class AuthenticationPreferences(context: Context) : SharedPreferenceStore(
|
||||
companion object {
|
||||
// Preferences
|
||||
val autoLoginUserBehavior = enumPreference("auto_login_user_behavior", UserSelectBehavior.LAST_USER)
|
||||
val autoLoginServerId = stringPreference("auto_login_server_id", "")
|
||||
val autoLoginUserId = stringPreference("auto_login_user_id", "")
|
||||
|
||||
val sortBy = enumPreference("sort_by", AuthenticationSortBy.LAST_USE)
|
||||
val alwaysAuthenticate = booleanPreference("always_authenticate", false)
|
||||
|
||||
// Persistent state
|
||||
val lastServerId = stringPreference("last_server_id", "")
|
||||
val lastUserId = stringPreference("last_user_id", "")
|
||||
}
|
||||
|
||||
init {
|
||||
runMigrations {
|
||||
// v0.15.4 to v0.15.5
|
||||
migration(toVersion = 2) {
|
||||
// Unfortunately we cannot migrate the "specific user" login option
|
||||
// so we'll reset the preference to disabled if it was used
|
||||
if (it.getString("auto_login_user_behavior", null) === UserSelectBehavior.SPECIFIC_USER.name) {
|
||||
putString("auto_login_user_id", "")
|
||||
putString("auto_login_user_behavior", UserSelectBehavior.DISABLED.name)
|
||||
}
|
||||
|
||||
putString("last_user_id", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -19,13 +20,14 @@ import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Storage for authentication related entities. Stores servers with users inside, including
|
||||
* access tokens.
|
||||
* Storage for authentication related entities. Stores servers with users inside. Should be used in
|
||||
* combination with the [AccountManagerStore] to store access tokens and integrate with the
|
||||
* operating system.
|
||||
*
|
||||
* The data is stored in a JSON file located in the applications data directory.
|
||||
*/
|
||||
class AuthenticationStore(
|
||||
private val context: Context,
|
||||
private val context: Context
|
||||
) {
|
||||
private val storePath
|
||||
get() = context.filesDir.resolve("authentication_store.json")
|
||||
@@ -56,20 +58,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()
|
||||
}
|
||||
|
||||
// Current version, return as-is
|
||||
2 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
|
||||
|
||||
1 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
|
||||
null -> {
|
||||
Timber.e("Authentication Store is corrupt!")
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
else -> {
|
||||
Timber.e("Authentication Store is using an unknown version!")
|
||||
emptyMap()
|
||||
@@ -77,10 +70,10 @@ class AuthenticationStore(
|
||||
}
|
||||
}
|
||||
|
||||
private fun write(servers: Map<UUID, AuthenticationStoreServer>): Boolean {
|
||||
private fun save(): Boolean {
|
||||
val root = JsonObject(mapOf(
|
||||
"version" to JsonPrimitive(2),
|
||||
"servers" to json.encodeToJsonElement(servers)
|
||||
"version" to JsonPrimitive(1),
|
||||
"servers" to json.encodeToJsonElement(store)
|
||||
))
|
||||
|
||||
storePath.writeText(json.encodeToString(root))
|
||||
@@ -88,10 +81,6 @@ class AuthenticationStore(
|
||||
return true
|
||||
}
|
||||
|
||||
private fun save(): Boolean {
|
||||
return write(store)
|
||||
}
|
||||
|
||||
fun getServers(): Map<UUID, AuthenticationStoreServer> = store
|
||||
|
||||
fun getUsers(server: UUID): Map<UUID, AuthenticationStoreUser>? = getServer(server)?.users
|
||||
|
||||
@@ -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"
|
||||
@@ -65,13 +62,10 @@ object Codec {
|
||||
const val MPEG2VIDEO = "mpeg2video"
|
||||
const val VP8 = "vp8"
|
||||
const val VP9 = "vp9"
|
||||
const val AV1 = "av1"
|
||||
const val VC1 = "vc1"
|
||||
}
|
||||
|
||||
object Subtitle {
|
||||
const val ASS = "ass"
|
||||
const val DVBSUB = "dvbsub"
|
||||
const val DVDSUB = "dvdsub"
|
||||
const val IDX = "idx"
|
||||
const val PGS = "pgs"
|
||||
@@ -82,8 +76,5 @@ object Codec {
|
||||
const val SUB = "sub"
|
||||
const val SUBRIP = "subrip"
|
||||
const val VTT = "vtt"
|
||||
const val SMIL = "smil"
|
||||
const val TTML = "ttml"
|
||||
const val WEBVTT = "webvtt"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package org.jellyfin.androidtv.constant
|
||||
|
||||
sealed interface CustomMessage {
|
||||
data object RefreshCurrentItem : CustomMessage
|
||||
data object ActionComplete : CustomMessage
|
||||
enum class CustomMessage {
|
||||
RefreshCurrentItem,
|
||||
ActionComplete,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
package org.jellyfin.androidtv.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
|
||||
|
||||
enum class GridDirection(
|
||||
override val nameRes: Int,
|
||||
) : PreferenceEnum {
|
||||
enum class GridDirection {
|
||||
/**
|
||||
* Horizontal.
|
||||
*/
|
||||
HORIZONTAL(R.string.grid_direction_horizontal),
|
||||
@EnumDisplayOptions(R.string.grid_direction_horizontal)
|
||||
HORIZONTAL,
|
||||
|
||||
/**
|
||||
* Vertical.
|
||||
*/
|
||||
VERTICAL(R.string.grid_direction_vertical),
|
||||
@EnumDisplayOptions(R.string.grid_direction_vertical)
|
||||
VERTICAL,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.jellyfin.androidtv.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
|
||||
/**
|
||||
@@ -9,17 +10,35 @@ import org.jellyfin.preference.PreferenceEnum
|
||||
* https://github.com/jellyfin/jellyfin-web/blob/master/src/components/homesections/homesections.js
|
||||
*/
|
||||
enum class HomeSectionType(
|
||||
override val serializedName: String,
|
||||
override val nameRes: Int,
|
||||
override val serializedName: String
|
||||
) : PreferenceEnum {
|
||||
LATEST_MEDIA("latestmedia", R.string.home_section_latest_media),
|
||||
LIBRARY_TILES_SMALL("smalllibrarytiles", R.string.home_section_library),
|
||||
LIBRARY_BUTTONS("librarybuttons", R.string.home_section_library_small),
|
||||
RESUME("resume", R.string.home_section_resume),
|
||||
RESUME_AUDIO("resumeaudio", R.string.home_section_resume_audio),
|
||||
RESUME_BOOK("resumebook", R.string.home_section_resume_book),
|
||||
ACTIVE_RECORDINGS("activerecordings", R.string.home_section_active_recordings),
|
||||
NEXT_UP("nextup", R.string.home_section_next_up),
|
||||
LIVE_TV("livetv", R.string.home_section_livetv),
|
||||
NONE("none", R.string.home_section_none),
|
||||
@EnumDisplayOptions(R.string.home_section_latest_media)
|
||||
LATEST_MEDIA("latestmedia"),
|
||||
|
||||
@EnumDisplayOptions(R.string.home_section_library)
|
||||
LIBRARY_TILES_SMALL("smalllibrarytiles"),
|
||||
|
||||
@EnumDisplayOptions(R.string.home_section_library_small)
|
||||
LIBRARY_BUTTONS("librarybuttons"),
|
||||
|
||||
@EnumDisplayOptions(R.string.home_section_resume)
|
||||
RESUME("resume"),
|
||||
|
||||
@EnumDisplayOptions(R.string.home_section_resume_audio)
|
||||
RESUME_AUDIO("resumeaudio"),
|
||||
|
||||
@EnumDisplayOptions(R.string.home_section_resume_book)
|
||||
RESUME_BOOK("resumebook"),
|
||||
|
||||
@EnumDisplayOptions(R.string.home_section_active_recordings)
|
||||
ACTIVE_RECORDINGS("activerecordings"),
|
||||
|
||||
@EnumDisplayOptions(R.string.home_section_next_up)
|
||||
NEXT_UP("nextup"),
|
||||
|
||||
@EnumDisplayOptions(R.string.home_section_livetv)
|
||||
LIVE_TV("livetv"),
|
||||
|
||||
@EnumDisplayOptions(R.string.home_section_none)
|
||||
NONE("none"),
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
package org.jellyfin.androidtv.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
|
||||
|
||||
enum class ImageType(
|
||||
override val nameRes: Int,
|
||||
) : PreferenceEnum {
|
||||
enum class ImageType {
|
||||
/**
|
||||
* Poster.
|
||||
*/
|
||||
POSTER(R.string.image_type_poster),
|
||||
@EnumDisplayOptions(R.string.image_type_poster)
|
||||
POSTER,
|
||||
|
||||
/**
|
||||
* Thumbnail.
|
||||
*/
|
||||
THUMB(R.string.image_type_thumbnail),
|
||||
@EnumDisplayOptions(R.string.image_type_thumbnail)
|
||||
THUMB,
|
||||
|
||||
/**
|
||||
* Banner.
|
||||
*/
|
||||
BANNER(R.string.image_type_banner),
|
||||
@EnumDisplayOptions(R.string.image_type_banner)
|
||||
BANNER
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.jellyfin.androidtv.constant
|
||||
object LiveTvOption {
|
||||
const val LIVE_TV_GUIDE_OPTION_ID = 1000
|
||||
const val LIVE_TV_RECORDINGS_OPTION_ID = 2000
|
||||
const val VIDEO_QUEUE_OPTION_ID = 3000
|
||||
const val LIVE_TV_SCHEDULE_OPTION_ID = 4000
|
||||
const val LIVE_TV_SERIES_OPTION_ID = 5000
|
||||
}
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
package org.jellyfin.androidtv.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
|
||||
|
||||
enum class PosterSize(
|
||||
override val nameRes: Int,
|
||||
) : PreferenceEnum {
|
||||
enum class PosterSize {
|
||||
/**
|
||||
* Smallest.
|
||||
*/
|
||||
SMALLEST(R.string.image_size_smallest),
|
||||
@EnumDisplayOptions(R.string.image_size_smallest)
|
||||
SMALLEST,
|
||||
/**
|
||||
* Small.
|
||||
*/
|
||||
SMALL(R.string.image_size_small),
|
||||
@EnumDisplayOptions(R.string.image_size_small)
|
||||
SMALL,
|
||||
|
||||
/**
|
||||
* Medium.
|
||||
*/
|
||||
MED(R.string.image_size_medium),
|
||||
@EnumDisplayOptions(R.string.image_size_medium)
|
||||
MED,
|
||||
|
||||
/**
|
||||
* Large.
|
||||
*/
|
||||
LARGE(R.string.image_size_large),
|
||||
@EnumDisplayOptions(R.string.image_size_large)
|
||||
LARGE,
|
||||
|
||||
/**
|
||||
* Extra Large.
|
||||
*/
|
||||
X_LARGE(R.string.image_size_xlarge),
|
||||
@EnumDisplayOptions(R.string.image_size_xlarge)
|
||||
X_LARGE
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package org.jellyfin.androidtv.constant
|
||||
|
||||
import android.content.Context
|
||||
import org.jellyfin.androidtv.R
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private val qualityOptions = setOf(
|
||||
200.0, 180.0, 140.0, 120.0, 110.0, 100.0, // 100 >=
|
||||
90.0, 80.0, 70.0, 60.0, 50.0, 40.0, 30.0, 20.0, 15.0, 10.0, // 10 >=
|
||||
5.0, 3.0, 2.0, 1.0, // 1 >=
|
||||
0.72, 0.42 // 0 >=
|
||||
)
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
fun getQualityProfiles(
|
||||
context: Context
|
||||
): Map<String, String> = qualityOptions.associate {
|
||||
val value = when {
|
||||
it >= 1.0 -> context.getString(R.string.bitrate_mbit, it)
|
||||
else -> context.getString(R.string.bitrate_kbit, it * 1000.0)
|
||||
}
|
||||
|
||||
it.toString().removeSuffix(".0") to value
|
||||
}
|
||||
|
||||
@@ -17,12 +17,13 @@ enum class QueryType {
|
||||
LiveTvChannel,
|
||||
LiveTvProgram,
|
||||
LiveTvRecording,
|
||||
LiveTvRecordingGroup,
|
||||
StaticItems,
|
||||
Artists,
|
||||
Persons,
|
||||
StaticAudioQueueItems,
|
||||
AlbumArtists,
|
||||
AudioPlaylists,
|
||||
LatestItems,
|
||||
SeriesTimer,
|
||||
Premieres,
|
||||
Resume,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
import org.jellyfin.apiclient.model.dlna.DeviceProfile;
|
||||
import org.jellyfin.apiclient.model.dlna.EncodingContext;
|
||||
import org.jellyfin.apiclient.model.dto.MediaSourceInfo;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
/**
|
||||
* Class AudioOptions.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
@Deprecated
|
||||
public class AudioOptions {
|
||||
public AudioOptions() {
|
||||
setContext(EncodingContext.Streaming);
|
||||
|
||||
setEnableDirectPlay(true);
|
||||
setEnableDirectStream(true);
|
||||
}
|
||||
|
||||
private boolean EnableDirectPlay;
|
||||
|
||||
public final boolean getEnableDirectPlay() {
|
||||
return EnableDirectPlay;
|
||||
}
|
||||
|
||||
public final void setEnableDirectPlay(boolean value) {
|
||||
EnableDirectPlay = value;
|
||||
}
|
||||
|
||||
private boolean EnableDirectStream;
|
||||
|
||||
public final boolean getEnableDirectStream() {
|
||||
return EnableDirectStream;
|
||||
}
|
||||
|
||||
public final void setEnableDirectStream(boolean value) {
|
||||
EnableDirectStream = value;
|
||||
}
|
||||
|
||||
private String ItemId;
|
||||
|
||||
public final String getItemId() {
|
||||
return ItemId;
|
||||
}
|
||||
|
||||
public final void setItemId(String value) {
|
||||
ItemId = value;
|
||||
}
|
||||
|
||||
private ArrayList<MediaSourceInfo> MediaSources;
|
||||
|
||||
public final ArrayList<MediaSourceInfo> getMediaSources() {
|
||||
return MediaSources;
|
||||
}
|
||||
|
||||
public final void setMediaSources(ArrayList<MediaSourceInfo> value) {
|
||||
MediaSources = value;
|
||||
}
|
||||
|
||||
private DeviceProfile Profile;
|
||||
|
||||
public final DeviceProfile getProfile() {
|
||||
return Profile;
|
||||
}
|
||||
|
||||
public final void setProfile(DeviceProfile value) {
|
||||
Profile = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional. Only needed if a specific AudioStreamIndex or SubtitleStreamIndex are requested.
|
||||
*/
|
||||
private String MediaSourceId;
|
||||
|
||||
public final String getMediaSourceId() {
|
||||
return MediaSourceId;
|
||||
}
|
||||
|
||||
public final void setMediaSourceId(String value) {
|
||||
MediaSourceId = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows an override of supported number of audio channels
|
||||
* Example: DeviceProfile supports five channel, but user only has stereo speakers
|
||||
*/
|
||||
private Integer MaxAudioChannels = null;
|
||||
|
||||
public final Integer getMaxAudioChannels() {
|
||||
return MaxAudioChannels;
|
||||
}
|
||||
|
||||
public final void setMaxAudioChannels(Integer value) {
|
||||
MaxAudioChannels = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The application's configured quality setting
|
||||
*/
|
||||
private Integer MaxBitrate = null;
|
||||
|
||||
public final Integer getMaxBitrate() {
|
||||
return MaxBitrate;
|
||||
}
|
||||
|
||||
public final void setMaxBitrate(Integer value) {
|
||||
MaxBitrate = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the context.
|
||||
*
|
||||
* <value>The context.</value>
|
||||
*/
|
||||
private EncodingContext Context = EncodingContext.values()[0];
|
||||
|
||||
public final EncodingContext getContext() {
|
||||
return Context;
|
||||
}
|
||||
|
||||
public final void setContext(EncodingContext value) {
|
||||
Context = value;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.compat
|
||||
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
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 mediaSources: List<MediaSourceInfo>? = null
|
||||
var profile: DeviceProfile? = null
|
||||
|
||||
/**
|
||||
* Optional. Only needed if a specific AudioStreamIndex or SubtitleStreamIndex are requested.
|
||||
*/
|
||||
var mediaSourceId: String? = null
|
||||
|
||||
/**
|
||||
* Allows an override of supported number of audio channels
|
||||
* Example: DeviceProfile supports five channel, but user only has stereo speakers
|
||||
*/
|
||||
var maxAudioChannels: Int? = null
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
import org.jellyfin.apiclient.model.dlna.PlaybackErrorCode;
|
||||
|
||||
@Deprecated
|
||||
public class PlaybackException extends RuntimeException {
|
||||
private PlaybackErrorCode ErrorCode = PlaybackErrorCode.values()[0];
|
||||
|
||||
public final PlaybackErrorCode getErrorCode() {
|
||||
return ErrorCode;
|
||||
}
|
||||
|
||||
public final void setErrorCode(PlaybackErrorCode value) {
|
||||
ErrorCode = value;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.compat
|
||||
|
||||
import org.jellyfin.sdk.model.api.PlaybackErrorCode
|
||||
|
||||
class PlaybackException : RuntimeException() {
|
||||
var errorCode = PlaybackErrorCode.NOT_ALLOWED
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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;
|
||||
|
||||
@Deprecated
|
||||
public class StreamBuilder
|
||||
{
|
||||
public static SubtitleProfile GetSubtitleProfile(MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod)
|
||||
{
|
||||
if (playMethod != PlayMethod.Transcode && !subtitleStream.getIsExternal())
|
||||
{
|
||||
// Look for supported embedded subs
|
||||
for (SubtitleProfile profile : subtitleProfiles)
|
||||
{
|
||||
if (!profile.SupportsLanguage(subtitleStream.getLanguage()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (profile.getMethod() != SubtitleDeliveryMethod.Embed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (subtitleStream.getIsTextSubtitleStream() == 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(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.getIsTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat())) || (profile.getMethod() == SubtitleDeliveryMethod.Hls && subtitleStream.getIsTextSubtitleStream()))
|
||||
{
|
||||
boolean requiresConversion = !Utils.equalsIgnoreCase(subtitleStream.getCodec(), profile.getFormat());
|
||||
|
||||
if (!requiresConversion)
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
|
||||
if (!allowConversion)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (subtitleStream.getIsTextSubtitleStream() && subtitleStream.getSupportsExternalStream() && subtitleStream.SupportsSubtitleConversionTo(profile.getFormat()))
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,43 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
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 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.MediaSourceInfo;
|
||||
import org.jellyfin.apiclient.model.dto.NameValuePair;
|
||||
import org.jellyfin.apiclient.model.entities.MediaStream;
|
||||
import org.jellyfin.apiclient.model.entities.MediaStreamType;
|
||||
import org.jellyfin.apiclient.model.mediainfo.MediaProtocol;
|
||||
import org.jellyfin.apiclient.model.session.PlayMethod;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Class StreamInfo.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
@Deprecated
|
||||
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 +51,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 +91,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 +141,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 +157,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,14 +177,191 @@ public class StreamInfo {
|
||||
return getMediaSource() == null ? null : getMediaSource().getId();
|
||||
}
|
||||
|
||||
public final ArrayList<MediaStream> getSelectableAudioStreams() {
|
||||
return getSelectableStreams(MediaStreamType.AUDIO);
|
||||
public final boolean getIsDirectStream() {
|
||||
return getPlayMethod() == PlayMethod.DirectStream || getPlayMethod() == PlayMethod.DirectPlay;
|
||||
}
|
||||
|
||||
public final ArrayList<org.jellyfin.sdk.model.api.MediaStream> getSelectableStreams(MediaStreamType type) {
|
||||
ArrayList<org.jellyfin.sdk.model.api.MediaStream> list = new ArrayList<org.jellyfin.sdk.model.api.MediaStream>();
|
||||
public final String ToUrl(String baseUrl, String accessToken) {
|
||||
if (!Utils.isEmpty(getMediaUrl())) {
|
||||
return getMediaUrl();
|
||||
}
|
||||
|
||||
for (org.jellyfin.sdk.model.api.MediaStream stream : getMediaSource().getMediaStreams()) {
|
||||
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) {
|
||||
for (MediaStream stream : getMediaSource().getMediaStreams()) {
|
||||
if (stream.getType() == MediaStreamType.Subtitle) {
|
||||
AddSubtitleProfiles(list, stream, enableAllProfiles, baseUrl, accessToken, startPositionTicks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private void AddSubtitleProfiles(ArrayList<SubtitleStreamInfo> list, 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(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);
|
||||
}
|
||||
|
||||
public final ArrayList<MediaStream> GetSelectableStreams(MediaStreamType type) {
|
||||
ArrayList<MediaStream> list = new ArrayList<MediaStream>();
|
||||
|
||||
for (MediaStream stream : getMediaSource().getMediaStreams()) {
|
||||
if (type == stream.getType()) {
|
||||
list.add(stream);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod;
|
||||
|
||||
@Deprecated
|
||||
public class SubtitleStreamInfo {
|
||||
private String Url;
|
||||
|
||||
public final String getUrl() {
|
||||
return Url;
|
||||
}
|
||||
|
||||
public final void setUrl(String value) {
|
||||
Url = value;
|
||||
}
|
||||
|
||||
private String Name;
|
||||
|
||||
public final String getName() {
|
||||
return Name;
|
||||
}
|
||||
|
||||
public final void setName(String value) {
|
||||
Name = value;
|
||||
}
|
||||
|
||||
private String Format;
|
||||
|
||||
public final String getFormat() {
|
||||
return Format;
|
||||
}
|
||||
|
||||
public final void setFormat(String value) {
|
||||
Format = value;
|
||||
}
|
||||
|
||||
private String DisplayTitle;
|
||||
|
||||
public final String getDisplayTitle() {
|
||||
return DisplayTitle;
|
||||
}
|
||||
|
||||
public final void setDisplayTitle(String value) {
|
||||
DisplayTitle = value;
|
||||
}
|
||||
|
||||
private int Index;
|
||||
|
||||
public final int getIndex() {
|
||||
return Index;
|
||||
}
|
||||
|
||||
public final void setIndex(int value) {
|
||||
Index = value;
|
||||
}
|
||||
|
||||
private SubtitleDeliveryMethod DeliveryMethod = SubtitleDeliveryMethod.values()[0];
|
||||
|
||||
public final SubtitleDeliveryMethod getDeliveryMethod() {
|
||||
return DeliveryMethod;
|
||||
}
|
||||
|
||||
public final void setDeliveryMethod(SubtitleDeliveryMethod value) {
|
||||
DeliveryMethod = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
/**
|
||||
* Class VideoOptions.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
@Deprecated
|
||||
public class VideoOptions extends AudioOptions {
|
||||
private Integer AudioStreamIndex;
|
||||
private Integer SubtitleStreamIndex;
|
||||
|
||||
public final Integer getAudioStreamIndex() {
|
||||
return AudioStreamIndex;
|
||||
}
|
||||
|
||||
public final void setAudioStreamIndex(Integer value) {
|
||||
AudioStreamIndex = value;
|
||||
}
|
||||
|
||||
public final Integer getSubtitleStreamIndex() {
|
||||
return SubtitleStreamIndex;
|
||||
}
|
||||
|
||||
public final void setSubtitleStreamIndex(Integer value) {
|
||||
SubtitleStreamIndex = value;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.compat
|
||||
|
||||
class VideoOptions : AudioOptions() {
|
||||
var audioStreamIndex: Int? = null
|
||||
var subtitleStreamIndex: Int? = null
|
||||
}
|
||||
@@ -1,46 +1,33 @@
|
||||
package org.jellyfin.androidtv.data.eventhandling
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
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.util.apiclient.PlaybackHelper
|
||||
import org.jellyfin.apiclient.model.entities.MediaType
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.sessionApi
|
||||
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.model.api.BaseItemKind
|
||||
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.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.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(
|
||||
@@ -49,113 +36,53 @@ class SocketHandler(
|
||||
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)
|
||||
private var socketInstance: SocketInstance = createInstance()
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
add(GeneralCommandType.MUTE)
|
||||
add(GeneralCommandType.UNMUTE)
|
||||
add(GeneralCommandType.TOGGLE_MUTE)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
api.sessionApi.postCapabilities(
|
||||
playableMediaTypes = listOf(MediaType.Video, MediaType.Audio),
|
||||
supportsMediaControl = true,
|
||||
supportedCommands = listOf(
|
||||
GeneralCommandType.DISPLAY_MESSAGE,
|
||||
GeneralCommandType.SEND_STRING,
|
||||
),
|
||||
)
|
||||
} 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 {
|
||||
private fun createInstance() = api.ws().apply {
|
||||
// Library
|
||||
subscribe<LibraryChangedMessage>()
|
||||
.onEach { message -> message.data?.let(::onLibraryChanged) }
|
||||
.launchIn(coroutineScope)
|
||||
addListener<LibraryChangedMessage> { message -> onLibraryChanged(message.info) }
|
||||
|
||||
// Media playback
|
||||
subscribe<PlayMessage>()
|
||||
.onEach { message -> onPlayMessage(message) }
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
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)
|
||||
addListener<PlayMessage> { message -> onPlayMessage(message) }
|
||||
addListener<PlayStateMessage> { message -> onPlayStateMessage(message) }
|
||||
|
||||
// General commands
|
||||
subscribeGeneralCommand(GeneralCommandType.DISPLAY_CONTENT)
|
||||
.onEach { message ->
|
||||
val itemId by message
|
||||
val itemType by message
|
||||
addGeneralCommandsListener(setOf(GeneralCommandType.DISPLAY_CONTENT)) { message ->
|
||||
val itemId by message
|
||||
val itemUuid = itemId?.toUUIDOrNull()
|
||||
|
||||
val itemUuid = itemId?.toUUIDOrNull()
|
||||
val itemKind = itemType?.let { type ->
|
||||
BaseItemKind.entries.find { value ->
|
||||
value.serialName.equals(type, true)
|
||||
}
|
||||
}
|
||||
if (itemUuid != null) onDisplayContent(itemUuid)
|
||||
}
|
||||
addGeneralCommandsListener(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING)) { message ->
|
||||
val header by message
|
||||
val text by message
|
||||
val string by message
|
||||
|
||||
if (itemUuid != null && itemKind != null) onDisplayContent(itemUuid, itemKind)
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
subscribeGeneralCommands(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING))
|
||||
.onEach { message ->
|
||||
val header by message
|
||||
val text by message
|
||||
val string by message
|
||||
|
||||
onDisplayMessage(header, text ?: string)
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
onDisplayMessage(header, text ?: string)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLibraryChanged(info: LibraryUpdateInfo) {
|
||||
@@ -167,73 +94,64 @@ 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,
|
||||
false,
|
||||
message.data?.startPositionTicks,
|
||||
message.data?.startIndex,
|
||||
context
|
||||
)
|
||||
}.onFailure { Timber.w(it, "Failed to start playback") }
|
||||
PlaybackHelper.retrieveAndPlay(
|
||||
itemId.toString(),
|
||||
false,
|
||||
message.request.startPositionTicks,
|
||||
context
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("ComplexMethod")
|
||||
private suspend fun onPlayStateMessage(message: PlaystateMessage) = withContext(Dispatchers.Main) {
|
||||
Timber.i("Received PlayStateMessage with command ${message.data?.command}")
|
||||
|
||||
// Audio playback uses (Rewrite)MediaManager, (legacy) video playback uses playbackController
|
||||
when {
|
||||
mediaManager.hasAudioQueueItems() -> {
|
||||
Timber.i("Ignoring PlayStateMessage: should be handled by PlaySessionSocketService")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
// PlaybackController
|
||||
else -> {
|
||||
val playbackController = playbackControllerContainer.playbackController
|
||||
when (message.data?.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
|
||||
)
|
||||
|
||||
PlaystateCommand.REWIND -> playbackController?.rewind()
|
||||
PlaystateCommand.FAST_FORWARD -> playbackController?.fastForward()
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
private fun onPlayStateMessage(message: PlayStateMessage) = coroutineScope.launch(Dispatchers.Main) {
|
||||
Timber.i("Received PlayStateMessage with command ${message.request.command}")
|
||||
val playbackController = playbackControllerContainer.playbackController
|
||||
// Audio playback uses the mediaManager, video playback and live tv use the playbackController
|
||||
if (mediaManager.isAudioPlayerInitialized) 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
|
||||
} else 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.request.seekPositionTicks ?: 0) / TICKS_TO_MS
|
||||
)
|
||||
// FIXME get rewind/forward amount from displayprefs
|
||||
PlaystateCommand.REWIND -> playbackController?.skip(REWIND_MS)
|
||||
PlaystateCommand.FAST_FORWARD -> playbackController?.skip(FORWARD_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onDisplayContent(itemId: UUID, itemKind: BaseItemKind) = withContext(Dispatchers.Main) {
|
||||
// FIXME: Add an ItemLauncher function that accepts SDK BaseItemDto
|
||||
// Add "GeneralCommandType.DISPLAY_CONTENT" to supportedCommands in capabilities to receive
|
||||
// these messages. Otherwise this function is never called.
|
||||
private fun onDisplayContent(itemId: UUID) {
|
||||
val playbackController = playbackControllerContainer.playbackController
|
||||
|
||||
if (playbackController?.isPlaying == true || playbackController?.isPaused == true) {
|
||||
Timber.i("Not launching $itemId: playback in progress")
|
||||
return@withContext
|
||||
return
|
||||
}
|
||||
|
||||
Timber.i("Launching $itemId")
|
||||
|
||||
when (itemKind) {
|
||||
BaseItemKind.USER_VIEW,
|
||||
BaseItemKind.COLLECTION_FOLDER -> {
|
||||
val item by api.userLibraryApi.getItem(itemId = itemId)
|
||||
itemLauncher.launchUserView(item)
|
||||
}
|
||||
|
||||
else -> navigationRepository.navigate(Destinations.itemDetails(itemId))
|
||||
coroutineScope.launch {
|
||||
val item by api.userLibraryApi.getItem(itemId = itemId)
|
||||
// ItemLauncher.launch(item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,5 +168,7 @@ class SocketHandler(
|
||||
|
||||
companion object {
|
||||
const val TICKS_TO_MS = 10000L
|
||||
const val REWIND_MS = -11000
|
||||
const val FORWARD_MS = 30000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,4 @@ package org.jellyfin.androidtv.data.model
|
||||
|
||||
data class AppNotification(
|
||||
val message: String,
|
||||
val dismiss: () -> Unit,
|
||||
val public: Boolean,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.jellyfin.androidtv.data.model;
|
||||
|
||||
import org.jellyfin.apiclient.model.entities.ChapterInfo;
|
||||
|
||||
public class ChapterItemInfo extends ChapterInfo {
|
||||
private String itemId;
|
||||
|
||||
public String getItemId() {
|
||||
return itemId;
|
||||
}
|
||||
|
||||
public void setItemId(String itemId) {
|
||||
this.itemId = itemId;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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?,
|
||||
)
|
||||
@@ -1,15 +1,15 @@
|
||||
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 lastDeletedItemId: String? = 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.jellyfin.androidtv.data.model;
|
||||
|
||||
import org.jellyfin.apiclient.model.querying.ItemFilter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class FilterOptions {
|
||||
private boolean favoriteOnly;
|
||||
private boolean unwatchedOnly;
|
||||
|
||||
public void setFavoriteOnly(boolean value) {
|
||||
favoriteOnly = value;
|
||||
}
|
||||
|
||||
public void setUnwatchedOnly(boolean value) {
|
||||
unwatchedOnly = value;
|
||||
}
|
||||
|
||||
public boolean isUnwatchedOnly() {
|
||||
return unwatchedOnly;
|
||||
}
|
||||
|
||||
public boolean isFavoriteOnly() {
|
||||
return favoriteOnly;
|
||||
}
|
||||
|
||||
public ItemFilter[] getFilters() {
|
||||
if (!unwatchedOnly && !favoriteOnly) return null;
|
||||
|
||||
List<ItemFilter> filters = new ArrayList<>();
|
||||
if (favoriteOnly) filters.add(ItemFilter.IsFavorite);
|
||||
if (unwatchedOnly) filters.add(ItemFilter.IsUnplayed);
|
||||
|
||||
return filters.toArray(new ItemFilter[0]);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.model
|
||||
|
||||
import org.jellyfin.sdk.model.api.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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.jellyfin.androidtv.data.model;
|
||||
|
||||
public class InfoItem {
|
||||
private String label;
|
||||
private String value;
|
||||
|
||||
public InfoItem() {
|
||||
this("", "");
|
||||
}
|
||||
|
||||
public InfoItem(String label, String value) {
|
||||
this.label = label;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.model
|
||||
|
||||
data class InfoItem @JvmOverloads constructor(
|
||||
val label: String = "",
|
||||
val value: String = "",
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
package org.jellyfin.androidtv.data.querying;
|
||||
|
||||
data class AdditionalPartsQuery(val itemId: String)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.querying
|
||||
|
||||
data object GetUserViewsRequest
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.jellyfin.androidtv.data.querying;
|
||||
|
||||
public class SpecialsQuery {
|
||||
private String ItemId;
|
||||
|
||||
public SpecialsQuery(String itemId) {
|
||||
ItemId = itemId;
|
||||
}
|
||||
|
||||
public String getItemId() {
|
||||
return ItemId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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.java.KoinJavaComponent;
|
||||
|
||||
public class StdItemQuery extends ItemQuery {
|
||||
public StdItemQuery(ItemFields[] fields) {
|
||||
if (fields == null) {
|
||||
fields = new ItemFields[]{
|
||||
ItemFields.PrimaryImageAspectRatio,
|
||||
ItemFields.Overview,
|
||||
ItemFields.ItemCounts,
|
||||
ItemFields.DisplayPreferencesId,
|
||||
ItemFields.ChildCount
|
||||
};
|
||||
}
|
||||
setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
|
||||
setFields(fields);
|
||||
}
|
||||
|
||||
public StdItemQuery() {
|
||||
this(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.jellyfin.androidtv.data.querying;
|
||||
|
||||
public class TrailersQuery {
|
||||
private String ItemId;
|
||||
|
||||
public TrailersQuery(String itemId) {
|
||||
ItemId = itemId;
|
||||
}
|
||||
|
||||
public String getItemId() {
|
||||
return ItemId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.jellyfin.androidtv.data.querying;
|
||||
|
||||
public class ViewQuery {
|
||||
//Dummy indicator class
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.repository
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.jellyfin.androidtv.constant.CustomMessage
|
||||
|
||||
interface CustomMessageRepository {
|
||||
val message: StateFlow<CustomMessage?>
|
||||
fun pushMessage(message: CustomMessage)
|
||||
}
|
||||
|
||||
class CustomMessageRepositoryImpl : CustomMessageRepository {
|
||||
private val _message = MutableStateFlow<CustomMessage?>(null)
|
||||
override val message get() = _message.asStateFlow()
|
||||
|
||||
override fun pushMessage(message: CustomMessage) {
|
||||
// Make sure to re-emit the same message if requested
|
||||
if (_message.value == message) _message.value = null
|
||||
|
||||
_message.value = message
|
||||
}
|
||||
}
|
||||
@@ -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,40 +0,0 @@
|
||||
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
|
||||
suspend fun setPlayed(item: UUID, played: Boolean): UserItemDataDto
|
||||
}
|
||||
|
||||
class ItemMutationRepositoryImpl(
|
||||
private val api: ApiClient,
|
||||
private val dataRefreshService: DataRefreshService,
|
||||
) : ItemMutationRepository {
|
||||
override suspend fun setFavorite(item: UUID, favorite: Boolean): UserItemDataDto {
|
||||
val response by when {
|
||||
favorite -> withContext(Dispatchers.IO) { api.userLibraryApi.markFavoriteItem(itemId = item) }
|
||||
else -> withContext(Dispatchers.IO) { api.userLibraryApi.unmarkFavoriteItem(itemId = item) }
|
||||
}
|
||||
|
||||
dataRefreshService.lastFavoriteUpdate = Instant.now()
|
||||
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) }
|
||||
}
|
||||
|
||||
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,95 +1,46 @@
|
||||
package org.jellyfin.androidtv.data.repository
|
||||
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.model.Server
|
||||
import org.jellyfin.androidtv.auth.repository.ServerRepository
|
||||
import org.jellyfin.androidtv.data.model.AppNotification
|
||||
import org.jellyfin.androidtv.preference.SystemPreferences
|
||||
import org.jellyfin.androidtv.util.isTvDevice
|
||||
import org.jellyfin.sdk.model.ServerVersion
|
||||
|
||||
interface NotificationsRepository {
|
||||
val notifications: StateFlow<List<AppNotification>>
|
||||
|
||||
fun dismissNotification(item: AppNotification)
|
||||
fun addDefaultNotifications()
|
||||
fun updateServerNotifications(server: Server?)
|
||||
}
|
||||
|
||||
class NotificationsRepositoryImpl(
|
||||
private val context: Context,
|
||||
private val systemPreferences: SystemPreferences,
|
||||
private val uiModeManager: UiModeManager,
|
||||
) : NotificationsRepository {
|
||||
override val notifications = MutableStateFlow(emptyList<AppNotification>())
|
||||
|
||||
override fun dismissNotification(item: AppNotification) {
|
||||
notifications.value = notifications.value.filter { it != item }
|
||||
item.dismiss()
|
||||
}
|
||||
|
||||
override fun addDefaultNotifications() {
|
||||
addUiModeNotification()
|
||||
addBetaNotification()
|
||||
}
|
||||
|
||||
private fun addNotification(
|
||||
message: String,
|
||||
public: Boolean = false,
|
||||
dismiss: () -> Unit = {}
|
||||
): AppNotification {
|
||||
val notification = AppNotification(message, dismiss, public)
|
||||
notifications.value += notification
|
||||
return notification
|
||||
}
|
||||
|
||||
private fun removeNotification(notification: AppNotification) {
|
||||
notifications.value -= notification
|
||||
private fun addNotification(message: String) {
|
||||
notifications.value = notifications.value + AppNotification(message)
|
||||
}
|
||||
|
||||
private fun addUiModeNotification() {
|
||||
val disableUiModeWarning = systemPreferences[SystemPreferences.disableUiModeWarning]
|
||||
val supportedUiModes = setOf(Configuration.UI_MODE_TYPE_TELEVISION, Configuration.UI_MODE_TYPE_UNDEFINED)
|
||||
val invalidUiMode = !supportedUiModes.contains(uiModeManager.currentModeType)
|
||||
val isTouch = context.packageManager.hasSystemFeature("android.hardware.touchscreen")
|
||||
val hasHdmiCec = context.packageManager.hasSystemFeature("android.hardware.hdmi.cec")
|
||||
|
||||
if (!context.isTvDevice() && !disableUiModeWarning) {
|
||||
addNotification(
|
||||
context.getString(R.string.app_notification_uimode_invalid),
|
||||
public = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addBetaNotification() {
|
||||
val dismissedVersion = systemPreferences[SystemPreferences.dismissedBetaNotificationVersion]
|
||||
val currentVersion = BuildConfig.VERSION_NAME
|
||||
val isBeta = currentVersion.lowercase().contains("beta")
|
||||
|
||||
if (isBeta && currentVersion != dismissedVersion) {
|
||||
addNotification(context.getString(R.string.app_notification_beta, currentVersion)) {
|
||||
systemPreferences[SystemPreferences.dismissedBetaNotificationVersion] =
|
||||
currentVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update server notification
|
||||
private var _updateServerNotification: AppNotification? = null
|
||||
override fun updateServerNotifications(server: Server?) {
|
||||
// Remove current update notification
|
||||
_updateServerNotification?.let(::removeNotification)
|
||||
|
||||
val currentServerVersion = server?.version?.let(ServerVersion::fromString) ?: return
|
||||
if (currentServerVersion < ServerRepository.upcomingMinimumServerVersion) {
|
||||
_updateServerNotification =
|
||||
addNotification(
|
||||
message = context.getString(
|
||||
R.string.app_notification_update_soon,
|
||||
currentServerVersion,
|
||||
ServerRepository.upcomingMinimumServerVersion
|
||||
),
|
||||
)
|
||||
if (invalidUiMode && isTouch && !hasHdmiCec) {
|
||||
addNotification(context.getString(R.string.app_notification_uimode_invalid))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.apiclient.model.entities.CollectionType
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.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,31 @@ 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.Games,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +1,191 @@
|
||||
package org.jellyfin.androidtv.data.service
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
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 android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.util.Size
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.BlendModeColorFilterCompat
|
||||
import androidx.core.graphics.BlendModeCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.add
|
||||
import androidx.window.layout.WindowMetricsCalculator
|
||||
import com.bumptech.glide.Glide
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.model.Server
|
||||
import org.jellyfin.androidtv.preference.UserPreferences
|
||||
import org.jellyfin.androidtv.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.androidtv.util.sdk.compat.asSdk
|
||||
import org.jellyfin.apiclient.model.search.SearchHint
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import java.time.Instant
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ExecutionException
|
||||
import org.jellyfin.apiclient.model.dto.BaseItemDto as LegacyBaseItemDto
|
||||
|
||||
class BackgroundService(
|
||||
private val context: Context,
|
||||
private val jellyfin: Jellyfin,
|
||||
private val api: ApiClient,
|
||||
private val userPreferences: UserPreferences,
|
||||
private val imageLoader: ImageLoader,
|
||||
) {
|
||||
companion object {
|
||||
val SLIDESHOW_DURATION = 30.seconds
|
||||
val TRANSITION_DURATION = 800.milliseconds
|
||||
const val TRANSITION_DURATION = 400L // 0.4 seconds
|
||||
const val SLIDESHOW_DURATION = 30000L // 30 seconds
|
||||
const val UPDATE_INTERVAL = 500L // 0.5 seconds
|
||||
val FRAGMENT_TAG = BackgroundServiceFragment::class.qualifiedName!!
|
||||
}
|
||||
|
||||
// Async
|
||||
private val scope = MainScope()
|
||||
private var loadBackgroundsJob: Job? = null
|
||||
private var updateBackgroundTimerJob: Job? = null
|
||||
private var lastBackgroundTimerUpdate = 0L
|
||||
private var lastBackgroundUpdate = 0L
|
||||
|
||||
// Current background data
|
||||
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()
|
||||
// All background drawables currently showing
|
||||
internal val backgrounds = mutableListOf<Drawable>()
|
||||
|
||||
// Current background index
|
||||
private var currentIndex = 0
|
||||
|
||||
// Preferred display size, set when calling [attach].
|
||||
private var windowSize = Size(0, 0)
|
||||
private var windowBackground: Drawable = ColorDrawable(Color.BLACK)
|
||||
|
||||
// Background layers
|
||||
internal val backgroundDrawable = ContextCompat.getDrawable(context, R.drawable.layer_background) as LayerDrawable
|
||||
|
||||
// Filter to darken backgrounds
|
||||
private val colorFilter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(
|
||||
ContextCompat.getColor(context, R.color.background_filter),
|
||||
BlendModeCompat.SRC_ATOP
|
||||
)
|
||||
|
||||
// Animation
|
||||
@Suppress("MagicNumber")
|
||||
private val backgroundAnimator = ValueAnimator.ofInt(0, 255).apply {
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
duration = TRANSITION_DURATION
|
||||
|
||||
addUpdateListener { animation ->
|
||||
// Set alpha
|
||||
val value = animation.animatedValue as Int
|
||||
backgroundDrawable.findDrawableByLayerId(R.id.background_next).alpha = value
|
||||
backgroundDrawable.invalidateSelf()
|
||||
}
|
||||
|
||||
doOnEnd {
|
||||
// Set next as current and clear next
|
||||
val drawable = backgroundDrawable.findDrawableByLayerId(R.id.background_next)
|
||||
backgroundDrawable.setDrawableByLayerId(R.id.background_current, drawable)
|
||||
backgroundDrawable.setDrawableByLayerId(R.id.background_next, ColorDrawable(Color.TRANSPARENT))
|
||||
backgroundDrawable.invalidateSelf()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the bakground to [activity].
|
||||
*/
|
||||
fun attach(activity: FragmentActivity) {
|
||||
// Set default background to current if it's not layered
|
||||
val current = activity.window.decorView.background
|
||||
windowBackground = if (current !is LayerDrawable) current.copy() else ColorDrawable(Color.BLACK)
|
||||
backgroundDrawable.setDrawableByLayerId(R.id.background_static, windowBackground)
|
||||
|
||||
// Store size of window manager for this activity
|
||||
windowSize = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(activity).let {
|
||||
Size(it.bounds.width(), it.bounds.height())
|
||||
}
|
||||
|
||||
// Add a fragment to the activity to automatically set the background on resume
|
||||
val fragment = activity.supportFragmentManager.findFragmentByTag(FRAGMENT_TAG)
|
||||
if (fragment == null) {
|
||||
// Add fragment
|
||||
Timber.i("Adding BackgroundServiceFragment to activity")
|
||||
|
||||
activity.supportFragmentManager
|
||||
.beginTransaction()
|
||||
.add<BackgroundServiceFragment>(FRAGMENT_TAG)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use all available backdrops from [baseItem] as background.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use the SDK instead of the legacy apiclient",
|
||||
ReplaceWith("setBackground(baseItem?.asSdk())", "org.jellyfin.androidtv.util.sdk.compat.asSdk")
|
||||
)
|
||||
fun setBackground(baseItem: LegacyBaseItemDto?) = setBackground(baseItem?.asSdk())
|
||||
|
||||
/**
|
||||
* Use all available backdrops from [baseItem] as background.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Use backdrop from [searchHint] as background.
|
||||
*/
|
||||
fun setBackground(searchHint: SearchHint) {
|
||||
// Check if item is set and backgrounds are enabled
|
||||
if (!userPreferences[UserPreferences.backdropEnabled])
|
||||
return clearBackgrounds()
|
||||
|
||||
// Manually grab the backdrop URL
|
||||
val backdropUrls = setOfNotNull(searchHint.backdropImageItemId?.let { itemId ->
|
||||
api.imageApi.getItemImageUrl(
|
||||
itemId = itemId.toUUID(),
|
||||
imageType = ImageType.BACKDROP,
|
||||
tag = searchHint.backdropImageTag,
|
||||
)
|
||||
})
|
||||
|
||||
loadBackgrounds(backdropUrls)
|
||||
}
|
||||
@@ -82,89 +195,111 @@ 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()
|
||||
|
||||
loadBackgrounds(setOf(splashscreenUrl))
|
||||
}
|
||||
|
||||
private fun loadBackgrounds(backdropUrls: Set<String>) {
|
||||
if (backdropUrls.isEmpty()) return clearBackgrounds()
|
||||
|
||||
// Re-enable backgrounds if disabled
|
||||
_enabled.value = true
|
||||
|
||||
// Cancel current loading job
|
||||
loadBackgroundsJob?.cancel()
|
||||
loadBackgroundsJob = scope.launch(Dispatchers.IO) {
|
||||
_backgrounds = backdropUrls.mapNotNull { url ->
|
||||
imageLoader.execute(
|
||||
request = ImageRequest.Builder(context).data(url).build()
|
||||
).image?.toBitmap()?.asImageBitmap()
|
||||
}
|
||||
val backdropDrawables = backdropUrls
|
||||
.map { url ->
|
||||
Glide.with(context)
|
||||
.load(url)
|
||||
.override(windowSize.width, windowSize.height)
|
||||
.centerCrop()
|
||||
.submit()
|
||||
}
|
||||
.map { future ->
|
||||
async {
|
||||
try {
|
||||
future.get()
|
||||
} catch (ex: ExecutionException) {
|
||||
Timber.e(ex, "There was an error fetching the background image.")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
.filterNotNull()
|
||||
.onEach { it.colorFilter = colorFilter }
|
||||
|
||||
// Go to first background
|
||||
_currentIndex = 0
|
||||
update()
|
||||
backgrounds.clear()
|
||||
backgrounds.addAll(backdropDrawables)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
// Go to first background
|
||||
currentIndex = 0
|
||||
update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearBackgrounds() {
|
||||
loadBackgroundsJob?.cancel()
|
||||
|
||||
// Re-enable backgrounds if disabled
|
||||
_enabled.value = true
|
||||
if (backgrounds.isEmpty()) return
|
||||
|
||||
if (_backgrounds.isEmpty()) return
|
||||
|
||||
_backgrounds = emptyList()
|
||||
backgrounds.clear()
|
||||
update()
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the showing of backgrounds until any function manipulating the backgrounds is called.
|
||||
*/
|
||||
fun disable() {
|
||||
_enabled.value = false
|
||||
}
|
||||
|
||||
@MainThread
|
||||
internal fun update() {
|
||||
val now = Instant.now().toEpochMilli()
|
||||
if (lastBackgroundTimerUpdate > now - TRANSITION_DURATION.inWholeMilliseconds)
|
||||
return setTimer((lastBackgroundTimerUpdate - now).milliseconds + TRANSITION_DURATION, false)
|
||||
val now = System.currentTimeMillis()
|
||||
if (lastBackgroundUpdate > now - UPDATE_INTERVAL)
|
||||
return setTimer(lastBackgroundUpdate - now + UPDATE_INTERVAL, false)
|
||||
|
||||
lastBackgroundTimerUpdate = now
|
||||
lastBackgroundUpdate = now
|
||||
|
||||
// Snapshot the current state if an animation is running and draw the new
|
||||
// background on top.
|
||||
if (backgroundAnimator.isRunning) {
|
||||
val current = backgroundDrawable
|
||||
.toBitmap(windowSize.width, windowSize.height)
|
||||
.toDrawable(context.resources)
|
||||
backgroundAnimator.end()
|
||||
backgroundDrawable.setDrawableByLayerId(R.id.background_current, current)
|
||||
}
|
||||
|
||||
// Get next background to show
|
||||
if (_currentIndex >= _backgrounds.size) _currentIndex = 0
|
||||
if (currentIndex >= backgrounds.size) currentIndex = 0
|
||||
|
||||
// Set background
|
||||
_currentBackground.value = _backgrounds.getOrNull(_currentIndex)
|
||||
backgroundDrawable.setDrawableByLayerId(
|
||||
R.id.background_next,
|
||||
backgrounds.getOrElse(currentIndex) { windowBackground.copy() }
|
||||
)
|
||||
|
||||
// Animate
|
||||
backgroundAnimator.start()
|
||||
|
||||
// Set timer for next background
|
||||
if (_backgrounds.size > 1) setTimer()
|
||||
if (backgrounds.size > 1) setTimer()
|
||||
else updateBackgroundTimerJob?.cancel()
|
||||
}
|
||||
|
||||
private fun setTimer(updateDelay: Duration = SLIDESHOW_DURATION, increaseIndex: Boolean = true) {
|
||||
private fun setTimer(updateDelay: Long = SLIDESHOW_DURATION, increaseIndex: Boolean = true) {
|
||||
updateBackgroundTimerJob?.cancel()
|
||||
updateBackgroundTimerJob = scope.launch {
|
||||
delay(updateDelay)
|
||||
|
||||
if (increaseIndex) _currentIndex++
|
||||
if (increaseIndex) currentIndex++
|
||||
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Drawable.copy() = constantState!!.newDrawable().mutate()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.jellyfin.androidtv.data.service
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.koin.android.ext.android.inject
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Fragment used to hook into the activity lifecycle and set the background when neccesary.
|
||||
*/
|
||||
internal class BackgroundServiceFragment : Fragment() {
|
||||
private val backgroundService by inject<BackgroundService>()
|
||||
private var backgrounds: Array<Drawable>? = null
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
if (backgrounds != null) {
|
||||
Timber.d("Restoring active backgrounds")
|
||||
|
||||
backgroundService.backgrounds.clear()
|
||||
backgroundService.backgrounds.addAll(backgrounds!!)
|
||||
backgroundService.update()
|
||||
}
|
||||
|
||||
activity?.window?.decorView?.apply {
|
||||
Timber.d("Restoring background drawable")
|
||||
|
||||
// We need to force the system to add a new callback
|
||||
// this won't happen if we set the background to the same one
|
||||
// so we set it to null first
|
||||
if (background == backgroundService.backgroundDrawable) background = null
|
||||
|
||||
background = backgroundService.backgroundDrawable
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
backgrounds = backgroundService.backgrounds.toTypedArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.jellyfin.androidtv.di
|
||||
|
||||
import android.app.Application
|
||||
import org.jellyfin.androidtv.ui.shared.AppThemeCallbacks
|
||||
import org.jellyfin.androidtv.ui.shared.AuthenticatedUserCallbacks
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
|
||||
val activityLifecycleCallbacksModule = module {
|
||||
single { AuthenticatedUserCallbacks(get()) } bind Application.ActivityLifecycleCallbacks::class
|
||||
single { AppThemeCallbacks(get()) } bind Application.ActivityLifecycleCallbacks::class
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.jellyfin.androidtv.di
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.app.UiModeManager
|
||||
import android.media.AudioManager
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.work.WorkManager
|
||||
import org.koin.android.ext.koin.androidApplication
|
||||
@@ -11,7 +11,7 @@ import org.koin.dsl.module
|
||||
* Provides DI for Android system components
|
||||
*/
|
||||
val androidModule = module {
|
||||
factory { androidApplication().getSystemService<AccountManager>()!! }
|
||||
factory { androidApplication().getSystemService<UiModeManager>()!! }
|
||||
factory { androidApplication().getSystemService<AudioManager>()!! }
|
||||
factory { WorkManager.getInstance(get()) }
|
||||
}
|
||||
|
||||
@@ -1,165 +1,100 @@
|
||||
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 org.jellyfin.androidtv.BuildConfig
|
||||
import org.jellyfin.androidtv.auth.repository.ServerRepository
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepositoryImpl
|
||||
import org.jellyfin.androidtv.data.eventhandling.SocketHandler
|
||||
import org.jellyfin.androidtv.data.model.DataRefreshService
|
||||
import org.jellyfin.androidtv.data.repository.CustomMessageRepository
|
||||
import org.jellyfin.androidtv.data.repository.CustomMessageRepositoryImpl
|
||||
import org.jellyfin.androidtv.data.repository.ExternalAppRepository
|
||||
import org.jellyfin.androidtv.data.repository.ItemMutationRepository
|
||||
import org.jellyfin.androidtv.data.repository.ItemMutationRepositoryImpl
|
||||
import org.jellyfin.androidtv.data.repository.NotificationsRepository
|
||||
import org.jellyfin.androidtv.data.repository.NotificationsRepositoryImpl
|
||||
import org.jellyfin.androidtv.data.repository.UserViewsRepository
|
||||
import org.jellyfin.androidtv.data.repository.UserViewsRepositoryImpl
|
||||
import org.jellyfin.androidtv.data.service.BackgroundService
|
||||
import org.jellyfin.androidtv.integration.dream.DreamViewModel
|
||||
import org.jellyfin.androidtv.ui.InteractionTrackerViewModel
|
||||
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.navigation.NavigationRepositoryImpl
|
||||
import org.jellyfin.androidtv.ui.playback.MediaManager
|
||||
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.interaction.ApiEventListener
|
||||
import org.jellyfin.apiclient.logging.AndroidLogger
|
||||
import org.jellyfin.apiclient.serialization.GsonJsonSerializer
|
||||
import org.jellyfin.sdk.android.androidDevice
|
||||
import org.jellyfin.sdk.api.client.HttpClientOptions
|
||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||
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 { SocketHandler(get(), get(), get(), get(), get()) }
|
||||
|
||||
// Old apiclient
|
||||
single { GsonJsonSerializer() }
|
||||
|
||||
// Coil (images)
|
||||
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(),
|
||||
eventListener = ApiEventListener()
|
||||
)
|
||||
}
|
||||
|
||||
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())
|
||||
else add(GifDecoder.Factory())
|
||||
add(SvgDecoder.Factory())
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
// Non API related
|
||||
single { MediaManager(get()) }
|
||||
|
||||
single { DataRefreshService() }
|
||||
single { PlaybackControllerContainer() }
|
||||
single { InteractionTrackerViewModel(get(), get()) }
|
||||
|
||||
single<UserRepository> { UserRepositoryImpl() }
|
||||
single<UserViewsRepository> { UserViewsRepositoryImpl(get()) }
|
||||
single<NotificationsRepository> { NotificationsRepositoryImpl(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 { SearchViewModel(get()) }
|
||||
viewModel { DreamViewModel(get(), get(), get(), get(), get()) }
|
||||
viewModel { SettingsViewModel() }
|
||||
viewModel { NextUpViewModel(get(), get(), get(), get()) }
|
||||
|
||||
single { BackgroundService(get(), get(), get(), get(), get()) }
|
||||
single { BackgroundService(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()) }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.jellyfin.androidtv.di
|
||||
|
||||
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
|
||||
@@ -8,25 +9,24 @@ import org.jellyfin.androidtv.auth.repository.ServerUserRepository
|
||||
import org.jellyfin.androidtv.auth.repository.ServerUserRepositoryImpl
|
||||
import org.jellyfin.androidtv.auth.repository.SessionRepository
|
||||
import org.jellyfin.androidtv.auth.repository.SessionRepositoryImpl
|
||||
import org.jellyfin.androidtv.auth.store.AccountManagerStore
|
||||
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
|
||||
import org.jellyfin.androidtv.auth.store.AuthenticationStore
|
||||
import org.koin.dsl.module
|
||||
|
||||
val authModule = module {
|
||||
single { AuthenticationStore(get()) }
|
||||
single { AccountManagerStore(get()) }
|
||||
single { AuthenticationPreferences(get()) }
|
||||
|
||||
single<AuthenticationRepository> {
|
||||
AuthenticationRepositoryImpl(get(), get(), get(), get(), get(), get(defaultDeviceInfo))
|
||||
AuthenticationRepositoryImpl(get(), get(), get(), get(), get(), get(), get(defaultDeviceInfo))
|
||||
}
|
||||
single<ServerRepository> { ServerRepositoryImpl(get(), get()) }
|
||||
single<ServerUserRepository> { ServerUserRepositoryImpl(get(), get()) }
|
||||
single<ServerUserRepository> { ServerUserRepositoryImpl(get(), get(), get()) }
|
||||
single<SessionRepository> {
|
||||
SessionRepositoryImpl(get(), get(), get(), get(), get(defaultDeviceInfo), get(), get(), get())
|
||||
SessionRepositoryImpl(get(), 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()) }
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ class KoinInitializer : Initializer<KoinApplication> {
|
||||
androidContext(context)
|
||||
|
||||
modules(
|
||||
activityLifecycleCallbacksModule,
|
||||
androidModule,
|
||||
appModule,
|
||||
authModule,
|
||||
|
||||
@@ -1,96 +1,22 @@
|
||||
package org.jellyfin.androidtv.di
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
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.R
|
||||
import org.jellyfin.androidtv.BuildConfig
|
||||
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.VideoQueueManager
|
||||
import org.jellyfin.androidtv.ui.playback.rewrite.RewriteMediaManager
|
||||
import org.jellyfin.androidtv.util.profile.createDeviceProfile
|
||||
import org.jellyfin.playback.core.playbackManager
|
||||
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.jellyfin.androidtv.ui.playback.GarbagePlaybackLauncher
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackManager
|
||||
import org.jellyfin.androidtv.ui.playback.RewritePlaybackLauncher
|
||||
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 { PlaybackLauncher(get(), get(), get(), get()) }
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
OkHttpDataSource.Factory(okHttpFactory.createClient(httpClientOptions))
|
||||
single {
|
||||
PlaybackManager(get())
|
||||
}
|
||||
|
||||
single { createPlaybackManager() }
|
||||
}
|
||||
factory {
|
||||
val preferences = get<UserPreferences>()
|
||||
val useRewrite = preferences[UserPreferences.playbackRewriteEnabled] && BuildConfig.DEVELOPMENT
|
||||
|
||||
fun Scope.createPlaybackManager() = playbackManager(androidContext()) {
|
||||
val activityIntent = Intent(get(), MainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(get(), 0, activityIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val notificationChannelId = "session"
|
||||
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)
|
||||
if (useRewrite) RewritePlaybackLauncher()
|
||||
else GarbagePlaybackLauncher(get())
|
||||
}
|
||||
|
||||
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(
|
||||
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>()
|
||||
defaultRewindAmount = { userSettingPreferences[UserSettingPreferences.skipBackLength].milliseconds }
|
||||
defaultFastForwardAmount = { userSettingPreferences[UserSettingPreferences.skipForwardLength].milliseconds }
|
||||
}
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package org.jellyfin.androidtv.integration
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
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
|
||||
@@ -23,37 +20,29 @@ 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.apiclient.model.entities.MediaType
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.exception.TimeoutException
|
||||
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.extensions.ticks
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
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.format.DateTimeFormatter
|
||||
import kotlin.time.Duration
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/**
|
||||
* Manages channels on the android tv home screen.
|
||||
@@ -65,13 +54,17 @@ class LeanbackChannelWorker(
|
||||
workerParams: WorkerParameters,
|
||||
) : CoroutineWorker(context, workerParams), KoinComponent {
|
||||
companion object {
|
||||
/**
|
||||
* Amount of ticks found in a millisecond, used for calculation.
|
||||
*/
|
||||
private const val TICKS_IN_MILLISECOND = 10000
|
||||
|
||||
const val PERIODIC_UPDATE_REQUEST_NAME = "LeanbackChannelPeriodicUpdateRequest"
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -93,76 +86,13 @@ class LeanbackChannelWorker(
|
||||
else -> try {
|
||||
// Get next up episodes
|
||||
val (resumeItems, nextUpItems) = getNextUpItems()
|
||||
// Get latest media
|
||||
val (latestEpisodes, latestMovies, latestMedia) = getLatestMedia()
|
||||
val myMedia = getMyMedia()
|
||||
// Delete current items from the channels
|
||||
|
||||
// Delete current items from the My Media and Next Up channels
|
||||
context.contentResolver.delete(TvContractCompat.PreviewPrograms.CONTENT_URI, null, null)
|
||||
|
||||
// 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(),
|
||||
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()
|
||||
)
|
||||
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()
|
||||
)
|
||||
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()
|
||||
)
|
||||
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()
|
||||
)
|
||||
val preferParentThumb = userPreferences[UserPreferences.seriesThumbnailsEnabled]
|
||||
|
||||
// Add new items
|
||||
arrayOf(
|
||||
nextUpItems to nextUpChannel,
|
||||
latestMedia to latestMediaChannel,
|
||||
latestMovies to latestMoviesChannel,
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update various channels
|
||||
updateMyMedia()
|
||||
updateNextUp(nextUpItems)
|
||||
updateWatchNext(resumeItems + nextUpItems)
|
||||
|
||||
// Success!
|
||||
@@ -183,46 +113,28 @@ 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(
|
||||
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))
|
||||
} else {
|
||||
// Create new channel and save uri
|
||||
context.contentResolver.insert(TvContractCompat.Channels.CONTENT_URI, settings.toContentValues())!!.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.drawable.app_icon, context.theme)?.let {
|
||||
ChannelLogoUtils.storeChannelLogo(context, ContentUris.parseId(uri), it.toBitmap(80.dp(context), 80.dp(context)))
|
||||
}
|
||||
|
||||
return uri
|
||||
@@ -232,26 +144,74 @@ class LeanbackChannelWorker(
|
||||
* Updates the "my media" row with current media libraries.
|
||||
*/
|
||||
@Suppress("RestrictedApi")
|
||||
private suspend fun getMyMedia(): List<BaseItemDto> {
|
||||
private suspend fun updateMyMedia() {
|
||||
// Get channel
|
||||
val channelUri = 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(), default = true)
|
||||
|
||||
val response by api.userViewsApi.getUserViews(includeHidden = false)
|
||||
|
||||
// Add new items
|
||||
return response.items
|
||||
val items = response.items
|
||||
.orEmpty()
|
||||
.filter { userViewsRepository.isSupported(it.collectionType) }
|
||||
.map { item ->
|
||||
val imageUri = if (item.imageTags?.contains(ImageType.PRIMARY) == true)
|
||||
api.imageApi.getItemImageUrl(item.id, ImageType.PRIMARY).toUri()
|
||||
else
|
||||
ImageUtils.getResourceUrl(context, R.drawable.tile_land_tv).toUri()
|
||||
|
||||
PreviewProgram.Builder()
|
||||
.setChannelId(ContentUris.parseId(channelUri))
|
||||
.setType(TvContractCompat.PreviewPrograms.TYPE_CHANNEL)
|
||||
.setTitle(item.name)
|
||||
.setPosterArtUri(imageUri)
|
||||
.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
|
||||
.setIntent(Intent(context, StartupActivity::class.java).apply {
|
||||
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
|
||||
putExtra(StartupActivity.EXTRA_ITEM_IS_USER_VIEW, true)
|
||||
})
|
||||
.build()
|
||||
.toContentValues()
|
||||
}.toTypedArray()
|
||||
context.contentResolver.bulkInsert(TvContractCompat.PreviewPrograms.CONTENT_URI, items)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the poster art for an item. Uses the [preferParentThumb] parameter to fetch the series
|
||||
* image when preferred.
|
||||
*/
|
||||
private fun BaseItemDto.getPosterArtImageUrl(
|
||||
preferParentThumb: Boolean
|
||||
): Uri = 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))
|
||||
private fun BaseItemDto.getPosterArtImageUrl(preferParentThumb: Boolean): Uri {
|
||||
if (type == BaseItemKind.MOVIE) {
|
||||
return api.imageApi.getItemImageUrl(
|
||||
itemId = id,
|
||||
imageType = ImageType.PRIMARY,
|
||||
format = ImageFormat.WEBP,
|
||||
width = 106.dp(context),
|
||||
height = 153.dp(context),
|
||||
).let(Uri::parse)
|
||||
}
|
||||
|
||||
return when {
|
||||
(preferParentThumb || imageTags?.contains(ImageType.PRIMARY) != true)
|
||||
&& parentThumbItemId != null -> api.imageApi.getItemImageUrl(
|
||||
itemId = parentThumbItemId!!,
|
||||
imageType = ImageType.THUMB,
|
||||
format = ImageFormat.WEBP,
|
||||
width = 272.dp(context),
|
||||
height = 153.dp(context),
|
||||
)
|
||||
else -> api.imageApi.getItemImageUrl(
|
||||
itemId = id,
|
||||
imageType = ImageType.PRIMARY,
|
||||
format = ImageFormat.WEBP,
|
||||
width = 272.dp(context),
|
||||
height = 153.dp(context),
|
||||
)
|
||||
}.let(Uri::parse)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,129 +219,73 @@ class LeanbackChannelWorker(
|
||||
* 1. resume items
|
||||
* 2. next up items
|
||||
*/
|
||||
private suspend fun getNextUpItems(): Pair<List<BaseItemDto>, List<BaseItemDto>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val resume = async {
|
||||
api.itemsApi.getResumeItems(
|
||||
fields = ItemRepository.itemFields,
|
||||
imageTypeLimit = 1,
|
||||
limit = 10,
|
||||
mediaTypes = listOf(MediaType.VIDEO),
|
||||
includeItemTypes = listOf(BaseItemKind.EPISODE, BaseItemKind.MOVIE),
|
||||
excludeActiveSessions = true,
|
||||
).content.items
|
||||
}
|
||||
|
||||
val nextUp = async {
|
||||
api.tvShowsApi.getNextUp(
|
||||
imageTypeLimit = 1,
|
||||
limit = 10,
|
||||
enableResumable = false,
|
||||
fields = ItemRepository.itemFields,
|
||||
).content.items
|
||||
}
|
||||
|
||||
// Concat
|
||||
Pair(resume.await(), nextUp.await())
|
||||
private suspend fun getNextUpItems(): Pair<List<BaseItemDto>, List<BaseItemDto>> = withContext(Dispatchers.IO) {
|
||||
val resume = async {
|
||||
api.itemsApi.getResumeItems(
|
||||
fields = listOf(ItemFields.DATE_CREATED),
|
||||
imageTypeLimit = 1,
|
||||
limit = 10,
|
||||
mediaTypes = listOf(MediaType.Video),
|
||||
includeItemTypes = listOf(BaseItemKind.EPISODE, BaseItemKind.MOVIE),
|
||||
excludeActiveSessions = true,
|
||||
).content.items.orEmpty()
|
||||
}
|
||||
|
||||
private suspend fun getLatestMedia(): Triple<List<BaseItemDto>, List<BaseItemDto>, List<BaseItemDto>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val latestEpisodes = async {
|
||||
api.userLibraryApi.getLatestMedia(
|
||||
fields = ItemRepository.itemFields,
|
||||
limit = 50,
|
||||
includeItemTypes = listOf(BaseItemKind.EPISODE),
|
||||
isPlayed = false
|
||||
).content
|
||||
}
|
||||
|
||||
val latestMovies = async {
|
||||
api.userLibraryApi.getLatestMedia(
|
||||
fields = ItemRepository.itemFields,
|
||||
limit = 50,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
||||
isPlayed = false
|
||||
).content
|
||||
}
|
||||
|
||||
val latestMedia = async {
|
||||
api.userLibraryApi.getLatestMedia(
|
||||
fields = ItemRepository.itemFields,
|
||||
limit = 50,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
isPlayed = false
|
||||
).content
|
||||
}
|
||||
|
||||
// Concat
|
||||
Triple(latestEpisodes.await(), latestMovies.await(), latestMedia.await())
|
||||
val nextup = async {
|
||||
api.tvShowsApi.getNextUp(
|
||||
userId = api.userId,
|
||||
imageTypeLimit = 1,
|
||||
limit = 10,
|
||||
fields = listOf(ItemFields.DATE_CREATED),
|
||||
).content.items.orEmpty()
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
private fun createPreviewProgram(
|
||||
channelUri: Uri,
|
||||
item: BaseItemDto,
|
||||
preferParentThumb: Boolean
|
||||
): ContentValues {
|
||||
val imageUri = item.getPosterArtImageUrl(preferParentThumb)
|
||||
val seasonString = item.parentIndexNumber?.toString().orEmpty()
|
||||
// Concat
|
||||
Pair(resume.await(), nextup.await())
|
||||
}
|
||||
|
||||
val episodeString = when {
|
||||
item.indexNumberEnd != null && item.indexNumber != null ->
|
||||
"${item.indexNumber}-${item.indexNumberEnd}"
|
||||
/**
|
||||
* Updates the "next up" row with current episodes. Uses the [nextUpItems] parameter to store
|
||||
* items returned by a NextUpQuery().
|
||||
*/
|
||||
@Suppress("RestrictedApi")
|
||||
private fun updateNextUp(nextUpItems: List<BaseItemDto>) {
|
||||
val preferParentThumb = userPreferences[UserPreferences.seriesThumbnailsEnabled]
|
||||
|
||||
else -> item.indexNumber?.toString().orEmpty()
|
||||
}
|
||||
// Get channel
|
||||
val channelUri = 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())
|
||||
|
||||
return PreviewProgram.Builder()
|
||||
.setChannelId(ContentUris.parseId(channelUri))
|
||||
.setType(
|
||||
when (item.type) {
|
||||
BaseItemKind.SERIES -> WatchNextPrograms.TYPE_TV_SERIES
|
||||
BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE
|
||||
BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE
|
||||
BaseItemKind.AUDIO -> WatchNextPrograms.TYPE_TRACK
|
||||
BaseItemKind.PLAYLIST -> WatchNextPrograms.TYPE_PLAYLIST
|
||||
else -> WatchNextPrograms.TYPE_CHANNEL
|
||||
}
|
||||
)
|
||||
.setTitle(item.seriesName ?: item.name)
|
||||
.setEpisodeTitle(if (item.type == BaseItemKind.EPISODE) item.name else null)
|
||||
.setDescription(item.overview?.stripHtml())
|
||||
.setReleaseDate(
|
||||
if (item.premiereDate != null) DateTimeFormatter.ISO_DATE.format(item.premiereDate)
|
||||
else null
|
||||
)
|
||||
.setPosterArtUri(imageUri)
|
||||
.setPosterArtAspectRatio(
|
||||
when (item.type) {
|
||||
BaseItemKind.COLLECTION_FOLDER,
|
||||
BaseItemKind.EPISODE -> TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9
|
||||
// Add new items
|
||||
nextUpItems.map { item ->
|
||||
val imageUri = item.getPosterArtImageUrl(preferParentThumb)
|
||||
|
||||
else -> TvContractCompat.PreviewPrograms.ASPECT_RATIO_MOVIE_POSTER
|
||||
}
|
||||
)
|
||||
.setIntent(Intent(context, StartupActivity::class.java).apply {
|
||||
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()
|
||||
val seasonString = item.parentIndexNumber?.toString().orEmpty()
|
||||
|
||||
val episodeString = when {
|
||||
item.indexNumberEnd != null && item.indexNumber != null ->
|
||||
"${item.indexNumber}-${item.indexNumberEnd}"
|
||||
else -> item.indexNumber?.toString().orEmpty()
|
||||
}
|
||||
|
||||
PreviewProgram.Builder()
|
||||
.setChannelId(ContentUris.parseId(channelUri))
|
||||
.setType(WatchNextPrograms.TYPE_TV_EPISODE)
|
||||
.setTitle(item.seriesName)
|
||||
.setEpisodeTitle(item.name)
|
||||
.setSeasonNumber(seasonString, item.parentIndexNumber ?: 0)
|
||||
.setEpisodeNumber(episodeString, item.indexNumber ?: 0)
|
||||
.setPosterArtUri(imageUri)
|
||||
.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
|
||||
.setIntent(Intent(context, StartupActivity::class.java).apply {
|
||||
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
|
||||
})
|
||||
.build()
|
||||
.toContentValues()
|
||||
}.let { context.contentResolver.bulkInsert(TvContractCompat.PreviewPrograms.CONTENT_URI, it.toTypedArray()) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,127 +293,70 @@ 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()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert [BaseItemDto] to [WatchNextProgram]. Assumes the item type is "episode".
|
||||
*/
|
||||
@Suppress("RestrictedApi")
|
||||
private fun getBaseItemAsWatchNextProgram(item: BaseItemDto) =
|
||||
WatchNextProgram.Builder().apply {
|
||||
val preferParentThumb = userPreferences[UserPreferences.seriesThumbnailsEnabled]
|
||||
private fun getBaseItemAsWatchNextProgram(item: BaseItemDto) = WatchNextProgram.Builder().apply {
|
||||
val preferParentThumb = userPreferences[UserPreferences.seriesThumbnailsEnabled]
|
||||
|
||||
setInternalProviderId(item.id.toString())
|
||||
setInternalProviderId(item.id.toString())
|
||||
|
||||
// Poster size & type
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
setType(WatchNextPrograms.TYPE_TV_EPISODE)
|
||||
setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_16_9)
|
||||
} else if (item.type == BaseItemKind.MOVIE) {
|
||||
setType(WatchNextPrograms.TYPE_MOVIE)
|
||||
setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_MOVIE_POSTER)
|
||||
// Poster size & type
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
setType(WatchNextPrograms.TYPE_TV_EPISODE)
|
||||
setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_16_9)
|
||||
} else if (item.type == BaseItemKind.MOVIE) {
|
||||
setType(WatchNextPrograms.TYPE_MOVIE)
|
||||
setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_MOVIE_POSTER)
|
||||
}
|
||||
|
||||
// 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_IN_MILLISECOND).toInt())
|
||||
// Use last played date to prioritze
|
||||
engagement = item.userData?.lastPlayedDate
|
||||
}
|
||||
// First episode of the season
|
||||
item.indexNumber == 1 -> setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEW)
|
||||
// Default
|
||||
else -> setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
|
||||
}
|
||||
|
||||
// Name and episode details
|
||||
if (item.seriesName != null) {
|
||||
setTitle(item.seriesName)
|
||||
setEpisodeTitle(item.name)
|
||||
setLastEngagementTimeUtcMillis(engagement?.toInstant(ZoneOffset.UTC)?.toEpochMilli()
|
||||
?: System.currentTimeMillis())
|
||||
|
||||
item.indexNumber?.takeIf { it > 0 }?.let { setEpisodeNumber(it) }
|
||||
item.parentIndexNumber?.takeIf { it > 0 }?.let { setSeasonNumber(it) }
|
||||
} else {
|
||||
setTitle(item.name)
|
||||
}
|
||||
// Episode runtime has been determined
|
||||
item.runTimeTicks?.let { runTimeTicks ->
|
||||
setDurationMillis((runTimeTicks / TICKS_IN_MILLISECOND).toInt())
|
||||
}
|
||||
|
||||
setDescription(item.overview?.stripHtml())
|
||||
|
||||
// Poster
|
||||
setPosterArtUri(item.getPosterArtImageUrl(preferParentThumb))
|
||||
|
||||
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()
|
||||
)
|
||||
}
|
||||
// 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()
|
||||
)
|
||||
}
|
||||
// Default
|
||||
else -> {
|
||||
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
|
||||
setLastEngagementTimeUtcMillis(Instant.now().toEpochMilli())
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime has been determined
|
||||
item.runTimeTicks?.ticks?.let { setDurationMillis(it.inWholeMilliseconds.toInt()) }
|
||||
|
||||
// Set intent to open the episode
|
||||
setIntent(Intent(context, StartupActivity::class.java).apply {
|
||||
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
|
||||
})
|
||||
}.build()
|
||||
// Set intent to open the episode
|
||||
setIntent(Intent(context, StartupActivity::class.java).apply {
|
||||
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
|
||||
})
|
||||
}.build()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user