Compare commits
13 Commits
master
...
release-0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a233314652 | ||
|
|
ec467db7ed | ||
|
|
3a49573c64 | ||
|
|
5346ee6c37 | ||
|
|
2df9c4c0e3 | ||
|
|
429b6601d6 | ||
|
|
4ebc3e1f50 | ||
|
|
9a41127a4d | ||
|
|
4010c2e839 | ||
|
|
d8af891489 | ||
|
|
b4753ebeae | ||
|
|
e300dd4187 | ||
|
|
f7dee09fd3 |
@@ -2,17 +2,8 @@ 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
|
||||
|
||||
[*.md]
|
||||
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
|
||||
|
||||
143
.github/ISSUE_TEMPLATE/bug-report-playback.yaml
vendored
143
.github/ISSUE_TEMPLATE/bug-report-playback.yaml
vendored
@@ -1,143 +0,0 @@
|
||||
name: Media playback bug report
|
||||
description: Create a bug report related to media playback
|
||||
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:
|
||||
label: Describe the bug
|
||||
description: |
|
||||
A clear and concise description of the bug, including steps to reproduce it and the normally expected behavior.
|
||||
You can also attach screenshots or screen recordings to help explain your issue.
|
||||
placeholder: |
|
||||
1. Go to …
|
||||
2. Click on …
|
||||
3. Scroll down to …
|
||||
4. See error / the app crashes
|
||||
|
||||
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
|
||||
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".
|
||||
Make sure that they don't contain any sensitive information like server URL, auth tokens or passwords.
|
||||
placeholder: Paste logs…
|
||||
- type: textarea
|
||||
id: logs-ffmpeg
|
||||
attributes:
|
||||
label: FFmpeg logs
|
||||
description: |
|
||||
Please paste your FFmpeg logs here. You can find these in your servers dashboard under "logs".
|
||||
placeholder: Paste logs…
|
||||
render: shell
|
||||
- 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
|
||||
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)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: android-version
|
||||
attributes:
|
||||
label: Android version
|
||||
description: Version of the OS and other information (e.g. custom ROM / OEM skin)
|
||||
placeholder: Android 13, Fire OS 7
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: server-version
|
||||
attributes:
|
||||
label: Jellyfin server version
|
||||
description: If on unstable, please specify the commit hash.
|
||||
placeholder: 10.10.2
|
||||
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
|
||||
106
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
106
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -1,106 +0,0 @@
|
||||
name: Bug report
|
||||
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:
|
||||
label: Describe the bug
|
||||
description: |
|
||||
A clear and concise description of the bug, including steps to reproduce it and the normally expected behavior.
|
||||
You can also attach screenshots or screen recordings to help explain your issue.
|
||||
placeholder: |
|
||||
1. Go to …
|
||||
2. Click on …
|
||||
3. Scroll down to …
|
||||
4. See error / the app crashes
|
||||
|
||||
Instead, I expect …
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
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".
|
||||
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
|
||||
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)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: android-version
|
||||
attributes:
|
||||
label: Android version
|
||||
description: Version of the OS and other information (e.g. custom ROM / OEM skin)
|
||||
placeholder: Android 13, Fire OS 7
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: server-version
|
||||
attributes:
|
||||
label: Jellyfin server version
|
||||
description: If on unstable, please specify the commit hash.
|
||||
placeholder: 10.10.2
|
||||
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
|
||||
36
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
36
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a bug report
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
<!-- A clear and concise description of what the bug is. -->
|
||||
|
||||
**To Reproduce**
|
||||
<!-- Steps to reproduce the behavior: -->
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
<!-- A clear and concise description of what you expected to happen. -->
|
||||
|
||||
**Logs**
|
||||
<!-- Please paste any log errors. -->
|
||||
|
||||
**Screenshots**
|
||||
<!-- If applicable, add screenshots to help explain your problem. -->
|
||||
|
||||
**System (please complete the following information):**
|
||||
- Android TV or Fire TV version: [e.g. Android 9 or Fire OS 7]
|
||||
- Device manufacturer: [e.g. Amazon, Nvidia, Sony]
|
||||
- Device model: [e.g. Fire TV Stick (2nd Gen) or Shield TV]
|
||||
- Jellyfin server version: [e.g. 10.5.4]
|
||||
|
||||
**Additional context**
|
||||
<!-- Add any other context about the problem here. -->
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,4 +1,4 @@
|
||||
blank_issues_enabled: false
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Jellyfin documentation
|
||||
url: https://jellyfin.org/docs/general/getting-help.html
|
||||
|
||||
56
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
56
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
@@ -1,56 +0,0 @@
|
||||
name: Feature request
|
||||
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
|
||||
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.
|
||||
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
|
||||
14
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
14
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Request a new feature
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the feature you'd like**
|
||||
<!-- A clear and concise description of what you want to happen. -->
|
||||
|
||||
**Additional context**
|
||||
<!-- Add any other context or screenshots about the feature request here. -->
|
||||
29
.github/ISSUE_TEMPLATE/media_playback.md
vendored
Normal file
29
.github/ISSUE_TEMPLATE/media_playback.md
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: Media playback issue
|
||||
about: Create a media playback issue report
|
||||
title: ''
|
||||
labels: playback
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Checks**
|
||||
- [ ] Issue does not happen in the browser (jellyfin-web)
|
||||
|
||||
**Media Info of the file**
|
||||
<!-- Use the Media Info tool (set to text format, download here: https://mediaarea.net/en/MediaInfo) or copy the info from the web ui for the file with the playback issue. -->
|
||||
|
||||
**Logs**
|
||||
<!-- Please paste any log messages from during the playback issue. -->
|
||||
|
||||
**FFmpeg Logs**
|
||||
<!-- Please paste any FFmpeg logs if remuxing or transcoding appears to be part of the issue. -->
|
||||
|
||||
**System (please complete the following information):**
|
||||
- Android TV or Fire TV version: [e.g. Android 9 or Fire OS 7]
|
||||
- Device manufacturer: [e.g. Amazon, Nvidia, Sony]
|
||||
- Device model: [e.g. Fire TV Stick (2nd Gen) or Shield TV]
|
||||
- Jellyfin server version: [e.g. 10.5.4]
|
||||
|
||||
**Additional context**
|
||||
<!-- Add any other context about the problem here. -->
|
||||
10
.github/dependabot.yml
vendored
Normal file
10
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
labels:
|
||||
- ci
|
||||
- github-actions
|
||||
- dependencies
|
||||
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 # -->
|
||||
|
||||
19
.github/stale.yml
vendored
Normal file
19
.github/stale.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 120
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 21
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- enhancement
|
||||
- confirmed
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: stale
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
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).
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
28
.github/workflows/app-build.yaml
vendored
28
.github/workflows/app-build.yaml
vendored
@@ -1,37 +1,29 @@
|
||||
name: App / Build
|
||||
name: App Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- release-*
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
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@v2
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@v2
|
||||
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@v2
|
||||
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:
|
||||
@@ -7,28 +7,22 @@ on:
|
||||
- release-*
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v2
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@v2
|
||||
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@v1
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
sarif_file: .
|
||||
|
||||
77
.github/workflows/app-publish.yaml
vendored
77
.github/workflows/app-publish.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: App / Publish
|
||||
name: App Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -6,83 +6,62 @@ 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@v2
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@v2
|
||||
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
|
||||
- name: Sign APK
|
||||
id: signApk
|
||||
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # tag=v1
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: "34.0.0"
|
||||
run: ./gradlew --no-daemon --info assemble versionTxt
|
||||
- name: Sign proprietary APK
|
||||
id: sign
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
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
|
||||
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 }}
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
- name: Prepare release archive
|
||||
run: |
|
||||
mkdir -p build/jellyfin-publish
|
||||
mv app/build/outputs/apk/*/jellyfin-androidtv-*-debug.apk build/jellyfin-publish/
|
||||
mv app/build/outputs/apk/*/jellyfin-androidtv-*-release-unsigned.apk build/jellyfin-publish/
|
||||
mv ${{ steps.signApk.outputs.signedReleaseFile }} build/jellyfin-publish/jellyfin-androidtv-v${{ env.JELLYFIN_VERSION }}-release.apk
|
||||
mv ${{ steps.signAab.outputs.signedReleaseFile }} build/jellyfin-publish/jellyfin-androidtv-v${{ env.JELLYFIN_VERSION }}-release.aab
|
||||
mv ${{ steps.sign.outputs.signedReleaseFile }} build/jellyfin-publish/jellyfin-androidtv-v${{ env.JELLYFIN_VERSION }}-release.apk
|
||||
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@0.3.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
asset_paths: '["build/jellyfin-publish/*"]'
|
||||
- name: Upload release archive to repo.jellyfin.org
|
||||
uses: burnett01/rsync-deployments@0dc935cdecc5f5e571865e60d2a6cdc673704823 # tag=5.2
|
||||
uses: burnett01/rsync-deployments@5.1
|
||||
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@v0.1.4
|
||||
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 .;
|
||||
|
||||
21
.github/workflows/app-test.yaml
vendored
21
.github/workflows/app-test.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: App / Test
|
||||
name: App Test
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -7,22 +7,17 @@ on:
|
||||
- release-*
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
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@v2
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@v2
|
||||
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-latest
|
||||
if: github.repository == 'jellyfin/jellyfin-androidtv'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: Update Gradle Wrapper
|
||||
uses: gradle-update/update-gradle-wrapper-action@v1
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
12
.github/workflows/gradlew-validate.yaml
vendored
12
.github/workflows/gradlew-validate.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Gradle / Validate wrapper
|
||||
name: Gradle validate
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -8,15 +8,11 @@ on:
|
||||
paths:
|
||||
- '**/gradlе-wrapper.jar'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v2
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
|
||||
uses: gradle/wrapper-validation-action@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@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 }}
|
||||
32
.github/workflows/repo-stale.yaml
vendored
32
.github/workflows/repo-stale.yaml
vendored
@@ -1,32 +0,0 @@
|
||||
name: Repo / Reply stale issue
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 3 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
name: Triage
|
||||
runs-on: ubuntu-24.04
|
||||
if: ${{ contains(github.repository_owner, 'jellyfin') }}
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
days-before-stale: 120
|
||||
days-before-pr-stale: -1
|
||||
days-before-close: 21
|
||||
days-before-pr-close: -1
|
||||
exempt-issue-labels: enhancement,confirmed
|
||||
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 you 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,29 @@
|
||||
plugins {
|
||||
alias(libs.plugins.aboutlibraries)
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
id("com.android.application")
|
||||
kotlin("android")
|
||||
kotlin("plugin.serialization") version Plugins.Versions.kotlin
|
||||
kotlin("kapt")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.jellyfin.androidtv"
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||
compileSdkVersion(30)
|
||||
|
||||
defaultConfig {
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
// Android version targets
|
||||
minSdkVersion(21)
|
||||
targetSdkVersion(30)
|
||||
|
||||
// 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,13 +31,11 @@ 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!!)
|
||||
resValue("string", "app_search_suggest_authority", "${namespace}.content")
|
||||
resValue("string", "app_search_suggest_intent_data", "content://${namespace}.content/intent")
|
||||
// Add applicationId as string for XML resources
|
||||
resValue("string", "app_id", "org.jellyfin.androidtv")
|
||||
|
||||
// Set flavored application name
|
||||
resValue("string", "app_name", "@string/app_name_release")
|
||||
@@ -45,14 +43,12 @@ 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"
|
||||
|
||||
// Set package names used in various XML files
|
||||
resValue("string", "app_id", namespace + applicationIdSuffix)
|
||||
resValue("string", "app_search_suggest_authority", "${namespace + applicationIdSuffix}.content")
|
||||
resValue("string", "app_search_suggest_intent_data", "content://${namespace + applicationIdSuffix}.content/intent")
|
||||
// Add applicationId as string for XML resources
|
||||
resValue("string", "app_id", "org.jellyfin.androidtv.debug")
|
||||
|
||||
// Set flavored application name
|
||||
resValue("string", "app_name", "@string/app_name_debug")
|
||||
@@ -61,22 +57,15 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
lintOptions {
|
||||
lintConfig = file("$rootDir/android-lint.xml")
|
||||
abortOnError = false
|
||||
isAbortOnError = false
|
||||
sarifReport = true
|
||||
checkDependencies = true
|
||||
}
|
||||
|
||||
testOptions.unitTests.all {
|
||||
it.useJUnitPlatform()
|
||||
}
|
||||
}
|
||||
|
||||
base.archivesName.set("jellyfin-androidtv-v${project.getVersionName()}")
|
||||
|
||||
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 +76,8 @@ 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.preference)
|
||||
implementation(projects.playback)
|
||||
implementation(libs.jellyfin.apiclient)
|
||||
implementation(libs.jellyfin.sdk) {
|
||||
// Change version if desired
|
||||
val sdkVersion = findProperty("sdk.version")?.toString()
|
||||
@@ -110,12 +95,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)
|
||||
@@ -125,42 +107,37 @@ dependencies {
|
||||
implementation(libs.bundles.androidx.lifecycle)
|
||||
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)
|
||||
|
||||
// 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)
|
||||
// GSON
|
||||
implementation(libs.gson)
|
||||
|
||||
// Markdown
|
||||
implementation(libs.bundles.markwon)
|
||||
// Media players
|
||||
implementation(libs.exoplayer)
|
||||
implementation(libs.libvlc)
|
||||
|
||||
// Image utility
|
||||
implementation(libs.bundles.coil)
|
||||
implementation(libs.glide.core)
|
||||
kapt(libs.glide.compiler)
|
||||
implementation(libs.kenburnsview)
|
||||
|
||||
// Crash Reporting
|
||||
implementation(libs.bundles.acra)
|
||||
|
||||
// Licenses
|
||||
implementation(libs.aboutlibraries)
|
||||
|
||||
// Logging
|
||||
implementation(libs.timber)
|
||||
implementation(libs.slf4j.timber)
|
||||
implementation(libs.slf4j.android)
|
||||
|
||||
// Debugging
|
||||
if (getProperty("leakcanary.enable")?.toBoolean() == true)
|
||||
debugImplementation(libs.leakcanary)
|
||||
|
||||
// Compatibility (desugaring)
|
||||
coreLibraryDesugaring(libs.android.desugar)
|
||||
|
||||
// Testing
|
||||
testImplementation(libs.kotest.runner.junit5)
|
||||
testImplementation(libs.kotest.assertions)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockito)
|
||||
}
|
||||
|
||||
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,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="auto">
|
||||
package="org.jellyfin.androidtv">
|
||||
|
||||
<!-- 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.ACCESS_WIFI_STATE" />
|
||||
<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,106 +35,140 @@
|
||||
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:dataExtractionRules="@xml/backup_rules"
|
||||
android:banner="@drawable/app_banner"
|
||||
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">
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:ignore="GoogleAppIndexingWarning">
|
||||
|
||||
<!-- Screensaver -->
|
||||
<service
|
||||
android:name=".integration.dream.LibraryDreamService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_DREAM_SERVICE">
|
||||
|
||||
android:name=".auth.AuthenticatorService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.dreams.DreamService" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<action android:name="android.accounts.AccountAuthenticator" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/authenticator" />
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
android:initOrder="1000"
|
||||
tools:node="merge">
|
||||
<meta-data
|
||||
android:name="org.jellyfin.androidtv.LogInitializer"
|
||||
android:value="androidx.startup" />
|
||||
<meta-data
|
||||
android:name="org.jellyfin.androidtv.di.KoinInitializer"
|
||||
android:value="androidx.startup" />
|
||||
<meta-data
|
||||
android:name="org.jellyfin.androidtv.SessionInitializer"
|
||||
android:value="androidx.startup" />
|
||||
</provider>
|
||||
<receiver android:name=".util.RemoteControlReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name=".integration.MediaContentProvider"
|
||||
android:authorities="${applicationId}.content"
|
||||
android:exported="true"
|
||||
android:initOrder="10" />
|
||||
|
||||
<provider
|
||||
android:name=".integration.provider.ImageProvider"
|
||||
android:authorities="${applicationId}.integration.provider.ImageProvider"
|
||||
android:exported="true"
|
||||
android:initOrder="10" />
|
||||
|
||||
<!-- Authentication -->
|
||||
<activity
|
||||
android:name=".ui.browsing.MainActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
<activity
|
||||
android:name=".ui.startup.StartupActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:noHistory="true"
|
||||
android:windowSoftInputMode="adjustNothing">
|
||||
android:screenOrientation="landscape"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<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" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
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.ByLetterActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.browsing.ByGenreActivity"
|
||||
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.browsing.BrowsePersonsActivity"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.search.SearchActivity"
|
||||
android:screenOrientation="landscape">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEARCH" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,64 +1,134 @@
|
||||
package org.jellyfin.androidtv
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.await
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleObserver
|
||||
import androidx.lifecycle.OnLifecycleEvent
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.work.*
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.acra.ACRA
|
||||
import org.jellyfin.androidtv.data.eventhandling.SocketHandler
|
||||
import org.jellyfin.androidtv.data.repository.NotificationsRepository
|
||||
import org.acra.config.dialog
|
||||
import org.acra.config.httpSender
|
||||
import org.acra.config.limiter
|
||||
import org.acra.data.StringFormat
|
||||
import org.acra.ktx.initAcra
|
||||
import org.acra.sender.HttpSender
|
||||
import org.jellyfin.androidtv.auth.SessionRepository
|
||||
import org.jellyfin.androidtv.di.*
|
||||
import org.jellyfin.androidtv.integration.LeanbackChannelWorker
|
||||
import org.jellyfin.androidtv.telemetry.TelemetryService
|
||||
import org.jellyfin.androidtv.util.AutoBitrate
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.android.getKoin
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.KoinExperimentalAPI
|
||||
import org.koin.core.context.startKoin
|
||||
import timber.log.Timber
|
||||
import timber.log.Timber.DebugTree
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Suppress("unused")
|
||||
class JellyfinApplication : Application() {
|
||||
class JellyfinApplication : TvApp(), LifecycleObserver {
|
||||
@OptIn(KoinExperimentalAPI::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Don't run in ACRA service
|
||||
if (ACRA.isACRASenderServiceProcess()) return
|
||||
// Enable improved logging for leaking resources
|
||||
// https://wh0.github.io/2020/08/12/closeguard.html
|
||||
if (BuildConfig.DEBUG) {
|
||||
try {
|
||||
Class.forName("dalvik.system.CloseGuard")
|
||||
.getMethod("setEnabled", Boolean::class.javaPrimitiveType)
|
||||
.invoke(null, true)
|
||||
} catch (e: ReflectiveOperationException) {
|
||||
@Suppress("TooGenericExceptionThrown")
|
||||
throw RuntimeException(e)
|
||||
}
|
||||
}
|
||||
|
||||
val notificationsRepository by inject<NotificationsRepository>()
|
||||
notificationsRepository.addDefaultNotifications()
|
||||
// Initialize the logging library
|
||||
Timber.plant(DebugTree())
|
||||
Timber.i("Application object created")
|
||||
|
||||
// Dependency Injection
|
||||
startKoin {
|
||||
androidContext(this@JellyfinApplication)
|
||||
|
||||
modules(
|
||||
appModule,
|
||||
authModule,
|
||||
activityLifecycleCallbacksModule,
|
||||
playbackModule,
|
||||
preferenceModule,
|
||||
utilsModule
|
||||
)
|
||||
}
|
||||
|
||||
// Register lifecycle callbacks
|
||||
getKoin().getAll<ActivityLifecycleCallbacks>().forEach(::registerActivityLifecycleCallbacks)
|
||||
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
|
||||
// Restore system session
|
||||
get<SessionRepository>().restoreDefaultSystemSession()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the Process Lifecycle when the app is activated in the foreground (activity opened).
|
||||
*/
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_START)
|
||||
fun onActivate() {
|
||||
Timber.i("Process Lifecycle started")
|
||||
|
||||
get<SessionRepository>().restoreDefaultSession()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the StartupActivity when the user session is started.
|
||||
*/
|
||||
suspend fun onSessionStart() = withContext(Dispatchers.IO) {
|
||||
@DelicateCoroutinesApi
|
||||
suspend fun onSessionStart() {
|
||||
val workManager by inject<WorkManager>()
|
||||
val socketListener by inject<SocketHandler>()
|
||||
val autoBitrate by inject<AutoBitrate>()
|
||||
|
||||
// Update background worker
|
||||
launch {
|
||||
// Cancel all current workers
|
||||
workManager.cancelAllWork().await()
|
||||
// Cancel all current workers
|
||||
workManager.cancelAllWork().await()
|
||||
|
||||
// Recreate periodic workers
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
LeanbackChannelWorker.PERIODIC_UPDATE_REQUEST_NAME,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
PeriodicWorkRequestBuilder<LeanbackChannelWorker>(1, TimeUnit.HOURS)
|
||||
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES)
|
||||
.build()
|
||||
).await()
|
||||
}
|
||||
// Recreate periodic workers
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
LeanbackChannelWorker.PERIODIC_UPDATE_REQUEST_NAME,
|
||||
ExistingPeriodicWorkPolicy.REPLACE,
|
||||
PeriodicWorkRequestBuilder<LeanbackChannelWorker>(1, TimeUnit.HOURS)
|
||||
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES)
|
||||
.build()
|
||||
).await()
|
||||
|
||||
// Update WebSockets
|
||||
launch { socketListener.updateSession() }
|
||||
// Detect auto bitrate
|
||||
GlobalScope.launch(Dispatchers.IO) { autoBitrate.detect() }
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
|
||||
TelemetryService.init(this)
|
||||
initAcra {
|
||||
buildConfigClass = BuildConfig::class.java
|
||||
reportFormat = StringFormat.JSON
|
||||
|
||||
httpSender {
|
||||
uri = "https://collector.tracepot.com/a2eda9d9"
|
||||
httpMethod = HttpSender.Method.POST
|
||||
}
|
||||
|
||||
dialog {
|
||||
withResTitle(R.string.acra_dialog_title)
|
||||
withResText(R.string.acra_dialog_text)
|
||||
withResTheme(R.style.Theme_Jellyfin)
|
||||
}
|
||||
|
||||
limiter {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package org.jellyfin.androidtv
|
||||
|
||||
import android.content.Context
|
||||
import androidx.startup.Initializer
|
||||
import timber.log.Timber
|
||||
|
||||
class LogInitializer : Initializer<Unit> {
|
||||
override fun create(context: Context) {
|
||||
// Enable improved logging for leaking resources
|
||||
// https://wh0.github.io/2020/08/12/closeguard.html
|
||||
if (BuildConfig.DEBUG) {
|
||||
try {
|
||||
Class.forName("dalvik.system.CloseGuard")
|
||||
.getMethod("setEnabled", Boolean::class.javaPrimitiveType)
|
||||
.invoke(null, true)
|
||||
} catch (e: ReflectiveOperationException) {
|
||||
@Suppress("TooGenericExceptionThrown")
|
||||
throw RuntimeException(e)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the logging library
|
||||
Timber.plant(Timber.DebugTree())
|
||||
Timber.i("Debug tree planted")
|
||||
}
|
||||
|
||||
override fun dependencies() = emptyList<Class<out Initializer<*>>>()
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package org.jellyfin.androidtv
|
||||
|
||||
import android.content.Context
|
||||
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
|
||||
|
||||
@Suppress("unused")
|
||||
class SessionInitializer : Initializer<Unit> {
|
||||
override fun create(context: Context) {
|
||||
val koin = AppInitializer.getInstance(context)
|
||||
.initializeComponent(KoinInitializer::class.java)
|
||||
.koin
|
||||
|
||||
// Restore system session
|
||||
ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.IO) {
|
||||
koin.get<SessionRepository>().restoreSession(destroyOnly = false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun dependencies() = listOf(KoinInitializer::class.java)
|
||||
}
|
||||
171
app/src/main/java/org/jellyfin/androidtv/TvApp.java
Normal file
171
app/src/main/java/org/jellyfin/androidtv/TvApp.java
Normal file
@@ -0,0 +1,171 @@
|
||||
package org.jellyfin.androidtv;
|
||||
|
||||
import static org.koin.java.KoinJavaComponent.inject;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Application;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MediatorLiveData;
|
||||
|
||||
import org.jellyfin.androidtv.ui.livetv.TvManager;
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackController;
|
||||
import org.jellyfin.apiclient.interaction.ApiClient;
|
||||
import org.jellyfin.apiclient.interaction.EmptyResponse;
|
||||
import org.jellyfin.apiclient.interaction.Response;
|
||||
import org.jellyfin.apiclient.model.dto.BaseItemDto;
|
||||
import org.jellyfin.apiclient.model.dto.UserDto;
|
||||
import org.jellyfin.apiclient.model.entities.DisplayPreferences;
|
||||
|
||||
import java.util.HashMap;
|
||||
|
||||
import kotlin.Lazy;
|
||||
import timber.log.Timber;
|
||||
|
||||
public class TvApp extends Application {
|
||||
public static final String DISPLAY_PREFS_APP_NAME = "ATV";
|
||||
|
||||
public static final int LIVE_TV_GUIDE_OPTION_ID = 1000;
|
||||
public static final int LIVE_TV_RECORDINGS_OPTION_ID = 2000;
|
||||
public static final int VIDEO_QUEUE_OPTION_ID = 3000;
|
||||
public static final int LIVE_TV_SCHEDULE_OPTION_ID = 4000;
|
||||
public static final int LIVE_TV_SERIES_OPTION_ID = 5000;
|
||||
|
||||
private static TvApp app;
|
||||
private MediatorLiveData<UserDto> currentUser = new MediatorLiveData<UserDto>();
|
||||
private BaseItemDto lastPlayedItem;
|
||||
private PlaybackController playbackController;
|
||||
|
||||
private HashMap<String, DisplayPreferences> displayPrefsCache = new HashMap<>();
|
||||
|
||||
private Activity currentActivity;
|
||||
|
||||
private Lazy<ApiClient> apiClient = inject(ApiClient.class);
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
app = this;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static TvApp getApplication() {
|
||||
return app;
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
@Nullable
|
||||
public UserDto getCurrentUser() {
|
||||
return currentUser.getValue();
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public LiveData<UserDto> getCurrentUserLiveData() {
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public void setCurrentUser(UserDto currentUser) {
|
||||
this.currentUser.postValue(currentUser);
|
||||
TvManager.clearCache();
|
||||
this.displayPrefsCache = new HashMap<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated This function is causing a **lot** of issues because not all activities will set their self as "currentactivity". Try to receive a Context instance instead.
|
||||
*/
|
||||
@Deprecated
|
||||
@Nullable
|
||||
public Activity getCurrentActivity() {
|
||||
return currentActivity;
|
||||
}
|
||||
|
||||
public void setCurrentActivity(Activity activity) {
|
||||
currentActivity = activity;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public PlaybackController getPlaybackController() {
|
||||
return playbackController;
|
||||
}
|
||||
|
||||
public void setPlaybackController(PlaybackController playbackController) {
|
||||
this.playbackController = playbackController;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public boolean canManageRecordings() {
|
||||
UserDto currentUser = getCurrentUser();
|
||||
return currentUser != null && currentUser.getPolicy().getEnableLiveTvManagement();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public DisplayPreferences getCachedDisplayPrefs(String key) {
|
||||
return getCachedDisplayPrefs(key, DISPLAY_PREFS_APP_NAME);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public DisplayPreferences getCachedDisplayPrefs(String key, String app) {
|
||||
String cacheKey = String.format("%s.%s", app, key);
|
||||
return displayPrefsCache.containsKey(cacheKey) ? displayPrefsCache.get(cacheKey) : new DisplayPreferences();
|
||||
}
|
||||
|
||||
public void updateDisplayPrefs(DisplayPreferences preferences) {
|
||||
updateDisplayPrefs("ATV", preferences);
|
||||
}
|
||||
|
||||
public void updateDisplayPrefs(String app, DisplayPreferences preferences) {
|
||||
displayPrefsCache.put(String.format("%s.%s", app, preferences.getId()), preferences);
|
||||
apiClient.getValue().UpdateDisplayPreferencesAsync(preferences, getCurrentUser().getId(), app, new EmptyResponse());
|
||||
Timber.d("Display prefs updated for %s isFavorite: %s", preferences.getId(), preferences.getCustomPrefs().get("FavoriteOnly"));
|
||||
}
|
||||
|
||||
public void getDisplayPrefsAsync(String key, Response<DisplayPreferences> response) {
|
||||
getDisplayPrefsAsync(key, DISPLAY_PREFS_APP_NAME, response);
|
||||
}
|
||||
|
||||
public void getDisplayPrefsAsync(final String key, String app, final Response<DisplayPreferences> outerResponse) {
|
||||
String cacheKey = String.format("%s.%s", app, key);
|
||||
|
||||
if (displayPrefsCache.containsKey(cacheKey)) {
|
||||
Timber.d("Display prefs loaded from cache %s", cacheKey);
|
||||
outerResponse.onResponse(displayPrefsCache.get(cacheKey));
|
||||
} else {
|
||||
apiClient.getValue().GetDisplayPreferencesAsync(key, getCurrentUser().getId(), app, new Response<DisplayPreferences>() {
|
||||
@Override
|
||||
public void onResponse(DisplayPreferences response) {
|
||||
if (response.getSortBy() == null) response.setSortBy("SortName");
|
||||
if (response.getCustomPrefs() == null)
|
||||
response.setCustomPrefs(new HashMap<String, String>());
|
||||
|
||||
displayPrefsCache.put(cacheKey, response);
|
||||
Timber.d("Display prefs loaded and saved in cache %s", cacheKey);
|
||||
outerResponse.onResponse(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Exception exception) {
|
||||
//Continue with defaults
|
||||
Timber.e(exception, "Unable to load display prefs ");
|
||||
DisplayPreferences prefs = new DisplayPreferences();
|
||||
prefs.setId(key);
|
||||
prefs.setSortBy("SortName");
|
||||
prefs.setCustomPrefs(new HashMap<String, String>());
|
||||
outerResponse.onResponse(prefs);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public BaseItemDto getLastPlayedItem() {
|
||||
return lastPlayedItem;
|
||||
}
|
||||
|
||||
public void setLastPlayedItem(BaseItemDto lastPlayedItem) {
|
||||
this.lastPlayedItem = lastPlayedItem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package org.jellyfin.androidtv.auth
|
||||
|
||||
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.*
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class AccountManagerHelper(
|
||||
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) {
|
||||
val androidAccount = accountManager.getAccountsByType(ACCOUNT_TYPE)
|
||||
.first { accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == accountManagerAccount.id }
|
||||
|
||||
// Remove current account info
|
||||
@Suppress("DEPRECATION")
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP_MR1)
|
||||
accountManager.removeAccountExplicitly(androidAccount)
|
||||
else
|
||||
accountManager.removeAccount(androidAccount, null, null)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
80
app/src/main/java/org/jellyfin/androidtv/auth/ApiBinder.kt
Normal file
80
app/src/main/java/org/jellyfin/androidtv/auth/ApiBinder.kt
Normal file
@@ -0,0 +1,80 @@
|
||||
package org.jellyfin.androidtv.auth
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.JellyfinApplication
|
||||
import org.jellyfin.androidtv.util.apiclient.callApi
|
||||
import org.jellyfin.androidtv.util.apiclient.callApiEmpty
|
||||
import org.jellyfin.apiclient.interaction.ApiClient
|
||||
import org.jellyfin.apiclient.interaction.device.IDevice
|
||||
import org.jellyfin.apiclient.model.apiclient.ServerInfo
|
||||
import org.jellyfin.apiclient.model.dto.UserDto
|
||||
import org.jellyfin.apiclient.model.entities.MediaType
|
||||
import org.jellyfin.apiclient.model.session.ClientCapabilities
|
||||
import org.jellyfin.apiclient.model.session.GeneralCommandType
|
||||
import timber.log.Timber
|
||||
|
||||
class ApiBinder(
|
||||
private val application: JellyfinApplication,
|
||||
private val api: ApiClient,
|
||||
private val device: IDevice,
|
||||
private val authenticationStore: AuthenticationStore,
|
||||
) {
|
||||
fun updateSession(session: Session?, resultCallback: (Boolean) -> Unit) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
try {
|
||||
val success = updateSessionInternal(session)
|
||||
resultCallback(success)
|
||||
} catch (throwable: Throwable) {
|
||||
Timber.e(throwable, "Unable to update legacy API session.")
|
||||
resultCallback(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateSessionInternal(session: Session?): Boolean {
|
||||
if (session == null) {
|
||||
application.currentUser = 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 = AuthenticationDevice(device, session.userId.toString())
|
||||
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
|
||||
})
|
||||
|
||||
// Update currentUser DTO
|
||||
val user = callApi<UserDto> { callback -> api.GetUserAsync(session.userId.toString(), callback) }
|
||||
application.currentUser = user
|
||||
|
||||
callApiEmpty { callback ->
|
||||
api.ReportCapabilities(ClientCapabilities().apply {
|
||||
playableMediaTypes = arrayListOf(MediaType.Video, MediaType.Audio)
|
||||
supportsMediaControl = true
|
||||
supportedCommands = arrayListOf(
|
||||
GeneralCommandType.DisplayContent.toString(),
|
||||
GeneralCommandType.DisplayMessage.toString(),
|
||||
)
|
||||
}, callback)
|
||||
}
|
||||
|
||||
// Connect to WebSocket AFTER HTTP connection confirmed working
|
||||
// to catch exceptions not catchable with the legacy websocket client
|
||||
api.ensureWebSocket()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.jellyfin.androidtv.auth
|
||||
|
||||
import org.jellyfin.apiclient.interaction.device.IDevice
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Wrapper for [IDevice] that changes the device id to a combination of the original device id
|
||||
* and the username. This is neccasary to prevent the server from trashing other sessions with
|
||||
* the same device id.
|
||||
*/
|
||||
class AuthenticationDevice(
|
||||
private val parent: IDevice,
|
||||
private val username: String
|
||||
) : IDevice {
|
||||
// Hash the deviceId because we can't use special characters as the device id is send
|
||||
// via HTTP headers
|
||||
override val deviceId = MessageDigest.getInstance("SHA-1").run {
|
||||
update("${parent.deviceId}+${username}".toByteArray())
|
||||
digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
override val deviceName = parent.deviceName
|
||||
|
||||
fun toDeviceInfo() = DeviceInfo(
|
||||
id = deviceId,
|
||||
name = deviceName
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package org.jellyfin.androidtv.auth
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import org.jellyfin.androidtv.auth.model.*
|
||||
import org.jellyfin.androidtv.preference.AuthenticationPreferences
|
||||
import org.jellyfin.androidtv.util.ImageUtils
|
||||
import org.jellyfin.apiclient.interaction.device.IDevice
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.api.client.KtorClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
||||
import org.jellyfin.sdk.api.operations.ImageApi
|
||||
import org.jellyfin.sdk.api.operations.UserApi
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import timber.log.Timber
|
||||
import java.util.*
|
||||
|
||||
interface AuthenticationRepository {
|
||||
fun getServers(): List<Server>
|
||||
fun getUsers(server: UUID): List<PrivateUser>?
|
||||
fun getUser(server: UUID, user: UUID): PrivateUser?
|
||||
fun saveServer(id: UUID, name: String, address: String, version: String?, loginDisclaimer: String?)
|
||||
fun authenticateUser(user: User): Flow<LoginState>
|
||||
fun authenticateUser(user: User, server: Server): Flow<LoginState>
|
||||
fun login(server: Server, username: String, password: String = ""): Flow<LoginState>
|
||||
fun logout(user: User)
|
||||
fun removeUser(user: User)
|
||||
fun getUserImageUrl(server: Server, user: User): String?
|
||||
}
|
||||
|
||||
class AuthenticationRepositoryImpl(
|
||||
private val jellyfin: Jellyfin,
|
||||
private val sessionRepository: SessionRepository,
|
||||
private val device: IDevice,
|
||||
private val accountManagerHelper: AccountManagerHelper,
|
||||
private val authenticationStore: AuthenticationStore,
|
||||
private val userApiClient: KtorClient,
|
||||
private val authenticationPreferences: AuthenticationPreferences,
|
||||
) : AuthenticationRepository {
|
||||
private val serverComparator = compareByDescending<Server> { it.dateLastAccessed }.thenBy { it.name }
|
||||
private val userComparator = compareByDescending<PrivateUser> { it.lastUsed }.thenBy { it.name }
|
||||
|
||||
override fun getServers() = authenticationStore.getServers().map { (id, info) ->
|
||||
Server(
|
||||
id = id,
|
||||
name = info.name,
|
||||
address = info.address,
|
||||
version = info.version,
|
||||
loginDisclaimer = info.loginDisclaimer,
|
||||
dateLastAccessed = Date(info.lastUsed),
|
||||
)
|
||||
}.sortedWith(serverComparator)
|
||||
|
||||
private fun mapUser(
|
||||
serverId: UUID,
|
||||
userId: UUID,
|
||||
userInfo: AuthenticationStoreUser,
|
||||
authInfo: AccountManagerAccount?,
|
||||
) = PrivateUser(
|
||||
id = authInfo?.id ?: userId,
|
||||
serverId = authInfo?.server ?: serverId,
|
||||
name = userInfo.name,
|
||||
accessToken = authInfo?.accessToken,
|
||||
requirePassword = userInfo.requirePassword,
|
||||
imageTag = userInfo.imageTag,
|
||||
lastUsed = userInfo.lastUsed,
|
||||
)
|
||||
|
||||
override fun getUsers(server: UUID): List<PrivateUser>? =
|
||||
authenticationStore.getUsers(server)?.mapNotNull { (userId, userInfo) ->
|
||||
accountManagerHelper.getAccount(userId).let { authInfo ->
|
||||
mapUser(server, userId, userInfo, authInfo)
|
||||
}
|
||||
}?.sortedWith(userComparator)
|
||||
|
||||
override fun getUser(server: UUID, user: UUID): PrivateUser? {
|
||||
val userInfo = authenticationStore.getUser(server, user) ?: return null
|
||||
val authInfo = accountManagerHelper.getAccount(user) ?: return null
|
||||
|
||||
return mapUser(server, user, userInfo, authInfo)
|
||||
}
|
||||
|
||||
override fun saveServer(id: UUID, name: String, address: String, version: String?, loginDisclaimer: String?) {
|
||||
val current = authenticationStore.getServer(id)
|
||||
val server = if (current != null) current.copy(name = name, address = address, version = version, loginDisclaimer = loginDisclaimer, lastUsed = Date().time)
|
||||
else AuthenticationStoreServer(name, address, version, loginDisclaimer)
|
||||
|
||||
authenticationStore.putServer(id, server)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active session to the information in [user] and [server].
|
||||
* Connects to the server and requests the info of the currently authenticated user.
|
||||
*
|
||||
* @return Whether the user information can be retrieved.
|
||||
*/
|
||||
private suspend fun setActiveSession(user: User, server: Server): Boolean {
|
||||
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 = Date().time))
|
||||
}
|
||||
|
||||
authenticationStore.getUser(server.id, user.id)?.let { storedUser ->
|
||||
authenticationStore.putUser(server.id, user.id, storedUser.copy(lastUsed = Date().time))
|
||||
}
|
||||
}
|
||||
|
||||
return authenticated
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun authenticateUser(user: User): Flow<LoginState> = flow {
|
||||
Timber.d("Authenticating serverless user %s", user)
|
||||
emit(AuthenticatingState)
|
||||
|
||||
val server = authenticationStore.getServer(user.serverId)?.let {
|
||||
Server(
|
||||
id = user.serverId,
|
||||
name = it.name,
|
||||
address = it.address,
|
||||
version = it.version,
|
||||
loginDisclaimer = it.loginDisclaimer,
|
||||
dateLastAccessed = Date(it.lastUsed),
|
||||
)
|
||||
}
|
||||
|
||||
if (server == null) emit(RequireSignInState)
|
||||
else if (!server.versionSupported) emit(ServerVersionNotSupported(server))
|
||||
else emitAll(authenticateUser(user, server))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun authenticateUser(user: User, server: Server): Flow<LoginState> = flow {
|
||||
Timber.d("Authenticating user %s", user)
|
||||
emit(AuthenticatingState)
|
||||
|
||||
val account = accountManagerHelper.getAccount(user.id)
|
||||
when {
|
||||
!server.versionSupported -> emit(ServerVersionNotSupported(server))
|
||||
// Access token found, proceed with sign in
|
||||
!authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate] && account?.accessToken != null -> when {
|
||||
// Update session
|
||||
setActiveSession(user, server) -> {
|
||||
// Update stored user
|
||||
try {
|
||||
val userInfo by UserApi(userApiClient).getCurrentUser()
|
||||
val currentUser = authenticationStore.getUser(server.id, user.id)
|
||||
|
||||
val updatedUser = currentUser?.copy(
|
||||
name = userInfo.name!!,
|
||||
requirePassword = userInfo.hasPassword,
|
||||
imageTag = userInfo.primaryImageTag
|
||||
) ?: AuthenticationStoreUser(
|
||||
name = userInfo.name!!,
|
||||
requirePassword = userInfo.hasPassword,
|
||||
imageTag = userInfo.primaryImageTag
|
||||
)
|
||||
authenticationStore.putUser(server.id, user.id, updatedUser)
|
||||
accountManagerHelper.putAccount(AccountManagerAccount(user.id, server.id, updatedUser.name, account.accessToken))
|
||||
|
||||
emit(AuthenticatedState)
|
||||
} catch(err: ApiClientException) {
|
||||
Timber.e(err, "Unable to get current user data")
|
||||
emit(RequireSignInState)
|
||||
}
|
||||
}
|
||||
// Login failed
|
||||
else -> when {
|
||||
// No password required - try login
|
||||
!user.requirePassword -> emitAll(login(server, user.name))
|
||||
// Require new sign in
|
||||
else -> emit(RequireSignInState)
|
||||
}
|
||||
}
|
||||
// User is known to not require a password, try a sign in
|
||||
!user.requirePassword -> emitAll(login(server, user.name))
|
||||
// Account found without access token, require sign in
|
||||
else -> emit(RequireSignInState)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun login(server: Server, username: String, password: String) = flow {
|
||||
if (!server.versionSupported) {
|
||||
emit(ServerVersionNotSupported(server))
|
||||
return@flow
|
||||
}
|
||||
|
||||
val api = jellyfin.createApi(server.address, deviceInfo = AuthenticationDevice(device, username).toDeviceInfo())
|
||||
val userApi = UserApi(api)
|
||||
val result = try {
|
||||
val response = userApi.authenticateUserByName(username, password)
|
||||
response.content
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to sign in as $username")
|
||||
emit(RequireSignInState)
|
||||
return@flow
|
||||
}
|
||||
|
||||
val userInfo = result.user ?: return@flow emit(RequireSignInState)
|
||||
|
||||
val userId = userInfo.id
|
||||
val currentUser = authenticationStore.getUser(server.id, userId)
|
||||
val updatedUser = currentUser?.copy(
|
||||
name = userInfo.name!!,
|
||||
lastUsed = Date().time,
|
||||
requirePassword = userInfo.hasPassword,
|
||||
imageTag = userInfo.primaryImageTag
|
||||
) ?: AuthenticationStoreUser(
|
||||
name = userInfo.name!!,
|
||||
requirePassword = userInfo.hasPassword,
|
||||
imageTag = userInfo.primaryImageTag
|
||||
)
|
||||
|
||||
authenticationStore.putUser(server.id, userId, updatedUser)
|
||||
val accountManagerAccount = AccountManagerAccount(userId, server.id, updatedUser.name, result.accessToken)
|
||||
accountManagerHelper.putAccount(accountManagerAccount)
|
||||
|
||||
val user = PrivateUser(
|
||||
id = userId,
|
||||
serverId = server.id,
|
||||
name = updatedUser.name,
|
||||
accessToken = result.accessToken,
|
||||
requirePassword = userInfo.hasPassword,
|
||||
imageTag = userInfo.primaryImageTag,
|
||||
lastUsed = Date().time,
|
||||
)
|
||||
|
||||
// We just added the account so it should activate properly although in rare cases it doesn't.
|
||||
// this is often caused by issues in the platforms account manager
|
||||
if (setActiveSession(user, server)) emit(AuthenticatedState)
|
||||
else {
|
||||
// Try to remove the account and ask for sign in
|
||||
accountManagerHelper.removeAccount(accountManagerAccount)
|
||||
emit(RequireSignInState)
|
||||
}
|
||||
}
|
||||
|
||||
override fun logout(user: User) {
|
||||
accountManagerHelper.getAccount(user.id)?.let(accountManagerHelper::removeAccount)
|
||||
}
|
||||
|
||||
override fun removeUser(user: User) {
|
||||
authenticationStore.removeUser(user.serverId, user.id)
|
||||
logout(user)
|
||||
}
|
||||
|
||||
override fun getUserImageUrl(server: Server, user: User): String? {
|
||||
val api = jellyfin.createApi(baseUrl = server.address)
|
||||
val imageApi = ImageApi(api)
|
||||
|
||||
return user.imageTag?.let { imageTag ->
|
||||
imageApi.getUserImageUrl(
|
||||
userId = user.id,
|
||||
tag = imageTag,
|
||||
imageType = ImageType.PRIMARY,
|
||||
maxHeight = ImageUtils.MAX_PRIMARY_IMAGE_HEIGHT
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.jellyfin.androidtv.auth
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
|
||||
|
||||
enum class AuthenticationSortBy {
|
||||
@EnumDisplayOptions(name = R.string.last_use)
|
||||
LAST_USE,
|
||||
@EnumDisplayOptions(name = R.string.alphabetical)
|
||||
ALPHABETICAL
|
||||
}
|
||||
@@ -1,31 +1,25 @@
|
||||
package org.jellyfin.androidtv.auth.store
|
||||
package org.jellyfin.androidtv.auth
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.*
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.contextual
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticationStoreUser
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import org.jellyfin.androidtv.util.serializer.UUIDSerializer
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* 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 [AccountManagerHelper] 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")
|
||||
@@ -33,7 +27,7 @@ class AuthenticationStore(
|
||||
private val json = Json {
|
||||
encodeDefaults = true
|
||||
serializersModule = SerializersModule {
|
||||
contextual(UUIDSerializer())
|
||||
contextual(UUIDSerializer)
|
||||
}
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
@@ -46,30 +40,14 @@ class AuthenticationStore(
|
||||
// No store found
|
||||
if (!storePath.exists()) return emptyMap()
|
||||
|
||||
// Parse JSON document
|
||||
val root = try {
|
||||
json.parseToJsonElement(storePath.readText()).jsonObject
|
||||
} catch (e: SerializationException) {
|
||||
Timber.e(e, "Unable to read JSON")
|
||||
JsonObject(emptyMap())
|
||||
}
|
||||
|
||||
// Check for version
|
||||
val root = json.parseToJsonElement(storePath.readText()).jsonObject
|
||||
return when (root["version"]?.jsonPrimitive?.intOrNull) {
|
||||
// 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 +55,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 +66,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
|
||||
@@ -113,6 +87,10 @@ class AuthenticationStore(
|
||||
return save()
|
||||
}
|
||||
|
||||
fun containsServer(server: UUID): Boolean = server in store
|
||||
|
||||
fun containsUser(server: UUID, user: UUID): Boolean = server in store && user in store[server]!!.users
|
||||
|
||||
/**
|
||||
* Removes the server and stored users from the credential store.
|
||||
*/
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.jellyfin.androidtv.auth
|
||||
|
||||
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 1,
|
||||
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,98 @@
|
||||
package org.jellyfin.androidtv.auth
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.contextual
|
||||
import org.jellyfin.androidtv.auth.model.AccountManagerAccount
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticationStoreUser
|
||||
import org.jellyfin.androidtv.preference.SystemPreferences
|
||||
import org.jellyfin.androidtv.util.serializer.UUIDSerializer
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
|
||||
class LegacyAccountMigration(
|
||||
private val context: Context,
|
||||
private val authenticationStore: AuthenticationStore,
|
||||
private val accountManagerHelper: AccountManagerHelper,
|
||||
private val systemPreferences: SystemPreferences,
|
||||
) {
|
||||
companion object {
|
||||
const val LEGACY_CREDENTIAL_FILE = "org.jellyfin.androidtv.login.json"
|
||||
}
|
||||
|
||||
suspend fun migrate() {
|
||||
val path = context.filesDir.resolve(LEGACY_CREDENTIAL_FILE)
|
||||
|
||||
if (path.exists() && !systemPreferences[SystemPreferences.legacyCredentialsMigrated]) {
|
||||
Timber.d("Starting migration of legacy credentials from $path")
|
||||
val json = Json {
|
||||
serializersModule = SerializersModule {
|
||||
contextual(UUIDSerializer)
|
||||
}
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
val root = json.parseToJsonElement(path.readText()).jsonObject
|
||||
val server = root["serverInfo"]?.jsonObject
|
||||
val user = root["userDto"]?.jsonObject
|
||||
|
||||
val serverId = server?.get("Id")?.jsonPrimitive?.content?.toUUIDOrNull()
|
||||
if (serverId != null) {
|
||||
val serverName = server["Name"]?.jsonPrimitive?.content
|
||||
val serverAddress = server["Address"]?.jsonPrimitive?.content
|
||||
val serverVersion = server["Version"]?.jsonPrimitive?.content
|
||||
|
||||
if (!authenticationStore.containsServer(serverId)) {
|
||||
Timber.i("Migrating server $serverId")
|
||||
authenticationStore.putServer(
|
||||
id = serverId,
|
||||
server = AuthenticationStoreServer(
|
||||
name = serverName ?: "",
|
||||
address = serverAddress ?: "",
|
||||
loginDisclaimer = null,
|
||||
version = serverVersion,
|
||||
lastRefreshed = 0,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val userId = user?.get("Id")?.jsonPrimitive?.content?.toUUIDOrNull()
|
||||
if (userId != null && !authenticationStore.containsUser(serverId, userId)) {
|
||||
Timber.i("Migrating user $userId")
|
||||
val name = user["Name"]?.jsonPrimitive?.content ?: userId.toString()
|
||||
val requirePassword = user["HasPassword"]?.jsonPrimitive?.booleanOrNull ?: true
|
||||
val imageTag = user["PrimaryImageTag"]?.jsonPrimitive?.content
|
||||
val accessToken = user["AccessToken"]?.jsonPrimitive?.content ?: server["AccessToken"]?.jsonPrimitive?.content
|
||||
|
||||
authenticationStore.putUser(
|
||||
server = serverId,
|
||||
userId = userId,
|
||||
userInfo = AuthenticationStoreUser(
|
||||
name = name,
|
||||
requirePassword = requirePassword,
|
||||
imageTag = imageTag
|
||||
)
|
||||
)
|
||||
accountManagerHelper.putAccount(AccountManagerAccount(
|
||||
id = userId,
|
||||
server = serverId,
|
||||
name = name,
|
||||
accessToken = accessToken?.let {
|
||||
// Convert empty string to null
|
||||
if (it.isBlank()) null
|
||||
else it
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
systemPreferences[SystemPreferences.legacyCredentialsMigrated] = true
|
||||
} else {
|
||||
Timber.d("Skipping migration of legacy credentials from $path (file does not exist)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package org.jellyfin.androidtv.auth
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.liveData
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.jellyfin.androidtv.auth.model.*
|
||||
import org.jellyfin.androidtv.util.sdk.toPublicUser
|
||||
import org.jellyfin.androidtv.util.sdk.toServer
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.operations.BrandingApi
|
||||
import org.jellyfin.sdk.api.operations.SystemApi
|
||||
import org.jellyfin.sdk.api.operations.UserApi
|
||||
import org.jellyfin.sdk.discovery.RecommendedServerInfo
|
||||
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
||||
import org.jellyfin.sdk.model.api.ServerDiscoveryInfo
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
import timber.log.Timber
|
||||
import java.util.*
|
||||
|
||||
interface ServerRepository {
|
||||
fun getStoredServers(): List<Server>
|
||||
fun getDiscoveryServers(): Flow<Server>
|
||||
suspend fun migrateLegacyCredentials()
|
||||
|
||||
fun getServerUsers(server: Server): LiveData<List<User>>
|
||||
fun removeServer(serverId: UUID): Boolean
|
||||
fun addServer(address: String): Flow<ServerAdditionState>
|
||||
suspend fun refreshServerInfo(server: Server): Boolean
|
||||
|
||||
companion object {
|
||||
val minimumServerVersion = Jellyfin.minimumVersion.copy(build = null)
|
||||
}
|
||||
}
|
||||
|
||||
class ServerRepositoryImpl(
|
||||
private val jellyfin: Jellyfin,
|
||||
private val authenticationRepository: AuthenticationRepository,
|
||||
private val authenticationStore: AuthenticationStore,
|
||||
private val legacyAccountMigration: LegacyAccountMigration,
|
||||
) : ServerRepository {
|
||||
override fun getStoredServers() = authenticationRepository.getServers()
|
||||
|
||||
override fun getDiscoveryServers() = flow {
|
||||
val servers = jellyfin.discovery.discoverLocalServers().mapNotNull(ServerDiscoveryInfo::toServer)
|
||||
emitAll(servers)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
private suspend fun getServerPublicUsers(server: Server): List<PublicUser> {
|
||||
val client = jellyfin.createApi(server.address)
|
||||
val userApi = UserApi(client)
|
||||
|
||||
return try {
|
||||
val users by userApi.getPublicUsers()
|
||||
users.mapNotNull(UserDto::toPublicUser)
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to retrieve public users")
|
||||
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
public override suspend fun refreshServerInfo(server: Server): Boolean {
|
||||
// Only update existing servers
|
||||
val serverInfo = authenticationStore.getServer(server.id) ?: return false
|
||||
val now = Date().time
|
||||
|
||||
// Only update every 10 minutes
|
||||
if (now - serverInfo.lastRefreshed < 600000 && serverInfo.version != null) return false
|
||||
|
||||
return try {
|
||||
val client = jellyfin.createApi(server.address)
|
||||
// Get login disclaimer
|
||||
val branding by BrandingApi(client).getBrandingOptions()
|
||||
val systemInfo by SystemApi(client).getPublicSystemInfo()
|
||||
|
||||
authenticationStore.putServer(server.id, serverInfo.copy(
|
||||
name = systemInfo.serverName ?: serverInfo.name,
|
||||
version = systemInfo.version ?: serverInfo.version,
|
||||
loginDisclaimer = branding.loginDisclaimer ?: serverInfo.loginDisclaimer,
|
||||
lastRefreshed = now
|
||||
))
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to update server")
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun getServerStoredUsers(server: Server): List<PrivateUser> = authenticationRepository
|
||||
.getUsers(server.id)
|
||||
.orEmpty()
|
||||
|
||||
override fun getServerUsers(server: Server): LiveData<List<User>> = liveData {
|
||||
val users = mutableListOf<User>()
|
||||
|
||||
users.addAll(getServerStoredUsers(server))
|
||||
emit(users.toList())
|
||||
|
||||
getServerPublicUsers(server)
|
||||
.sortedBy { it.name }
|
||||
.forEach { user ->
|
||||
val index = users.indexOfFirst { it.id == user.id }
|
||||
if (index == -1) users.add(user)
|
||||
else {
|
||||
users[index] = when(val currentUser = users[index]) {
|
||||
is PublicUser -> user
|
||||
is PrivateUser -> currentUser.copy(
|
||||
name = user.name,
|
||||
requirePassword = user.requirePassword,
|
||||
imageTag = user.imageTag
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit(users.toList())
|
||||
}
|
||||
|
||||
override suspend fun migrateLegacyCredentials() = legacyAccountMigration.migrate()
|
||||
|
||||
override fun removeServer(serverId: UUID) = authenticationStore.removeServer(serverId)
|
||||
|
||||
override fun addServer(address: String): Flow<ServerAdditionState> = flow {
|
||||
Timber.d("Adding server %s", address)
|
||||
|
||||
emit(ConnectingState(address))
|
||||
|
||||
val addressCandidates = jellyfin.discovery.getAddressCandidates(address)
|
||||
Timber.d("Found ${addressCandidates.size} candidates")
|
||||
|
||||
val goodRecommendations = mutableListOf<RecommendedServerInfo>()
|
||||
val badRecommendations = mutableListOf<RecommendedServerInfo>()
|
||||
val greatRecommendaton = jellyfin.discovery.getRecommendedServers(addressCandidates)
|
||||
.firstOrNull { recommendedServer ->
|
||||
when (recommendedServer.score) {
|
||||
RecommendedServerInfoScore.GREAT -> true
|
||||
RecommendedServerInfoScore.GOOD -> {
|
||||
goodRecommendations += recommendedServer
|
||||
false
|
||||
}
|
||||
else -> {
|
||||
badRecommendations += recommendedServer
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timber.d(buildString {
|
||||
append("Recommendations: ")
|
||||
if (greatRecommendaton == null) append(0)
|
||||
else append(1)
|
||||
append(" great, ")
|
||||
append(goodRecommendations.size)
|
||||
append(" good, ")
|
||||
append(badRecommendations.size)
|
||||
append(" bad")
|
||||
})
|
||||
|
||||
val chosenRecommendation = greatRecommendaton ?: goodRecommendations.firstOrNull()
|
||||
if (chosenRecommendation != null) {
|
||||
// Get system info
|
||||
val systemInfo = chosenRecommendation.systemInfo!!
|
||||
|
||||
// Get branding info
|
||||
val api = BrandingApi(jellyfin.createApi(chosenRecommendation.address))
|
||||
val branding by api.getBrandingOptions()
|
||||
|
||||
val id = systemInfo.id!!.toUUID()
|
||||
authenticationRepository.saveServer(
|
||||
id = id,
|
||||
name = systemInfo.serverName ?: "Jellyfin Server",
|
||||
address = chosenRecommendation.address,
|
||||
version = systemInfo.version,
|
||||
loginDisclaimer = branding.loginDisclaimer,
|
||||
)
|
||||
|
||||
emit(ConnectedState(id, systemInfo))
|
||||
} else {
|
||||
// No great or good recommendations, only add bad recommendations
|
||||
emit(UnableToConnectState(addressCandidates))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package org.jellyfin.androidtv.auth
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.jellyfin.androidtv.preference.AuthenticationPreferences
|
||||
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.*
|
||||
import org.jellyfin.sdk.api.client.KtorClient
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import java.util.*
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
data class Session(
|
||||
val userId: UUID,
|
||||
val serverId: UUID,
|
||||
val accessToken: String,
|
||||
)
|
||||
|
||||
interface SessionRepository {
|
||||
val currentSession: LiveData<Session?>
|
||||
val currentSystemSession: LiveData<Session?>
|
||||
|
||||
fun restoreDefaultSession()
|
||||
fun restoreDefaultSystemSession()
|
||||
|
||||
suspend fun switchCurrentSession(userId: UUID): Boolean
|
||||
fun destroyCurrentSession()
|
||||
}
|
||||
|
||||
class SessionRepositoryImpl(
|
||||
private val authenticationPreferences: AuthenticationPreferences,
|
||||
private val accountManagerHelper: AccountManagerHelper,
|
||||
private val apiBinder: ApiBinder,
|
||||
private val authenticationStore: AuthenticationStore,
|
||||
private val userApiClient: KtorClient,
|
||||
private val systemApiClient: KtorClient,
|
||||
) : SessionRepository {
|
||||
private val _currentSession = MutableLiveData<Session?>()
|
||||
override val currentSession: LiveData<Session?> get() = _currentSession
|
||||
|
||||
private val _currentSystemSession = MutableLiveData<Session?>()
|
||||
override val currentSystemSession: LiveData<Session?> get() = _currentSystemSession
|
||||
|
||||
override fun restoreDefaultSession() {
|
||||
Timber.d("Restoring default session")
|
||||
|
||||
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return destroyCurrentSession()
|
||||
|
||||
val behavior = authenticationPreferences[AuthenticationPreferences.autoLoginUserBehavior]
|
||||
val userId = authenticationPreferences[AuthenticationPreferences.autoLoginUserId].toUUIDOrNull()
|
||||
|
||||
when (behavior) {
|
||||
DISABLED -> destroyCurrentSession()
|
||||
LAST_USER -> setCurrentSession(createLastUserSession(), false)
|
||||
SPECIFIC_USER -> setCurrentSession(createUserSession(userId), false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun restoreDefaultSystemSession() {
|
||||
Timber.d("Restoring default system session")
|
||||
|
||||
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return setCurrentSession(null, false)
|
||||
|
||||
val behavior = authenticationPreferences[AuthenticationPreferences.systemUserBehavior]
|
||||
val userId = authenticationPreferences[AuthenticationPreferences.systemUserId].toUUIDOrNull()
|
||||
|
||||
when (behavior) {
|
||||
DISABLED -> setCurrentSystemSession(null)
|
||||
LAST_USER -> setCurrentSystemSession(createLastUserSession())
|
||||
SPECIFIC_USER -> setCurrentSystemSession(createUserSession(userId))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun switchCurrentSession(userId: UUID): Boolean {
|
||||
// No change in user - don't switch
|
||||
if (currentSession.value?.userId == userId) return false
|
||||
|
||||
Timber.d("Switching current session to user $userId")
|
||||
|
||||
val session = createUserSession(userId)
|
||||
if (session == null) {
|
||||
Timber.d("Could not switch to non-existing session for user $userId")
|
||||
return false
|
||||
}
|
||||
|
||||
return suspendCoroutine { continuation ->
|
||||
setCurrentSession(session, true) { success ->
|
||||
continuation.resume(success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun destroyCurrentSession() {
|
||||
Timber.d("Destroying current session")
|
||||
|
||||
_currentSession.value = null
|
||||
userApiClient.applySession(null)
|
||||
apiBinder.updateSession(null) { }
|
||||
}
|
||||
|
||||
private fun setCurrentSession(session: Session?, includeSystemUser: Boolean, callback: ((Boolean) -> Unit)? = null) {
|
||||
// No change in session - don't switch
|
||||
if (session != null && currentSession.value?.userId == session.userId) return
|
||||
|
||||
if (session != null) authenticationPreferences[AuthenticationPreferences.lastUserId] = session.userId.toString()
|
||||
|
||||
val systemUserBehavior = authenticationPreferences[AuthenticationPreferences.systemUserBehavior]
|
||||
if (includeSystemUser && systemUserBehavior == LAST_USER) setCurrentSystemSession(session)
|
||||
|
||||
// Update session after binding the apiclient settings
|
||||
apiBinder.updateSession(session) { success ->
|
||||
Timber.d("Updating current session. userId=${session?.userId} apiBindingSuccess=${success}")
|
||||
|
||||
if (success) {
|
||||
userApiClient.applySession(session)
|
||||
_currentSession.postValue(session)
|
||||
} else {
|
||||
userApiClient.applySession(null)
|
||||
_currentSession.postValue(null)
|
||||
}
|
||||
|
||||
callback?.invoke(success)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setCurrentSystemSession(session: Session?) {
|
||||
// No change in session - don't switch
|
||||
if (session != null && currentSession.value?.userId == session.userId) return
|
||||
|
||||
_currentSystemSession.postValue(session)
|
||||
|
||||
systemApiClient.applySession(session)
|
||||
}
|
||||
|
||||
private fun createLastUserSession(): Session? {
|
||||
val lastUserId = authenticationPreferences[AuthenticationPreferences.lastUserId].toUUIDOrNull()
|
||||
return createUserSession(lastUserId)
|
||||
}
|
||||
|
||||
private fun createUserSession(userId: UUID?): Session? {
|
||||
if (userId == null) return null
|
||||
|
||||
val account = accountManagerHelper.getAccount(userId)
|
||||
if (account?.accessToken == null) return null
|
||||
|
||||
return Session(
|
||||
userId = account.id,
|
||||
serverId = account.server,
|
||||
accessToken = account.accessToken
|
||||
)
|
||||
}
|
||||
|
||||
private fun KtorClient.applySession(session: Session?) {
|
||||
if (session == null) {
|
||||
baseUrl = null
|
||||
accessToken = null
|
||||
userId = null
|
||||
} else {
|
||||
val server = authenticationStore.getServer(session.serverId)
|
||||
?: return applySession(null)
|
||||
baseUrl = server.address
|
||||
accessToken = session.accessToken
|
||||
userId = session.userId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.jellyfin.androidtv.auth.model
|
||||
|
||||
import java.util.*
|
||||
|
||||
data class AccountManagerAccount(
|
||||
val id: UUID,
|
||||
val server: UUID,
|
||||
val name: String,
|
||||
val accessToken: String? = null,
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
package org.jellyfin.androidtv.auth.model
|
||||
|
||||
sealed class AuthenticateMethod
|
||||
data class AutomaticAuthenticateMethod(val user: User) : AuthenticateMethod()
|
||||
data class CredentialAuthenticateMethod(val username: String, val password: String = "") : AuthenticateMethod()
|
||||
data class QuickConnectAuthenticateMethod(val secret: String) : AuthenticateMethod()
|
||||
@@ -1,11 +0,0 @@
|
||||
package org.jellyfin.androidtv.auth.model
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
|
||||
enum class AuthenticationSortBy(
|
||||
override val nameRes: Int
|
||||
) : PreferenceEnum {
|
||||
LAST_USE(R.string.last_use),
|
||||
ALPHABETICAL(R.string.alphabetical);
|
||||
}
|
||||
@@ -5,9 +5,8 @@ package org.jellyfin.androidtv.auth.model
|
||||
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.UUID
|
||||
import org.jellyfin.androidtv.util.serializer.UUIDSerializer
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Locally stored server information. New properties require default values or deserialization will fail.
|
||||
@@ -18,9 +17,7 @@ data class AuthenticationStoreServer(
|
||||
val address: String,
|
||||
val version: String? = null,
|
||||
@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.*
|
||||
|
||||
/**
|
||||
* 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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.jellyfin.androidtv.auth.model
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
data class LoadingState(
|
||||
val status: Status,
|
||||
@StringRes val messageRes: Int? = null
|
||||
) {
|
||||
companion object {
|
||||
val PENDING = LoadingState(Status.PENDING)
|
||||
val LOADING = LoadingState(Status.LOADING)
|
||||
val SUCCESS = LoadingState(Status.SUCCESS)
|
||||
val ERROR = LoadingState(Status.ERROR)
|
||||
fun error(@StringRes messageRes: Int? = null) = LoadingState(Status.ERROR, messageRes)
|
||||
}
|
||||
|
||||
enum class Status {
|
||||
PENDING,
|
||||
LOADING,
|
||||
SUCCESS,
|
||||
ERROR
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package org.jellyfin.androidtv.auth.model
|
||||
|
||||
sealed class QuickConnectState
|
||||
|
||||
/**
|
||||
* State unknown until first poll completed.
|
||||
*/
|
||||
data object UnknownQuickConnectState : QuickConnectState()
|
||||
|
||||
/**
|
||||
* Server does not have QuickConnect enabled.
|
||||
*/
|
||||
data object UnavailableQuickConnectState : QuickConnectState()
|
||||
|
||||
/**
|
||||
* Connection is pending.
|
||||
*/
|
||||
data class PendingQuickConnectState(val code: String) : QuickConnectState()
|
||||
|
||||
/**
|
||||
* User connected.
|
||||
*/
|
||||
data object ConnectedQuickConnectState : QuickConnectState()
|
||||
@@ -1,9 +1,8 @@
|
||||
package org.jellyfin.androidtv.auth.model
|
||||
|
||||
import org.jellyfin.androidtv.auth.repository.ServerRepository
|
||||
import org.jellyfin.androidtv.auth.ServerRepository
|
||||
import org.jellyfin.sdk.model.ServerVersion
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Server model to use locally in place of ServerInfo model in ApiClient.
|
||||
@@ -14,15 +13,11 @@ data class Server(
|
||||
var address: String,
|
||||
val version: String? = null,
|
||||
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
|
||||
|
||||
override fun equals(other: Any?) = other is Server
|
||||
&& id == other.id
|
||||
&& address == other.address
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package org.jellyfin.androidtv.auth.model
|
||||
|
||||
import org.jellyfin.sdk.discovery.RecommendedServerIssue
|
||||
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||
import java.util.UUID
|
||||
import java.util.*
|
||||
|
||||
sealed class ServerAdditionState
|
||||
data class ConnectingState(val address: String) : ServerAdditionState()
|
||||
data class UnableToConnectState(val addressCandidates: Map<String, Collection<RecommendedServerIssue>>) : ServerAdditionState()
|
||||
data class UnableToConnectState(val addressCandidates: List<String>) : ServerAdditionState()
|
||||
data class ConnectedState(val id: UUID, val publicInfo: PublicSystemInfo) : ServerAdditionState()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package org.jellyfin.androidtv.auth.model
|
||||
|
||||
import java.util.UUID
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* User model used locally.
|
||||
@@ -10,10 +10,9 @@ 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
|
||||
|
||||
override fun equals(other: Any?) = other is User
|
||||
&& serverId == other.serverId
|
||||
&& id == other.id
|
||||
@@ -33,11 +32,10 @@ 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() {
|
||||
override fun withToken(accessToken: String) = copy(accessToken = accessToken)
|
||||
}
|
||||
) : User()
|
||||
|
||||
/**
|
||||
* Represents a user stored server side. Found using the Public User endpoint.
|
||||
@@ -47,7 +45,6 @@ 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)
|
||||
}
|
||||
) : User()
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
package org.jellyfin.androidtv.auth.repository
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import org.jellyfin.androidtv.auth.model.ApiClientErrorLoginState
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticateMethod
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticatedState
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticatingState
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticationStoreUser
|
||||
import org.jellyfin.androidtv.auth.model.AutomaticAuthenticateMethod
|
||||
import org.jellyfin.androidtv.auth.model.CredentialAuthenticateMethod
|
||||
import org.jellyfin.androidtv.auth.model.LoginState
|
||||
import org.jellyfin.androidtv.auth.model.PrivateUser
|
||||
import org.jellyfin.androidtv.auth.model.QuickConnectAuthenticateMethod
|
||||
import org.jellyfin.androidtv.auth.model.RequireSignInState
|
||||
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.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.sdk.forUser
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
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.authenticateUserByName
|
||||
import org.jellyfin.sdk.api.client.extensions.authenticateWithQuickConnect
|
||||
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
|
||||
|
||||
/**
|
||||
* Repository to manage authentication of the user in the app.
|
||||
*/
|
||||
interface AuthenticationRepository {
|
||||
fun authenticate(server: Server, method: AuthenticateMethod): Flow<LoginState>
|
||||
fun logout(user: User): Boolean
|
||||
fun getUserImageUrl(server: Server, user: User): String?
|
||||
}
|
||||
|
||||
class AuthenticationRepositoryImpl(
|
||||
private val jellyfin: Jellyfin,
|
||||
private val sessionRepository: SessionRepository,
|
||||
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> {
|
||||
return when (method) {
|
||||
is AutomaticAuthenticateMethod -> authenticateAutomatic(server, method.user)
|
||||
is CredentialAuthenticateMethod -> authenticateCredential(server, method.username, method.password)
|
||||
is QuickConnectAuthenticateMethod -> authenticateQuickConnect(server, method.secret)
|
||||
}
|
||||
}
|
||||
|
||||
private fun authenticateAutomatic(server: Server, user: User): Flow<LoginState> {
|
||||
Timber.i("Authenticating user ${user.id}")
|
||||
|
||||
// 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)
|
||||
// Try login with access token
|
||||
return if (authStoreUser?.accessToken != null) authenticateToken(server, user.withToken(authStoreUser.accessToken))
|
||||
// Require login
|
||||
else flowOf(RequireSignInState)
|
||||
}
|
||||
|
||||
private fun authenticateCredential(server: Server, username: String, password: String) = flow {
|
||||
val api = jellyfin.createApi(server.address, deviceInfo = defaultDeviceInfo.forUser(username))
|
||||
val result = try {
|
||||
val response = api.userApi.authenticateUserByName(username, password)
|
||||
response.content
|
||||
} catch (err: TimeoutException) {
|
||||
Timber.e(err, "Failed to connect to server trying to sign in $username")
|
||||
emit(ServerUnavailableState)
|
||||
return@flow
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to sign in as $username")
|
||||
emit(ApiClientErrorLoginState(err))
|
||||
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))
|
||||
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 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(),
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
} 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))
|
||||
}
|
||||
}.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,
|
||||
) ?: AuthenticationStoreUser(
|
||||
name = userInfo.name!!,
|
||||
imageTag = userInfo.primaryImage?.tag,
|
||||
accessToken = accessToken,
|
||||
)
|
||||
authenticationStore.putUser(server.id, userInfo.id, updatedUser)
|
||||
}
|
||||
|
||||
private suspend fun setActiveSession(user: User, server: Server): Boolean {
|
||||
val authenticated = sessionRepository.switchCurrentSession(server.id, 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.getUser(server.id, user.id)?.let { storedUser ->
|
||||
authenticationStore.putUser(server.id, user.id, storedUser.copy(lastUsed = Instant.now().toEpochMilli()))
|
||||
}
|
||||
}
|
||||
|
||||
return authenticated
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}?.getUrl(jellyfin.createApi(server.address))
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
package org.jellyfin.androidtv.auth.repository
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
|
||||
import org.jellyfin.androidtv.auth.model.ConnectedState
|
||||
import org.jellyfin.androidtv.auth.model.ConnectingState
|
||||
import org.jellyfin.androidtv.auth.model.Server
|
||||
import org.jellyfin.androidtv.auth.model.ServerAdditionState
|
||||
import org.jellyfin.androidtv.auth.model.UnableToConnectState
|
||||
import org.jellyfin.androidtv.auth.store.AuthenticationStore
|
||||
import org.jellyfin.androidtv.util.sdk.toServer
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.exception.InvalidContentException
|
||||
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.ServerDiscoveryInfo
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
import timber.log.Timber
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Repository to maintain servers.
|
||||
*/
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
class ServerRepositoryImpl(
|
||||
private val jellyfin: Jellyfin,
|
||||
private val authenticationStore: AuthenticationStore,
|
||||
) : ServerRepository {
|
||||
// State
|
||||
private val _storedServers = MutableStateFlow(emptyList<Server>())
|
||||
override val storedServers = _storedServers.asStateFlow()
|
||||
|
||||
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()
|
||||
.map { (id, entry) -> entry.asServer(id) }
|
||||
.sortedWith(compareByDescending<Server> { it.dateLastAccessed }.thenBy { it.name })
|
||||
.let { _storedServers.emit(it) }
|
||||
}
|
||||
|
||||
override suspend fun loadDiscoveryServers() {
|
||||
val servers = mutableListOf<Server>()
|
||||
|
||||
jellyfin.discovery
|
||||
.discoverLocalServers()
|
||||
.map(ServerDiscoveryInfo::toServer)
|
||||
.collect { server ->
|
||||
servers += server
|
||||
_discoveredServers.emit(servers.toList())
|
||||
}
|
||||
}
|
||||
|
||||
override fun setCurrentServer(server: Server?) {
|
||||
_currentServer.value = server
|
||||
}
|
||||
|
||||
// Mutating data
|
||||
override fun addServer(address: String): Flow<ServerAdditionState> = flow {
|
||||
Timber.i("Adding server %s", address)
|
||||
|
||||
emit(ConnectingState(address))
|
||||
|
||||
val addressCandidates = jellyfin.discovery.getAddressCandidates(address)
|
||||
Timber.i("Found ${addressCandidates.size} candidates")
|
||||
|
||||
val goodRecommendations = mutableListOf<RecommendedServerInfo>()
|
||||
val badRecommendations = mutableListOf<RecommendedServerInfo>()
|
||||
val greatRecommendation = jellyfin.discovery.getRecommendedServers(addressCandidates).firstOrNull { recommendedServer ->
|
||||
when (recommendedServer.score) {
|
||||
RecommendedServerInfoScore.GREAT -> true
|
||||
RecommendedServerInfoScore.GOOD -> {
|
||||
goodRecommendations += recommendedServer
|
||||
false
|
||||
}
|
||||
|
||||
else -> {
|
||||
badRecommendations += recommendedServer
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timber.i(buildString {
|
||||
append("Recommendations: ")
|
||||
if (greatRecommendation == null) append(0)
|
||||
else append(1)
|
||||
append(" great, ")
|
||||
append(goodRecommendations.size)
|
||||
append(" good, ")
|
||||
append(badRecommendations.size)
|
||||
append(" bad")
|
||||
})
|
||||
|
||||
val chosenRecommendation = greatRecommendation ?: goodRecommendations.firstOrNull()
|
||||
if (chosenRecommendation != null && chosenRecommendation.systemInfo.isSuccess) {
|
||||
// Get system info
|
||||
val systemInfo = chosenRecommendation.systemInfo.getOrThrow()
|
||||
|
||||
// Get branding info
|
||||
val api = jellyfin.createApi(chosenRecommendation.address)
|
||||
val branding = api.getBrandingOptionsOrDefault()
|
||||
|
||||
val id = systemInfo.id!!.toUUID()
|
||||
|
||||
val server = authenticationStore.getServer(id)?.copy(
|
||||
name = systemInfo.serverName ?: "Jellyfin Server",
|
||||
address = chosenRecommendation.address,
|
||||
version = systemInfo.version,
|
||||
loginDisclaimer = branding.loginDisclaimer,
|
||||
splashscreenEnabled = branding.splashscreenEnabled,
|
||||
setupCompleted = systemInfo.startupWizardCompleted ?: true,
|
||||
lastUsed = Instant.now().toEpochMilli()
|
||||
) ?: AuthenticationStoreServer(
|
||||
name = systemInfo.serverName ?: "Jellyfin Server",
|
||||
address = chosenRecommendation.address,
|
||||
version = systemInfo.version,
|
||||
loginDisclaimer = branding.loginDisclaimer,
|
||||
splashscreenEnabled = branding.splashscreenEnabled,
|
||||
setupCompleted = systemInfo.startupWizardCompleted ?: true,
|
||||
)
|
||||
|
||||
authenticationStore.putServer(id, server)
|
||||
loadStoredServers()
|
||||
|
||||
emit(ConnectedState(id, systemInfo))
|
||||
} else {
|
||||
// No great or good recommendations, only add bad recommendations
|
||||
val addressCandidatesWithIssues = (badRecommendations + goodRecommendations)
|
||||
.groupBy { it.address }
|
||||
.mapValues { (_, entry) -> entry.flatMap { server -> server.issues } }
|
||||
emit(UnableToConnectState(addressCandidatesWithIssues))
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
override suspend fun getServer(id: UUID, eagerUpdate: Boolean): 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)
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to update server")
|
||||
null
|
||||
}
|
||||
|
||||
return (updatedServer ?: server).asServer(id)
|
||||
}
|
||||
|
||||
override suspend fun updateServer(server: Server, force: Boolean): Boolean {
|
||||
// Only update existing servers
|
||||
val serverInfo = authenticationStore.getServer(server.id) ?: return false
|
||||
|
||||
return try {
|
||||
updateServerInternal(server.id, serverInfo, force) != null
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to update server")
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateServerInternal(
|
||||
id: UUID,
|
||||
server: AuthenticationStoreServer,
|
||||
forceUpdate: Boolean
|
||||
): AuthenticationStoreServer? {
|
||||
val now = Instant.now().toEpochMilli()
|
||||
|
||||
// Only update every 10 minutes
|
||||
if (now - server.lastRefreshed < 600000 && server.version != null && !forceUpdate) return null
|
||||
|
||||
val newServer = withContext(Dispatchers.IO) {
|
||||
val api = jellyfin.createApi(server.address)
|
||||
|
||||
// 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
|
||||
)
|
||||
}
|
||||
authenticationStore.putServer(id, newServer)
|
||||
|
||||
return newServer
|
||||
}
|
||||
|
||||
override suspend fun deleteServer(server: UUID): Boolean {
|
||||
val success = authenticationStore.removeServer(server)
|
||||
if (success) loadStoredServers()
|
||||
return success
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
private fun AuthenticationStoreServer.asServer(id: UUID) = Server(
|
||||
id = id,
|
||||
name = name,
|
||||
address = address,
|
||||
version = version,
|
||||
loginDisclaimer = loginDisclaimer,
|
||||
splashscreenEnabled = splashscreenEnabled,
|
||||
setupCompleted = setupCompleted,
|
||||
dateLastAccessed = Instant.ofEpochMilli(lastUsed),
|
||||
)
|
||||
|
||||
/**
|
||||
* Try to retrieve the branding options. If the response JSON is invalid it will return a default value.
|
||||
* This makes sure we can still work with older Jellyfin versions.
|
||||
*/
|
||||
private suspend fun ApiClient.getBrandingOptionsOrDefault() = try {
|
||||
brandingApi.getBrandingOptions().content
|
||||
} catch (exception: InvalidContentException) {
|
||||
Timber.w(exception, "Invalid branding options response, using default value")
|
||||
BrandingOptionsDto(
|
||||
loginDisclaimer = null,
|
||||
customCss = null,
|
||||
splashscreenEnabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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.AuthenticationStore
|
||||
import org.jellyfin.androidtv.util.sdk.toPublicUser
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Repository to maintain users for servers.
|
||||
* Authentication is done using the [AuthenticationRepository].
|
||||
*/
|
||||
interface ServerUserRepository {
|
||||
//server
|
||||
fun getStoredServerUsers(server: Server): List<PrivateUser>
|
||||
suspend fun getPublicServerUsers(server: Server): List<PublicUser>
|
||||
|
||||
fun deleteStoredUser(user: PrivateUser)
|
||||
}
|
||||
|
||||
class ServerUserRepositoryImpl(
|
||||
private val jellyfin: Jellyfin,
|
||||
private val authenticationStore: AuthenticationStore,
|
||||
) : ServerUserRepository {
|
||||
override fun getStoredServerUsers(server: Server) = authenticationStore.getUsers(server.id)
|
||||
?.mapNotNull { (userId, userInfo) ->
|
||||
val authInfo = authenticationStore.getUser(server.id, userId)
|
||||
PrivateUser(
|
||||
id = userId,
|
||||
serverId = server.id,
|
||||
name = userInfo.name,
|
||||
accessToken = authInfo?.accessToken,
|
||||
imageTag = userInfo.imageTag,
|
||||
lastUsed = userInfo.lastUsed,
|
||||
)
|
||||
}
|
||||
?.sortedWith(compareByDescending<PrivateUser> { it.lastUsed }.thenBy { it.name })
|
||||
.orEmpty()
|
||||
|
||||
override suspend fun getPublicServerUsers(server: Server): List<PublicUser> {
|
||||
// Create a fresh API because the shared one might be authenticated for a different server
|
||||
val api = jellyfin.createApi(server.address)
|
||||
|
||||
return try {
|
||||
val users = withContext(Dispatchers.IO) {
|
||||
api.userApi.getPublicUsers().content
|
||||
}
|
||||
users.mapNotNull(UserDto::toPublicUser)
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to retrieve public users")
|
||||
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteStoredUser(user: PrivateUser) {
|
||||
// Remove user info from store
|
||||
authenticationStore.removeUser(user.serverId, user.id)
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
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.store.AuthenticationPreferences
|
||||
import org.jellyfin.androidtv.auth.store.AuthenticationStore
|
||||
import org.jellyfin.androidtv.preference.PreferencesRepository
|
||||
import org.jellyfin.androidtv.preference.TelemetryPreferences
|
||||
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.DISABLED
|
||||
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.LAST_USER
|
||||
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.SPECIFIC_USER
|
||||
import org.jellyfin.androidtv.util.sdk.forUser
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.clientLogApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
data class Session(
|
||||
val userId: UUID,
|
||||
val serverId: UUID,
|
||||
val accessToken: String,
|
||||
)
|
||||
|
||||
enum class SessionRepositoryState {
|
||||
READY,
|
||||
RESTORING_SESSION,
|
||||
SWITCHING_SESSION,
|
||||
}
|
||||
|
||||
interface SessionRepository {
|
||||
val currentSession: StateFlow<Session?>
|
||||
val state: StateFlow<SessionRepositoryState>
|
||||
|
||||
suspend fun restoreSession(destroyOnly: Boolean)
|
||||
suspend fun switchCurrentSession(serverId: UUID, userId: UUID): Boolean
|
||||
fun destroyCurrentSession()
|
||||
}
|
||||
|
||||
class SessionRepositoryImpl(
|
||||
private val authenticationPreferences: AuthenticationPreferences,
|
||||
private val authenticationStore: AuthenticationStore,
|
||||
private val userApiClient: ApiClient,
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
private val defaultDeviceInfo: DeviceInfo,
|
||||
private val userRepository: UserRepository,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val telemetryPreferences: TelemetryPreferences,
|
||||
) : SessionRepository {
|
||||
private val currentSessionMutex = Mutex()
|
||||
private val _currentSession = MutableStateFlow<Session?>(null)
|
||||
override val currentSession = _currentSession.asStateFlow()
|
||||
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")
|
||||
|
||||
_state.value = SessionRepositoryState.RESTORING_SESSION
|
||||
|
||||
val alwaysAuthenticate = authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]
|
||||
val autoLoginBehavior = authenticationPreferences[AuthenticationPreferences.autoLoginUserBehavior]
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun switchCurrentSession(serverId: UUID, 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
|
||||
}
|
||||
|
||||
_state.value = SessionRepositoryState.SWITCHING_SESSION
|
||||
Timber.i("Switching current session to user $userId")
|
||||
|
||||
val session = createUserSession(serverId, userId)
|
||||
if (session == null) {
|
||||
Timber.w("Could not switch to non-existing session for user $userId")
|
||||
_state.value = SessionRepositoryState.READY
|
||||
return false
|
||||
}
|
||||
|
||||
val switched = setCurrentSession(session)
|
||||
_state.value = SessionRepositoryState.READY
|
||||
return switched
|
||||
}
|
||||
|
||||
override fun destroyCurrentSession() {
|
||||
Timber.i("Destroying current session")
|
||||
|
||||
userRepository.setCurrentUser(null)
|
||||
serverRepository.setCurrentServer(null)
|
||||
_currentSession.value = null
|
||||
_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)
|
||||
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 applied = userApiClient.applySession(session, deviceInfo)
|
||||
if (applied && session != null) {
|
||||
try {
|
||||
val user = withContext(Dispatchers.IO) {
|
||||
userApiClient.userApi.getCurrentUser().content
|
||||
}
|
||||
userRepository.setCurrentUser(user)
|
||||
serverRepository.setCurrentServer(server)
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to authenticate: bad response when getting user info")
|
||||
destroyCurrentSession()
|
||||
return false
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private fun createUserSession(serverId: UUID, userId: UUID): Session? {
|
||||
val account = authenticationStore.getUser(serverId, userId)
|
||||
if (account?.accessToken == null) return null
|
||||
|
||||
return Session(
|
||||
userId = userId,
|
||||
serverId = serverId,
|
||||
accessToken = account.accessToken
|
||||
)
|
||||
}
|
||||
|
||||
private fun ApiClient.applySession(session: Session?, newDeviceInfo: DeviceInfo = defaultDeviceInfo): Boolean {
|
||||
if (session == null) {
|
||||
update(
|
||||
baseUrl = null,
|
||||
accessToken = null,
|
||||
deviceInfo = newDeviceInfo,
|
||||
)
|
||||
} else {
|
||||
val server = authenticationStore.getServer(session.serverId)
|
||||
?: return false
|
||||
|
||||
update(
|
||||
baseUrl = server.address,
|
||||
accessToken = session.accessToken,
|
||||
deviceInfo = newDeviceInfo,
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package org.jellyfin.androidtv.auth.repository
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
|
||||
/**
|
||||
* Repository to get the current authenticated user.
|
||||
*/
|
||||
interface UserRepository {
|
||||
val currentUser: StateFlow<UserDto?>
|
||||
|
||||
fun setCurrentUser(user: UserDto?)
|
||||
}
|
||||
|
||||
class UserRepositoryImpl : UserRepository {
|
||||
override val currentUser = MutableStateFlow<UserDto?>(null)
|
||||
|
||||
override fun setCurrentUser(user: UserDto?) {
|
||||
currentUser.value = user
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package org.jellyfin.androidtv.auth.store
|
||||
|
||||
import android.content.Context
|
||||
import org.jellyfin.androidtv.auth.model.AuthenticationSortBy
|
||||
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior
|
||||
import org.jellyfin.preference.booleanPreference
|
||||
import org.jellyfin.preference.enumPreference
|
||||
import org.jellyfin.preference.store.SharedPreferenceStore
|
||||
import org.jellyfin.preference.stringPreference
|
||||
|
||||
class AuthenticationPreferences(context: Context) : SharedPreferenceStore(
|
||||
sharedPreferences = context.getSharedPreferences("authentication", Context.MODE_PRIVATE)
|
||||
) {
|
||||
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", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.jellyfin.androidtv.constant;
|
||||
|
||||
public enum ChangeTriggerType {
|
||||
LibraryUpdated,
|
||||
MoviePlayback,
|
||||
TvPlayback,
|
||||
GuideNeedsLoad,
|
||||
MusicPlayback,
|
||||
Always,
|
||||
VideoQueueChange,
|
||||
FavoriteUpdate
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package org.jellyfin.androidtv.constant
|
||||
|
||||
enum class ChangeTriggerType {
|
||||
LibraryUpdated,
|
||||
MoviePlayback,
|
||||
TvPlayback,
|
||||
MusicPlayback,
|
||||
FavoriteUpdate,
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package org.jellyfin.androidtv.constant
|
||||
|
||||
object Codec {
|
||||
object Container {
|
||||
@Suppress("ObjectPropertyName", "ObjectPropertyNaming")
|
||||
const val `3GP` = "3gp"
|
||||
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"
|
||||
const val MOV = "mov"
|
||||
const val MP3 = "mp3"
|
||||
const val MP4 = "mp4"
|
||||
const val MPEG = "mpeg"
|
||||
const val MPEGTS = "mpegts"
|
||||
const val MPG = "mpg"
|
||||
const val OGM = "ogm"
|
||||
const val OGV = "ogv"
|
||||
const val TS = "ts"
|
||||
const val VOB = "vob"
|
||||
const val WEBM = "webm"
|
||||
const val WMV = "wmv"
|
||||
const val WTV = "wtv"
|
||||
const val XVID = "xvid"
|
||||
}
|
||||
|
||||
object Audio {
|
||||
const val AAC = "aac"
|
||||
const val AAC_LATM = "aac_latm"
|
||||
const val AC3 = "ac3"
|
||||
const val ALAC = "alac"
|
||||
const val APE = "ape"
|
||||
const val DCA = "dca"
|
||||
const val DTS = "dts"
|
||||
const val EAC3 = "eac3"
|
||||
const val FLAC = "flac"
|
||||
const val MLP = "mlp"
|
||||
const val MP2 = "mp2"
|
||||
const val MP3 = "mp3"
|
||||
const val MPA = "mpa"
|
||||
const val OGA = "oga"
|
||||
const val OGG = "ogg"
|
||||
const val OPUS = "opus"
|
||||
const val PCM = "pcm"
|
||||
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"
|
||||
const val WMAV2 = "wmav2"
|
||||
}
|
||||
|
||||
object Video {
|
||||
const val H264 = "h264"
|
||||
const val HEVC = "hevc"
|
||||
const val MPEG = "mpeg"
|
||||
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"
|
||||
const val PGSSUB = "pgssub"
|
||||
const val SMI = "smi"
|
||||
const val SRT = "srt"
|
||||
const val SSA = "ssa"
|
||||
const val SUB = "sub"
|
||||
const val SUBRIP = "subrip"
|
||||
const val VTT = "vtt"
|
||||
const val SMIL = "smil"
|
||||
const val TTML = "ttml"
|
||||
const val WEBVTT = "webvtt"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.jellyfin.androidtv.constant;
|
||||
|
||||
public class CodecTypes {
|
||||
/* Audio Codec Types */
|
||||
public static final String AAC = "aac";
|
||||
public static final String AAC_LATM = "aac_latm";
|
||||
public static final String AC3 = "ac3";
|
||||
public static final String APE = "ape";
|
||||
public static final String DCA = "dca";
|
||||
public static final String DTS = "dts";
|
||||
public static final String EAC3 = "eac3";
|
||||
public static final String FLAC = "flac";
|
||||
public static final String MP2 = "mp2";
|
||||
public static final String MP3 = "mp3";
|
||||
public static final String MPA = "mpa";
|
||||
public static final String OPUS = "opus";
|
||||
public static final String PCM = "pcm";
|
||||
public static final String PCM_S16LE = "PCM_S16LE";
|
||||
public static final String PCM_S24LE = "PCM_S24LE";
|
||||
public static final String TRUEHD = "truehd";
|
||||
public static final String VORBIS = "vorbis";
|
||||
public static final String WAV = "wav";
|
||||
public static final String WMA = "wma";
|
||||
public static final String WMAV2 = "wmav2";
|
||||
|
||||
/* Video Codec Types */
|
||||
public static final String AVC = "avc";
|
||||
public static final String H264 = "h264";
|
||||
public static final String H265 = "h265";
|
||||
public static final String HEVC = "hevc";
|
||||
public static final String MPEG2 = "mpeg2";
|
||||
public static final String MPEG2VIDEO = "mpeg2video";
|
||||
public static final String MPEG4 = "mpeg4";
|
||||
public static final String VP8 = "vp8";
|
||||
public static final String VP9 = "vp9";
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.jellyfin.androidtv.constant;
|
||||
|
||||
public class ContainerTypes {
|
||||
public static final String _3GP = "3gp";
|
||||
public static final String _3GPP = "3gpp";
|
||||
public static final String ASF = "asf";
|
||||
public static final String AVI = "avi";
|
||||
public static final String DVR_MS = "dvr-ms";
|
||||
public static final String M2TS = "m2ts";
|
||||
public static final String M2V = "m2v";
|
||||
public static final String M4V = "m4v";
|
||||
public static final String MKV = "mkv";
|
||||
public static final String MOV = "mov";
|
||||
public static final String MP4 = "mp4";
|
||||
public static final String MPEG = "mpeg";
|
||||
public static final String MPEGTS = "mpegts";
|
||||
public static final String MPG = "mpg";
|
||||
public static final String OGA = "oga";
|
||||
public static final String OGG = "ogg";
|
||||
public static final String OGM = "ogm";
|
||||
public static final String OGV = "ogv";
|
||||
public static final String TS = "ts";
|
||||
public static final String VOB = "vob";
|
||||
public static final String WEBM = "webm";
|
||||
public static final String WEBMA = "webma";
|
||||
public static final String WMV = "wmv";
|
||||
public static final String WTV = "wtv";
|
||||
public static final String XVID = "xvid";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.jellyfin.androidtv.constant;
|
||||
|
||||
public enum CustomMessage {
|
||||
RefreshCurrentItem,
|
||||
RemoveCurrentItem,
|
||||
ActionComplete,
|
||||
RefreshRows
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package org.jellyfin.androidtv.constant
|
||||
|
||||
sealed interface CustomMessage {
|
||||
data object RefreshCurrentItem : CustomMessage
|
||||
data object ActionComplete : CustomMessage
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.jellyfin.androidtv.constant
|
||||
|
||||
object Extras {
|
||||
const val Folder = "folder"
|
||||
const val IsLiveTvSeriesRecordings = "is_livetv_series_recordings"
|
||||
const val IncludeType = "type_include"
|
||||
const val Folder = "Folder"
|
||||
const val IncludeType = "IncludeType"
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
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;
|
||||
|
||||
companion object {
|
||||
fun getGridDirection(direction: String?) = values().firstOrNull { it.name == direction }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
package org.jellyfin.androidtv.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
|
||||
/**
|
||||
* All possible homesections, "synced" with jellyfin-web.
|
||||
*
|
||||
* https://github.com/jellyfin/jellyfin-web/blob/master/src/components/homesections/homesections.js
|
||||
* All possible homesections, synced with jellyfin-web
|
||||
*/
|
||||
enum class HomeSectionType(
|
||||
override val serializedName: String,
|
||||
override val nameRes: Int,
|
||||
) : 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),
|
||||
val id: String
|
||||
) {
|
||||
LATEST_MEDIA("latestmedia"),
|
||||
LIBRARY_TILES_SMALL("smalllibrarytiles"),
|
||||
LIBRARY_BUTTONS("librarybuttons"),
|
||||
RESUME("resume"),
|
||||
RESUME_AUDIO("resumeaudio"),
|
||||
ACTIVE_RECORDINGS("activerecordings"),
|
||||
NEXT_UP("nextup"),
|
||||
LIVE_TV("livetv"),
|
||||
NONE("none");
|
||||
|
||||
companion object {
|
||||
fun getById(id: String) = values().firstOrNull { it.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.jellyfin.androidtv.constant;
|
||||
|
||||
public class ImageType {
|
||||
public static final String DEFAULT = "0";
|
||||
public static final String THUMB = "1";
|
||||
public static final String BANNER = "2";
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package org.jellyfin.androidtv.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
|
||||
enum class ImageType(
|
||||
override val nameRes: Int,
|
||||
) : PreferenceEnum {
|
||||
/**
|
||||
* Poster.
|
||||
*/
|
||||
POSTER(R.string.image_type_poster),
|
||||
|
||||
/**
|
||||
* Thumbnail.
|
||||
*/
|
||||
THUMB(R.string.image_type_thumbnail),
|
||||
|
||||
/**
|
||||
* Banner.
|
||||
*/
|
||||
BANNER(R.string.image_type_banner),
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
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 LIVE_TV_SCHEDULE_OPTION_ID = 4000
|
||||
const val LIVE_TV_SERIES_OPTION_ID = 5000
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.jellyfin.androidtv.constant;
|
||||
|
||||
public class MediaTypes {
|
||||
public static final String HLS = "hls";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.jellyfin.androidtv.constant;
|
||||
|
||||
public class PosterSize {
|
||||
public static final String AUTO = "0";
|
||||
public static final String SMALL = "1";
|
||||
public static final String MED = "2";
|
||||
public static final String LARGE = "3";
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package org.jellyfin.androidtv.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
|
||||
enum class PosterSize(
|
||||
override val nameRes: Int,
|
||||
) : PreferenceEnum {
|
||||
/**
|
||||
* Smallest.
|
||||
*/
|
||||
SMALLEST(R.string.image_size_smallest),
|
||||
/**
|
||||
* Small.
|
||||
*/
|
||||
SMALL(R.string.image_size_small),
|
||||
|
||||
/**
|
||||
* Medium.
|
||||
*/
|
||||
MED(R.string.image_size_medium),
|
||||
|
||||
/**
|
||||
* Large.
|
||||
*/
|
||||
LARGE(R.string.image_size_large),
|
||||
|
||||
/**
|
||||
* Extra Large.
|
||||
*/
|
||||
X_LARGE(R.string.image_size_xlarge),
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.jellyfin.androidtv.constant;
|
||||
|
||||
public enum QueryType {
|
||||
Items,
|
||||
NextUp,
|
||||
Views,
|
||||
Season,
|
||||
Upcoming,
|
||||
SimilarSeries,
|
||||
SimilarMovies,
|
||||
StaticPeople,
|
||||
StaticChapters,
|
||||
StaticServers,
|
||||
Users,
|
||||
Search,
|
||||
Specials,
|
||||
Trailers,
|
||||
LiveTvChannel,
|
||||
LiveTvProgram,
|
||||
LiveTvRecording,
|
||||
LiveTvRecordingGroup,
|
||||
StaticItems,
|
||||
Persons,
|
||||
StaticAudioQueueItems,
|
||||
AlbumArtists,
|
||||
AudioPlaylists,
|
||||
ContinueWatching,
|
||||
LatestItems,
|
||||
SeriesTimer,
|
||||
Premieres
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package org.jellyfin.androidtv.constant
|
||||
|
||||
enum class QueryType {
|
||||
Items,
|
||||
NextUp,
|
||||
Views,
|
||||
Season,
|
||||
Upcoming,
|
||||
SimilarSeries,
|
||||
SimilarMovies,
|
||||
StaticPeople,
|
||||
StaticChapters,
|
||||
Search,
|
||||
Specials,
|
||||
AdditionalParts,
|
||||
Trailers,
|
||||
LiveTvChannel,
|
||||
LiveTvProgram,
|
||||
LiveTvRecording,
|
||||
StaticItems,
|
||||
Artists,
|
||||
AlbumArtists,
|
||||
AudioPlaylists,
|
||||
LatestItems,
|
||||
SeriesTimer,
|
||||
Premieres,
|
||||
Resume,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.jellyfin.androidtv.constant;
|
||||
|
||||
public class ViewType {
|
||||
public static final String SMART = "0";
|
||||
public static final String GRID = "1";
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
import org.jellyfin.apiclient.model.dlna.DeviceProfile;
|
||||
import org.jellyfin.apiclient.model.dlna.EncodingContext;
|
||||
import org.jellyfin.apiclient.model.dto.MediaSourceInfo;
|
||||
|
||||
/**
|
||||
* 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 boolean ForceDirectPlay;
|
||||
|
||||
public final boolean getForceDirectPlay() {
|
||||
return ForceDirectPlay;
|
||||
}
|
||||
|
||||
public final void setForceDirectPlay(boolean value) {
|
||||
ForceDirectPlay = value;
|
||||
}
|
||||
|
||||
private boolean ForceDirectStream;
|
||||
|
||||
public final boolean getForceDirectStream() {
|
||||
return ForceDirectStream;
|
||||
}
|
||||
|
||||
public final void setForceDirectStream(boolean value) {
|
||||
ForceDirectStream = 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;
|
||||
}
|
||||
|
||||
private String DeviceId;
|
||||
|
||||
public final String getDeviceId() {
|
||||
return DeviceId;
|
||||
}
|
||||
|
||||
public final void setDeviceId(String value) {
|
||||
DeviceId = 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the audio transcoding bitrate.
|
||||
*
|
||||
* <value>The audio transcoding bitrate.</value>
|
||||
*/
|
||||
private Integer AudioTranscodingBitrate = null;
|
||||
|
||||
public final Integer getAudioTranscodingBitrate() {
|
||||
return AudioTranscodingBitrate;
|
||||
}
|
||||
|
||||
public final void setAudioTranscodingBitrate(Integer value) {
|
||||
AudioTranscodingBitrate = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the maximum bitrate.
|
||||
*
|
||||
* @return System.Nullable<System.Int32>.
|
||||
*/
|
||||
public final Integer GetMaxBitrate(boolean isAudio) {
|
||||
if (getMaxBitrate() != null) {
|
||||
return getMaxBitrate();
|
||||
}
|
||||
|
||||
if (getProfile() != null) {
|
||||
if (getContext() == EncodingContext.Static) {
|
||||
if (isAudio && getProfile().getMaxStaticMusicBitrate() != null) {
|
||||
return getProfile().getMaxStaticMusicBitrate();
|
||||
}
|
||||
return getProfile().getMaxStaticBitrate();
|
||||
}
|
||||
|
||||
return getProfile().getMaxStreamingBitrate();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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,13 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
import java.util.Comparator;
|
||||
|
||||
@Deprecated
|
||||
public abstract class BaseStreamInfoSorter implements Comparator<StreamInfo> {
|
||||
protected abstract int getValue(StreamInfo info);
|
||||
|
||||
@Override
|
||||
public int compare(StreamInfo lhs, StreamInfo rhs) {
|
||||
return Integer.compare(getValue(lhs), getValue(rhs));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
@Deprecated
|
||||
public class ChainedComparator<T> implements Comparator<T> {
|
||||
private List<Comparator<T>> simpleComparators;
|
||||
|
||||
public ChainedComparator(Comparator<T>... simpleComparators) {
|
||||
|
||||
List<Comparator<T>> list = new ArrayList<>();
|
||||
|
||||
for (Comparator<T> comparator : simpleComparators) {
|
||||
list.add(comparator);
|
||||
}
|
||||
this.simpleComparators = list;
|
||||
}
|
||||
|
||||
public int compare(T o1, T o2) {
|
||||
for (Comparator<T> comparator : simpleComparators) {
|
||||
int result = comparator.compare(o1, o2);
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
/**
|
||||
* Class DrawingUtils
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
@Deprecated
|
||||
public final class DrawingUtils {
|
||||
/**
|
||||
* Resizes a set of dimensions
|
||||
*
|
||||
* @param size The original size object
|
||||
* @param width A new fixed width, if desired
|
||||
* @param height A new fixed height, if desired
|
||||
* @param maxWidth A max fixed width, if desired
|
||||
* @param maxHeight A max fixed height, if desired
|
||||
* @return A new size object
|
||||
*/
|
||||
public static ImageSize Resize(ImageSize size, Double width, Double height, Double maxWidth, Double maxHeight) {
|
||||
double newWidth = size.getWidth();
|
||||
double newHeight = size.getHeight();
|
||||
|
||||
if (width != null && height != null) {
|
||||
newWidth = width;
|
||||
newHeight = height;
|
||||
} else if (height != null) {
|
||||
newWidth = GetNewWidth(newHeight, newWidth, height);
|
||||
newHeight = height;
|
||||
} else if (width != null) {
|
||||
newHeight = GetNewHeight(newHeight, newWidth, width);
|
||||
newWidth = width;
|
||||
}
|
||||
|
||||
if (maxHeight != null && maxHeight < newHeight) {
|
||||
newWidth = GetNewWidth(newHeight, newWidth, maxHeight);
|
||||
newHeight = maxHeight;
|
||||
}
|
||||
|
||||
if (maxWidth != null && maxWidth < newWidth) {
|
||||
newHeight = GetNewHeight(newHeight, newWidth, maxWidth);
|
||||
newWidth = maxWidth;
|
||||
}
|
||||
|
||||
ImageSize tempVar = new ImageSize();
|
||||
tempVar.setWidth(newWidth);
|
||||
tempVar.setHeight(newHeight);
|
||||
return tempVar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the new width.
|
||||
*
|
||||
* @param currentHeight Height of the current.
|
||||
* @param currentWidth Width of the current.
|
||||
* @param newHeight The new height.
|
||||
* @return System.Double.
|
||||
*/
|
||||
private static double GetNewWidth(double currentHeight, double currentWidth, double newHeight) {
|
||||
double scaleFactor = newHeight;
|
||||
scaleFactor /= currentHeight;
|
||||
scaleFactor *= currentWidth;
|
||||
|
||||
return scaleFactor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the new height.
|
||||
*
|
||||
* @param currentHeight Height of the current.
|
||||
* @param currentWidth Width of the current.
|
||||
* @param newWidth The new width.
|
||||
* @return System.Double.
|
||||
*/
|
||||
private static double GetNewHeight(double currentHeight, double currentWidth, double newWidth) {
|
||||
double scaleFactor = newWidth;
|
||||
scaleFactor /= currentWidth;
|
||||
scaleFactor *= currentHeight;
|
||||
|
||||
return scaleFactor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
|
||||
import timber.log.Timber;
|
||||
|
||||
/**
|
||||
* Struct ImageSize
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
//C# TO JAVA CONVERTER WARNING: Java does not allow user-defined value types. The behavior of this class will differ from the original:
|
||||
//ORIGINAL LINE: public struct ImageSize
|
||||
@Deprecated
|
||||
public final class ImageSize {
|
||||
private double _height;
|
||||
private double _width;
|
||||
|
||||
/**
|
||||
* Gets or sets the height.
|
||||
*
|
||||
* <value>The height.</value>
|
||||
*/
|
||||
public double getHeight() {
|
||||
return _height;
|
||||
}
|
||||
|
||||
public void setHeight(double value) {
|
||||
_height = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the width.
|
||||
*
|
||||
* <value>The width.</value>
|
||||
*/
|
||||
public double getWidth() {
|
||||
return _width;
|
||||
}
|
||||
|
||||
public void setWidth(double value) {
|
||||
_width = value;
|
||||
}
|
||||
|
||||
public boolean equals(ImageSize size) {
|
||||
return (Double.valueOf(getWidth())).equals(size.getWidth()) && (Double.valueOf(getHeight())).equals(size.getHeight());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format("%1$s-%2$s", getWidth(), getHeight());
|
||||
}
|
||||
|
||||
public ImageSize() {
|
||||
}
|
||||
|
||||
public ImageSize(String value) {
|
||||
_width = 0;
|
||||
|
||||
_height = 0;
|
||||
|
||||
ParseValue(value);
|
||||
}
|
||||
|
||||
public ImageSize(int width, int height) {
|
||||
_width = width;
|
||||
_height = height;
|
||||
}
|
||||
|
||||
private void ParseValue(String value) {
|
||||
if (Utils.isNonEmpty(value)) {
|
||||
String[] parts = value.split("[-]", -1);
|
||||
|
||||
if (parts.length == 2) {
|
||||
try {
|
||||
_width = Double.parseDouble(parts[0]);
|
||||
_height = Double.parseDouble(parts[1]);
|
||||
} catch (NumberFormatException ex) {
|
||||
Timber.e(ex, "Could not parse width and height");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ImageSize clone() {
|
||||
ImageSize varCopy = new ImageSize();
|
||||
|
||||
varCopy._height = this._height;
|
||||
varCopy._width = this._width;
|
||||
|
||||
return varCopy;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,47 @@
|
||||
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.constant.ContainerTypes;
|
||||
import org.jellyfin.androidtv.constant.MediaTypes;
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
|
||||
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.mediainfo.TransportStreamTimestamp;
|
||||
import org.jellyfin.apiclient.model.session.PlayMethod;
|
||||
|
||||
/**
|
||||
* 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 +55,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 +95,236 @@ public class StreamInfo {
|
||||
Container = value;
|
||||
}
|
||||
|
||||
private String SubProtocol;
|
||||
|
||||
public final String getSubProtocol() {
|
||||
return SubProtocol;
|
||||
}
|
||||
|
||||
public final void setSubProtocol(String value) {
|
||||
SubProtocol = value;
|
||||
}
|
||||
|
||||
private long StartPositionTicks;
|
||||
|
||||
public final long getStartPositionTicks() {
|
||||
return StartPositionTicks;
|
||||
}
|
||||
|
||||
public final void setStartPositionTicks(long value) {
|
||||
StartPositionTicks = value;
|
||||
}
|
||||
|
||||
private String VideoCodec;
|
||||
|
||||
public final String getVideoCodec() {
|
||||
return VideoCodec;
|
||||
}
|
||||
|
||||
public final void setVideoCodec(String value) {
|
||||
VideoCodec = value;
|
||||
}
|
||||
|
||||
private String VideoProfile;
|
||||
|
||||
public final String getVideoProfile() {
|
||||
return VideoProfile;
|
||||
}
|
||||
|
||||
public final void setVideoProfile(String value) {
|
||||
VideoProfile = value;
|
||||
}
|
||||
|
||||
private boolean CopyTimestamps;
|
||||
|
||||
public final boolean getCopyTimestamps() {
|
||||
return CopyTimestamps;
|
||||
}
|
||||
|
||||
public final void setCopyTimestamps(boolean value) {
|
||||
CopyTimestamps = value;
|
||||
}
|
||||
|
||||
private boolean ForceLiveStream;
|
||||
|
||||
public final boolean getForceLiveStream() {
|
||||
return ForceLiveStream;
|
||||
}
|
||||
|
||||
public final void setForceLiveStream(boolean value) {
|
||||
ForceLiveStream = value;
|
||||
}
|
||||
|
||||
private boolean EnableSubtitlesInManifest;
|
||||
|
||||
public final boolean getEnableSubtitlesInManifest() {
|
||||
return EnableSubtitlesInManifest;
|
||||
}
|
||||
|
||||
public final void setEnableSubtitlesInManifest(boolean value) {
|
||||
EnableSubtitlesInManifest = value;
|
||||
}
|
||||
|
||||
private String[] AudioCodecs;
|
||||
|
||||
public final String[] getAudioCodecs() {
|
||||
return AudioCodecs;
|
||||
}
|
||||
|
||||
public final void setAudioCodecs(String[] value) {
|
||||
AudioCodecs = value;
|
||||
}
|
||||
|
||||
private Integer AudioStreamIndex = null;
|
||||
|
||||
public final Integer getAudioStreamIndex() {
|
||||
return AudioStreamIndex;
|
||||
}
|
||||
|
||||
public final void setAudioStreamIndex(Integer value) {
|
||||
AudioStreamIndex = value;
|
||||
}
|
||||
|
||||
private Integer SubtitleStreamIndex = null;
|
||||
|
||||
public final Integer getSubtitleStreamIndex() {
|
||||
return SubtitleStreamIndex;
|
||||
}
|
||||
|
||||
public final void setSubtitleStreamIndex(Integer value) {
|
||||
SubtitleStreamIndex = value;
|
||||
}
|
||||
|
||||
private Integer TranscodingMaxAudioChannels = null;
|
||||
|
||||
public final Integer getTranscodingMaxAudioChannels() {
|
||||
return TranscodingMaxAudioChannels;
|
||||
}
|
||||
|
||||
public final void setTranscodingMaxAudioChannels(Integer value) {
|
||||
TranscodingMaxAudioChannels = value;
|
||||
}
|
||||
|
||||
private Integer MaxAudioChannels = null;
|
||||
|
||||
public final Integer getMaxAudioChannels() {
|
||||
return MaxAudioChannels;
|
||||
}
|
||||
|
||||
public final void setMaxAudioChannels(Integer value) {
|
||||
MaxAudioChannels = value;
|
||||
}
|
||||
|
||||
private Integer AudioBitrate = null;
|
||||
|
||||
public final Integer getAudioBitrate() {
|
||||
return AudioBitrate;
|
||||
}
|
||||
|
||||
public final void setAudioBitrate(Integer value) {
|
||||
AudioBitrate = value;
|
||||
}
|
||||
|
||||
private Integer VideoBitrate = null;
|
||||
|
||||
public final Integer getVideoBitrate() {
|
||||
return VideoBitrate;
|
||||
}
|
||||
|
||||
public final void setVideoBitrate(Integer value) {
|
||||
VideoBitrate = value;
|
||||
}
|
||||
|
||||
private Integer VideoLevel = null;
|
||||
|
||||
public final Integer getVideoLevel() {
|
||||
return VideoLevel;
|
||||
}
|
||||
|
||||
public final void setVideoLevel(Integer value) {
|
||||
VideoLevel = value;
|
||||
}
|
||||
|
||||
private Integer MaxWidth = null;
|
||||
|
||||
public final Integer getMaxWidth() {
|
||||
return MaxWidth;
|
||||
}
|
||||
|
||||
public final void setMaxWidth(Integer value) {
|
||||
MaxWidth = value;
|
||||
}
|
||||
|
||||
private Integer MaxHeight = null;
|
||||
|
||||
public final Integer getMaxHeight() {
|
||||
return MaxHeight;
|
||||
}
|
||||
|
||||
public final void setMaxHeight(Integer value) {
|
||||
MaxHeight = value;
|
||||
}
|
||||
|
||||
private Integer MaxVideoBitDepth = null;
|
||||
|
||||
public final Integer getMaxVideoBitDepth() {
|
||||
return MaxVideoBitDepth;
|
||||
}
|
||||
|
||||
public final void setMaxVideoBitDepth(Integer value) {
|
||||
MaxVideoBitDepth = value;
|
||||
}
|
||||
|
||||
private Integer MaxRefFrames = null;
|
||||
|
||||
public final Integer getMaxRefFrames() {
|
||||
return MaxRefFrames;
|
||||
}
|
||||
|
||||
public final void setMaxRefFrames(Integer value) {
|
||||
MaxRefFrames = value;
|
||||
}
|
||||
|
||||
private Float MaxFramerate = null;
|
||||
|
||||
public final Float getMaxFramerate() {
|
||||
return MaxFramerate;
|
||||
}
|
||||
|
||||
public final void setMaxFramerate(Float value) {
|
||||
MaxFramerate = value;
|
||||
}
|
||||
|
||||
private DeviceProfile DeviceProfile;
|
||||
|
||||
public final DeviceProfile getDeviceProfile() {
|
||||
return DeviceProfile;
|
||||
}
|
||||
|
||||
public final void setDeviceProfile(DeviceProfile value) {
|
||||
DeviceProfile = value;
|
||||
}
|
||||
|
||||
private String DeviceProfileId;
|
||||
|
||||
public final String getDeviceProfileId() {
|
||||
return DeviceProfileId;
|
||||
}
|
||||
|
||||
public final void setDeviceProfileId(String value) {
|
||||
DeviceProfileId = 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 +335,26 @@ public class StreamInfo {
|
||||
RunTimeTicks = value;
|
||||
}
|
||||
|
||||
private TranscodeSeekInfo TranscodeSeekInfo = getTranscodeSeekInfo().values()[0];
|
||||
|
||||
public final TranscodeSeekInfo getTranscodeSeekInfo() {
|
||||
return TranscodeSeekInfo;
|
||||
}
|
||||
|
||||
public final void setTranscodeSeekInfo(TranscodeSeekInfo value) {
|
||||
TranscodeSeekInfo = value;
|
||||
}
|
||||
|
||||
private boolean EstimateContentLength;
|
||||
|
||||
public final boolean getEstimateContentLength() {
|
||||
return EstimateContentLength;
|
||||
}
|
||||
|
||||
public final void setEstimateContentLength(boolean value) {
|
||||
EstimateContentLength = value;
|
||||
}
|
||||
|
||||
private MediaSourceInfo MediaSource;
|
||||
|
||||
public final MediaSourceInfo getMediaSource() {
|
||||
@@ -70,10 +365,24 @@ 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;
|
||||
}
|
||||
|
||||
public final void setSubtitleDeliveryMethod(SubtitleDeliveryMethod value) {
|
||||
SubtitleDeliveryMethod = value;
|
||||
}
|
||||
|
||||
private String SubtitleFormat;
|
||||
|
||||
public final String getSubtitleFormat() {
|
||||
return SubtitleFormat;
|
||||
}
|
||||
|
||||
public final void setSubtitleFormat(String value) {
|
||||
SubtitleFormat = value;
|
||||
}
|
||||
|
||||
private String PlaySessionId;
|
||||
@@ -86,18 +395,526 @@ public class StreamInfo {
|
||||
PlaySessionId = value;
|
||||
}
|
||||
|
||||
private ArrayList<MediaSourceInfo> AllMediaSources;
|
||||
|
||||
public final ArrayList<MediaSourceInfo> getAllMediaSources() {
|
||||
return AllMediaSources;
|
||||
}
|
||||
|
||||
public final void setAllMediaSources(ArrayList<MediaSourceInfo> value) {
|
||||
AllMediaSources = value;
|
||||
}
|
||||
|
||||
public final String getMediaSourceId() {
|
||||
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);
|
||||
}
|
||||
|
||||
public final String ToDlnaUrl(String baseUrl, String accessToken) {
|
||||
if (getPlayMethod() == PlayMethod.DirectPlay) {
|
||||
return getMediaSource().getPath();
|
||||
}
|
||||
|
||||
String dlnaCommand = BuildDlnaParam(this, accessToken);
|
||||
return GetUrl(baseUrl, dlnaCommand);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (MediaTypes.HLS.equalsIgnoreCase(getSubProtocol())) {
|
||||
return String.format("%1$s/audio/%2$s/master.m3u8?%3$s", baseUrl, getItemId(), queryString);
|
||||
}
|
||||
|
||||
return String.format("%1$s/audio/%2$s/stream%3$s?%4$s", baseUrl, getItemId(), extension, queryString);
|
||||
}
|
||||
|
||||
if (MediaTypes.HLS.equalsIgnoreCase(getSubProtocol())) {
|
||||
return String.format("%1$s/videos/%2$s/master.m3u8?%3$s", baseUrl, getItemId(), queryString);
|
||||
}
|
||||
|
||||
return String.format("%1$s/videos/%2$s/stream%3$s?%4$s", baseUrl, getItemId(), extension, queryString);
|
||||
}
|
||||
|
||||
private static String BuildDlnaParam(StreamInfo item, String accessToken) {
|
||||
ArrayList<String> list = new ArrayList<String>();
|
||||
|
||||
for (NameValuePair pair : BuildParams(item, accessToken, true)) {
|
||||
list.add(pair.getValue());
|
||||
}
|
||||
|
||||
return String.format("Params=%1$s", Utils.join(";", list));
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
String tempVar = item.getDeviceProfileId();
|
||||
list.add(new NameValuePair("DeviceProfileId", (tempVar != null) ? tempVar : ""));
|
||||
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()));
|
||||
String tempVar4 = item.getVideoCodec();
|
||||
list.add(new NameValuePair("VideoCodec", (tempVar4 != null) ? tempVar4 : ""));
|
||||
list.add(new NameValuePair("AudioCodec", audioCodecs));
|
||||
list.add(new NameValuePair("AudioStreamIndex", item.getAudioStreamIndex() != null ? String.valueOf(item.getAudioStreamIndex()) : ""));
|
||||
list.add(new NameValuePair("SubtitleStreamIndex", item.getSubtitleStreamIndex() != null && item.getSubtitleDeliveryMethod() != org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod.External ? String.valueOf(item.getSubtitleStreamIndex()) : ""));
|
||||
list.add(new NameValuePair("VideoBitrate", item.getVideoBitrate() != null ? String.valueOf(item.getVideoBitrate()) : ""));
|
||||
list.add(new NameValuePair("AudioBitrate", item.getAudioBitrate() != null ? String.valueOf(item.getAudioBitrate()) : ""));
|
||||
list.add(new NameValuePair("MaxAudioChannels", item.getMaxAudioChannels() != null ? String.valueOf(item.getMaxAudioChannels()) : ""));
|
||||
list.add(new NameValuePair("MaxFramerate", item.getMaxFramerate() != null ? String.valueOf(item.getMaxFramerate()) : ""));
|
||||
list.add(new NameValuePair("MaxWidth", item.getMaxWidth() != null ? String.valueOf(item.getMaxWidth()) : ""));
|
||||
list.add(new NameValuePair("MaxHeight", item.getMaxHeight() != null ? String.valueOf(item.getMaxHeight()) : ""));
|
||||
|
||||
if (MediaTypes.HLS.equalsIgnoreCase(item.getSubProtocol()) && !item.getForceLiveStream()) {
|
||||
list.add(new NameValuePair(START_TIME_TICKS, ""));
|
||||
} else {
|
||||
list.add(new NameValuePair(START_TIME_TICKS, String.valueOf(item.getStartPositionTicks())));
|
||||
}
|
||||
|
||||
list.add(new NameValuePair("Level", item.getVideoLevel() != null ? String.valueOf(item.getVideoLevel()) : ""));
|
||||
|
||||
list.add(new NameValuePair("MaxRefFrames", item.getMaxRefFrames() != null ? String.valueOf(item.getMaxRefFrames()) : ""));
|
||||
list.add(new NameValuePair("MaxVideoBitDepth", item.getMaxVideoBitDepth() != null ? String.valueOf(item.getMaxVideoBitDepth()) : ""));
|
||||
String tempVar5 = item.getVideoProfile();
|
||||
list.add(new NameValuePair("Profile", (tempVar5 != null) ? tempVar5 : ""));
|
||||
|
||||
// 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", Boolean.valueOf(item.getCopyTimestamps()).toString().toLowerCase()));
|
||||
list.add(new NameValuePair("ForceLiveStream", Boolean.valueOf(item.getForceLiveStream()).toString().toLowerCase()));
|
||||
list.add(new NameValuePair("SubtitleMethod", item.getSubtitleStreamIndex() != null && item.getSubtitleDeliveryMethod() != org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod.External ? item.getSubtitleDeliveryMethod().toString() : ""));
|
||||
|
||||
list.add(new NameValuePair("TranscodingMaxAudioChannels", item.getTranscodingMaxAudioChannels() != null ? String.valueOf(item.getTranscodingMaxAudioChannels()) : ""));
|
||||
list.add(new NameValuePair("EnableSubtitlesInManifest", Boolean.valueOf(item.getEnableSubtitlesInManifest()).toString().toLowerCase()));
|
||||
|
||||
String tempVar7 = item.getMediaSource().getETag();
|
||||
list.add(new NameValuePair("Tag", (tempVar7 != null) ? tempVar7 : ""));
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public final ArrayList<SubtitleStreamInfo> GetExternalSubtitles(boolean includeSelectedTrackOnly, String baseUrl, String accessToken) {
|
||||
return GetExternalSubtitles(includeSelectedTrackOnly, false, baseUrl, accessToken);
|
||||
}
|
||||
|
||||
public final ArrayList<SubtitleStreamInfo> GetExternalSubtitles(boolean includeSelectedTrackOnly, boolean enableAllProfiles, String baseUrl, String accessToken) {
|
||||
ArrayList<SubtitleStreamInfo> list = GetSubtitleProfiles(includeSelectedTrackOnly, enableAllProfiles, baseUrl, accessToken);
|
||||
ArrayList<SubtitleStreamInfo> newList = new ArrayList<SubtitleStreamInfo>();
|
||||
|
||||
// First add the selected track
|
||||
for (SubtitleStreamInfo stream : list) {
|
||||
if (stream.getDeliveryMethod() == SubtitleDeliveryMethod.External) {
|
||||
newList.add(stream);
|
||||
}
|
||||
}
|
||||
|
||||
return newList;
|
||||
}
|
||||
|
||||
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 = MediaTypes.HLS.equalsIgnoreCase(getSubProtocol()) ? 0 : (getPlayMethod() == PlayMethod.Transcode && !getCopyTimestamps() ? getStartPositionTicks() : 0);
|
||||
|
||||
// First add the selected track
|
||||
if (getSubtitleStreamIndex() != null) {
|
||||
for (MediaStream stream : getMediaSource().getMediaStreams()) {
|
||||
if (stream.getType() == MediaStreamType.Subtitle && stream.getIndex() == getSubtitleStreamIndex()) {
|
||||
AddSubtitleProfiles(list, stream, enableAllProfiles, baseUrl, accessToken, startPositionTicks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!includeSelectedTrackOnly) {
|
||||
for (MediaStream stream : getMediaSource().getMediaStreams()) {
|
||||
if (stream.getType() == MediaStreamType.Subtitle && (getSubtitleStreamIndex() == null || stream.getIndex() != getSubtitleStreamIndex())) {
|
||||
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();
|
||||
tempVar.setIsForced(stream.getIsForced());
|
||||
tempVar.setLanguage(stream.getLanguage());
|
||||
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);
|
||||
}
|
||||
|
||||
info.setIsExternalUrl(false);
|
||||
} else {
|
||||
info.setUrl(stream.getPath());
|
||||
info.setIsExternalUrl(true);
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the audio stream that will be used
|
||||
*/
|
||||
public final MediaStream getTargetAudioStream() {
|
||||
if (getMediaSource() != null) {
|
||||
return getMediaSource().GetDefaultAudioStream(getAudioStreamIndex());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the video stream that will be used
|
||||
*/
|
||||
public final MediaStream getTargetVideoStream() {
|
||||
if (getMediaSource() != null) {
|
||||
return getMediaSource().getVideoStream();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicts the audio sample rate that will be in the output stream
|
||||
*/
|
||||
public final Integer getTargetAudioSampleRate() {
|
||||
MediaStream stream = getTargetAudioStream();
|
||||
return stream == null ? null : stream.getSampleRate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicts the audio sample rate that will be in the output stream
|
||||
*/
|
||||
public final Integer getTargetVideoBitDepth() {
|
||||
MediaStream stream = getTargetVideoStream();
|
||||
return stream == null || !getIsDirectStream() ? null : stream.getBitDepth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the target reference frames.
|
||||
*
|
||||
* <value>The target reference frames.</value>
|
||||
*/
|
||||
public final Integer getTargetRefFrames() {
|
||||
MediaStream stream = getTargetVideoStream();
|
||||
return stream == null || !getIsDirectStream() ? null : stream.getRefFrames();
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicts the audio sample rate that will be in the output stream
|
||||
*/
|
||||
public final Float getTargetFramerate() {
|
||||
MediaStream stream = getTargetVideoStream();
|
||||
Float tempVar = stream.getAverageFrameRate();
|
||||
return getMaxFramerate() != null && !getIsDirectStream() ? getMaxFramerate() : stream == null ? null : (tempVar != null) ? tempVar : stream.getRealFrameRate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicts the audio sample rate that will be in the output stream
|
||||
*/
|
||||
public final Double getTargetVideoLevel() {
|
||||
MediaStream stream = getTargetVideoStream();
|
||||
return getVideoLevel() != null && !getIsDirectStream() ? getVideoLevel() : stream == null ? null : stream.getLevel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicts the audio sample rate that will be in the output stream
|
||||
*/
|
||||
public final Integer getTargetPacketLength() {
|
||||
MediaStream stream = getTargetVideoStream();
|
||||
return !getIsDirectStream() ? null : stream == null ? null : stream.getPacketLength();
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicts the audio sample rate that will be in the output stream
|
||||
*/
|
||||
public final String getTargetVideoProfile() {
|
||||
MediaStream stream = getTargetVideoStream();
|
||||
return !Utils.isEmpty(getVideoProfile()) && !getIsDirectStream() ? getVideoProfile() : stream == null ? null : stream.getProfile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the target video codec tag.
|
||||
*
|
||||
* <value>The target video codec tag.</value>
|
||||
*/
|
||||
public final String getTargetVideoCodecTag() {
|
||||
MediaStream stream = getTargetVideoStream();
|
||||
return !getIsDirectStream() ? null : stream == null ? null : stream.getCodecTag();
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicts the audio bitrate that will be in the output stream
|
||||
*/
|
||||
public final Integer getTargetAudioBitrate() {
|
||||
MediaStream stream = getTargetAudioStream();
|
||||
return getAudioBitrate() != null && !getIsDirectStream() ? getAudioBitrate() : stream == null ? null : stream.getBitRate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicts the audio channels that will be in the output stream
|
||||
*/
|
||||
public final Integer getTargetAudioChannels() {
|
||||
MediaStream stream = getTargetAudioStream();
|
||||
Integer streamChannels = stream == null ? null : stream.getChannels();
|
||||
|
||||
if (getMaxAudioChannels() != null && !getIsDirectStream()) {
|
||||
if (streamChannels != null) {
|
||||
return Math.min(getMaxAudioChannels(), streamChannels);
|
||||
}
|
||||
|
||||
return getMaxAudioChannels();
|
||||
}
|
||||
|
||||
return streamChannels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicts the audio codec that will be in the output stream
|
||||
*/
|
||||
public final String getTargetAudioCodec() {
|
||||
MediaStream stream = getTargetAudioStream();
|
||||
|
||||
String inputCodec = stream == null ? null : stream.getCodec();
|
||||
|
||||
if (getIsDirectStream()) {
|
||||
return inputCodec;
|
||||
}
|
||||
|
||||
for (String codec : getAudioCodecs()) {
|
||||
if (codec.equalsIgnoreCase(inputCodec)) {
|
||||
return codec;
|
||||
}
|
||||
}
|
||||
|
||||
return getAudioCodecs().length == 0 ? null : getAudioCodecs()[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicts the audio channels that will be in the output stream
|
||||
*/
|
||||
public final Long getTargetSize() {
|
||||
if (getIsDirectStream()) {
|
||||
return getMediaSource().getSize();
|
||||
}
|
||||
|
||||
if (getRunTimeTicks() != null) {
|
||||
Integer totalBitrate = getTargetTotalBitrate();
|
||||
|
||||
double totalSeconds = getRunTimeTicks();
|
||||
// Convert to ms
|
||||
totalSeconds /= 10000;
|
||||
// Convert to seconds
|
||||
totalSeconds /= 1000;
|
||||
|
||||
return totalBitrate != null ? java.lang.Math.round(totalBitrate * totalSeconds) : (Long) null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public final Integer getTargetVideoBitrate() {
|
||||
MediaStream stream = getTargetVideoStream();
|
||||
|
||||
return getVideoBitrate() != null && !getIsDirectStream() ? getVideoBitrate() : stream == null ? null : stream.getBitRate();
|
||||
}
|
||||
|
||||
public final TransportStreamTimestamp getTargetTimestamp() {
|
||||
TransportStreamTimestamp defaultValue = ContainerTypes.M2TS.equalsIgnoreCase(getContainer()) ? TransportStreamTimestamp.Valid : TransportStreamTimestamp.None;
|
||||
TransportStreamTimestamp tempVar = getMediaSource().getTimestamp();
|
||||
return !getIsDirectStream() ? defaultValue : getMediaSource() == null ? defaultValue : (tempVar != null) ? tempVar : TransportStreamTimestamp.None;
|
||||
}
|
||||
|
||||
public final Integer getTargetTotalBitrate() {
|
||||
Integer tempVar = getTargetAudioBitrate();
|
||||
Integer tempVar2 = getTargetVideoBitrate();
|
||||
return ((tempVar != null) ? tempVar : 0) + ((tempVar2 != null) ? tempVar2 : 0);
|
||||
}
|
||||
|
||||
public final Boolean getIsTargetAnamorphic() {
|
||||
if (getIsDirectStream()) {
|
||||
return getTargetVideoStream() == null ? null : getTargetVideoStream().getIsAnamorphic();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public final Integer getTargetWidth() {
|
||||
MediaStream videoStream = getTargetVideoStream();
|
||||
|
||||
if (videoStream != null && videoStream.getWidth() != null && videoStream.getHeight() != null) {
|
||||
ImageSize tempVar = new ImageSize();
|
||||
tempVar.setWidth(videoStream.getWidth());
|
||||
tempVar.setHeight(videoStream.getHeight());
|
||||
ImageSize size = tempVar.clone();
|
||||
|
||||
Double maxWidth = getMaxWidth() != null ? (double) getMaxWidth() : (Double) null;
|
||||
Double maxHeight = getMaxHeight() != null ? (double) getMaxHeight() : (Double) null;
|
||||
|
||||
ImageSize newSize = DrawingUtils.Resize(size.clone(), null, null, maxWidth, maxHeight);
|
||||
|
||||
return (int) newSize.getWidth();
|
||||
}
|
||||
|
||||
return getMaxWidth();
|
||||
}
|
||||
|
||||
public final Integer getTargetHeight() {
|
||||
MediaStream videoStream = getTargetVideoStream();
|
||||
|
||||
if (videoStream != null && videoStream.getWidth() != null && videoStream.getHeight() != null) {
|
||||
ImageSize tempVar = new ImageSize();
|
||||
tempVar.setWidth(videoStream.getWidth());
|
||||
tempVar.setHeight(videoStream.getHeight());
|
||||
ImageSize size = tempVar.clone();
|
||||
|
||||
Double maxWidth = getMaxWidth() != null ? (double) getMaxWidth() : (Double) null;
|
||||
Double maxHeight = getMaxHeight() != null ? (double) getMaxHeight() : (Double) null;
|
||||
|
||||
ImageSize newSize = DrawingUtils.Resize(size.clone(), null, null, maxWidth, maxHeight);
|
||||
|
||||
return (int) newSize.getHeight();
|
||||
}
|
||||
|
||||
return getMaxHeight();
|
||||
}
|
||||
|
||||
public final Integer getTargetVideoStreamCount() {
|
||||
if (getIsDirectStream()) {
|
||||
return GetMediaStreamCount(MediaStreamType.Video, Integer.MAX_VALUE);
|
||||
}
|
||||
return GetMediaStreamCount(MediaStreamType.Video, 1);
|
||||
}
|
||||
|
||||
public final Integer getTargetAudioStreamCount() {
|
||||
if (getIsDirectStream()) {
|
||||
return GetMediaStreamCount(MediaStreamType.Audio, Integer.MAX_VALUE);
|
||||
}
|
||||
return GetMediaStreamCount(MediaStreamType.Audio, 1);
|
||||
}
|
||||
|
||||
private Integer GetMediaStreamCount(MediaStreamType type, int limit) {
|
||||
Integer count = getMediaSource().GetStreamCount(type);
|
||||
|
||||
if (count != null) {
|
||||
count = Math.min(count, limit);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public final ArrayList<MediaStream> GetSelectableAudioStreams() {
|
||||
return GetSelectableStreams(MediaStreamType.Audio);
|
||||
}
|
||||
|
||||
public final ArrayList<MediaStream> GetSelectableSubtitleStreams() {
|
||||
return GetSelectableStreams(MediaStreamType.Subtitle);
|
||||
}
|
||||
|
||||
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,20 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
|
||||
@Deprecated
|
||||
public class StreamInfoSorter
|
||||
{
|
||||
public static ArrayList<StreamInfo> SortMediaSources(ArrayList<StreamInfo> streams, Integer maxBitrate)
|
||||
{
|
||||
ChainedComparator<StreamInfo> comparator = new ChainedComparator<>(
|
||||
new StreamInfoSorterComparator(0),
|
||||
new StreamInfoSorterComparator(1),
|
||||
new StreamInfoSorterComparator(2)
|
||||
);
|
||||
|
||||
Collections.sort(streams, comparator);
|
||||
return streams;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.jellyfin.androidtv.data.compat;
|
||||
|
||||
import org.jellyfin.apiclient.model.mediainfo.MediaProtocol;
|
||||
import org.jellyfin.apiclient.model.session.PlayMethod;
|
||||
|
||||
@Deprecated
|
||||
public class StreamInfoSorterComparator extends BaseStreamInfoSorter {
|
||||
private int level;
|
||||
|
||||
public StreamInfoSorterComparator(int level) {
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getValue(StreamInfo info) {
|
||||
switch (level) {
|
||||
case 0: {
|
||||
// Nothing beats direct playing a file
|
||||
if (info.getPlayMethod() == PlayMethod.DirectPlay && info.getMediaSource().getProtocol() == MediaProtocol.File) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
case 1: {
|
||||
switch (info.getPlayMethod()) {
|
||||
// Let's assume direct streaming a file is just as desirable as direct playing a remote url
|
||||
case DirectStream:
|
||||
case DirectPlay:
|
||||
return 0;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
case 2: {
|
||||
switch (info.getMediaSource().getProtocol()) {
|
||||
case File:
|
||||
return 0;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Unrecognized level");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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 Language;
|
||||
|
||||
public final String getLanguage() {
|
||||
return Language;
|
||||
}
|
||||
|
||||
public final void setLanguage(String value) {
|
||||
Language = value;
|
||||
}
|
||||
|
||||
private String Name;
|
||||
|
||||
public final String getName() {
|
||||
return Name;
|
||||
}
|
||||
|
||||
public final void setName(String value) {
|
||||
Name = value;
|
||||
}
|
||||
|
||||
private boolean IsForced;
|
||||
|
||||
public final boolean getIsForced() {
|
||||
return IsForced;
|
||||
}
|
||||
|
||||
public final void setIsForced(boolean value) {
|
||||
IsForced = 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;
|
||||
}
|
||||
|
||||
private boolean IsExternalUrl;
|
||||
|
||||
public final boolean getIsExternalUrl() {
|
||||
return IsExternalUrl;
|
||||
}
|
||||
|
||||
public final void setIsExternalUrl(boolean value) {
|
||||
IsExternalUrl = 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,254 +0,0 @@
|
||||
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.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.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 timber.log.Timber
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class SocketHandler(
|
||||
private val context: Context,
|
||||
private val api: ApiClient,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
} catch (err: ApiClientException) {
|
||||
Timber.e(err, "Unable to update capabilities")
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribe(coroutineScope: CoroutineScope) = api.webSocket.apply {
|
||||
// Library
|
||||
subscribe<LibraryChangedMessage>()
|
||||
.onEach { message -> message.data?.let(::onLibraryChanged) }
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
// 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)
|
||||
|
||||
// General commands
|
||||
subscribeGeneralCommand(GeneralCommandType.DISPLAY_CONTENT)
|
||||
.onEach { message ->
|
||||
val itemId by message
|
||||
val itemType by message
|
||||
|
||||
val itemUuid = itemId?.toUUIDOrNull()
|
||||
val itemKind = itemType?.let { type ->
|
||||
BaseItemKind.entries.find { value ->
|
||||
value.serialName.equals(type, true)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private fun onLibraryChanged(info: LibraryUpdateInfo) {
|
||||
Timber.d(buildString {
|
||||
appendLine("Library changed.")
|
||||
appendLine("Added ${info.itemsAdded.size} items")
|
||||
appendLine("Removed ${info.itemsRemoved.size} items")
|
||||
appendLine("Updated ${info.itemsUpdated.size} items")
|
||||
})
|
||||
|
||||
if (info.itemsAdded.any() || info.itemsRemoved.any())
|
||||
dataRefreshService.lastLibraryChange = Instant.now()
|
||||
}
|
||||
|
||||
private fun onPlayMessage(message: PlayMessage) {
|
||||
val itemIds = message.data?.itemIds ?: return
|
||||
|
||||
runCatching {
|
||||
playbackHelper.retrieveAndPlay(
|
||||
itemIds,
|
||||
false,
|
||||
message.data?.startPositionTicks,
|
||||
message.data?.startIndex,
|
||||
context
|
||||
)
|
||||
}.onFailure { Timber.w(it, "Failed to start playback") }
|
||||
}
|
||||
|
||||
@Suppress("ComplexMethod")
|
||||
private suspend fun onPlayStateMessage(message: PlaystateMessage) = withContext(Dispatchers.Main) {
|
||||
Timber.i("Received PlayStateMessage with command ${message.data?.command}")
|
||||
|
||||
// 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 suspend fun onDisplayContent(itemId: UUID, itemKind: BaseItemKind) = withContext(Dispatchers.Main) {
|
||||
val playbackController = playbackControllerContainer.playbackController
|
||||
|
||||
if (playbackController?.isPlaying == true || playbackController?.isPaused == true) {
|
||||
Timber.i("Not launching $itemId: playback in progress")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDisplayMessage(header: String?, text: String?) {
|
||||
val toastMessage = buildString {
|
||||
if (!header.isNullOrBlank()) append(header, ": ")
|
||||
append(text)
|
||||
}
|
||||
|
||||
runBlocking(Dispatchers.Main) {
|
||||
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TICKS_TO_MS = 10000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package org.jellyfin.androidtv.data.eventhandling;
|
||||
|
||||
import static org.koin.java.KoinJavaComponent.get;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Handler;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.jellyfin.androidtv.R;
|
||||
import org.jellyfin.androidtv.TvApp;
|
||||
import org.jellyfin.androidtv.data.model.DataRefreshService;
|
||||
import org.jellyfin.androidtv.data.querying.StdItemQuery;
|
||||
import org.jellyfin.androidtv.ui.itemhandling.BaseRowItem;
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher;
|
||||
import org.jellyfin.androidtv.ui.playback.MediaManager;
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackController;
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackLauncher;
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackOverlayActivity;
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
import org.jellyfin.androidtv.util.apiclient.PlaybackHelper;
|
||||
import org.jellyfin.apiclient.interaction.ApiClient;
|
||||
import org.jellyfin.apiclient.interaction.ApiEventListener;
|
||||
import org.jellyfin.apiclient.interaction.Response;
|
||||
import org.jellyfin.apiclient.model.dto.BaseItemDto;
|
||||
import org.jellyfin.apiclient.model.entities.LibraryUpdateInfo;
|
||||
import org.jellyfin.apiclient.model.querying.ItemFields;
|
||||
import org.jellyfin.apiclient.model.querying.ItemsResult;
|
||||
import org.jellyfin.apiclient.model.session.BrowseRequest;
|
||||
import org.jellyfin.apiclient.model.session.MessageCommand;
|
||||
import org.jellyfin.apiclient.model.session.PlayRequest;
|
||||
import org.jellyfin.apiclient.model.session.PlaystateRequest;
|
||||
import org.jellyfin.apiclient.model.session.SessionInfoDto;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import timber.log.Timber;
|
||||
|
||||
public class TvApiEventListener extends ApiEventListener {
|
||||
private final DataRefreshService dataRefreshService;
|
||||
private final MediaManager mediaManager;
|
||||
|
||||
public TvApiEventListener(DataRefreshService dataRefreshService, MediaManager mediaManager) {
|
||||
this.dataRefreshService = dataRefreshService;
|
||||
this.mediaManager = mediaManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlaybackStopped(ApiClient client, SessionInfoDto info) {
|
||||
TvApp app = TvApp.getApplication();
|
||||
Timber.d("Got Playback stopped message from server");
|
||||
if (info.getUserId().equals(app.getCurrentUser().getId())) {
|
||||
dataRefreshService.setLastPlayback(System.currentTimeMillis());
|
||||
if (info.getNowPlayingItem() == null) return;
|
||||
switch (info.getNowPlayingItem().getType()) {
|
||||
case "Movie":
|
||||
dataRefreshService.setLastMoviePlayback(System.currentTimeMillis());
|
||||
break;
|
||||
case "Episode":
|
||||
dataRefreshService.setLastTvPlayback(System.currentTimeMillis());
|
||||
break;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLibraryChanged(ApiClient client, LibraryUpdateInfo info) {
|
||||
Timber.d("Library Changed. Added %o items. Removed %o items. Changed %o items.", info.getItemsAdded().size(), info.getItemsRemoved().size(), info.getItemsUpdated().size());
|
||||
if (info.getItemsAdded().size() > 0 || info.getItemsRemoved().size() > 0)
|
||||
dataRefreshService.setLastLibraryChange(System.currentTimeMillis());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlaystateCommand(ApiClient client, PlaystateRequest command) {
|
||||
PlaybackController playbackController = TvApp.getApplication().getPlaybackController();
|
||||
|
||||
switch (command.getCommand()) {
|
||||
case Stop:
|
||||
if (mediaManager.isPlayingAudio())
|
||||
mediaManager.stopAudio();
|
||||
else {
|
||||
Activity currentActivity = TvApp.getApplication().getCurrentActivity();
|
||||
|
||||
if(currentActivity instanceof PlaybackOverlayActivity)
|
||||
currentActivity.finish();
|
||||
}
|
||||
break;
|
||||
case Pause:
|
||||
if (mediaManager.isPlayingAudio())
|
||||
mediaManager.pauseAudio();
|
||||
else if(playbackController != null)
|
||||
playbackController.playPause();
|
||||
break;
|
||||
case Unpause:
|
||||
if (mediaManager.hasAudioQueueItems())
|
||||
mediaManager.resumeAudio();
|
||||
else if(playbackController != null)
|
||||
playbackController.playPause();
|
||||
break;
|
||||
case NextTrack:
|
||||
if (mediaManager.hasAudioQueueItems())
|
||||
mediaManager.nextAudioItem();
|
||||
else if(playbackController != null)
|
||||
playbackController.next();
|
||||
break;
|
||||
case PreviousTrack:
|
||||
if (mediaManager.hasAudioQueueItems())
|
||||
mediaManager.prevAudioItem();
|
||||
else if(playbackController != null)
|
||||
playbackController.prev();
|
||||
break;
|
||||
case Seek:
|
||||
if (playbackController == null) break;
|
||||
|
||||
long pos = command.getSeekPositionTicks() / 10000;
|
||||
playbackController.seek(pos);
|
||||
break;
|
||||
case Rewind:
|
||||
if (playbackController == null) break;
|
||||
|
||||
playbackController.skip(-11000);
|
||||
break;
|
||||
case FastForward:
|
||||
if (playbackController == null) break;
|
||||
|
||||
playbackController.skip(30000);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBrowseCommand(ApiClient client, BrowseRequest command) {
|
||||
Timber.d("Browse command received");
|
||||
if (TvApp.getApplication().getCurrentActivity() == null || videoPlaying()) {
|
||||
Timber.i("Command ignored due to no activity or playback in progress");
|
||||
return;
|
||||
}
|
||||
client.GetItemAsync(command.getItemId(), TvApp.getApplication().getCurrentUser().getId(), new Response<BaseItemDto>() {
|
||||
@Override
|
||||
public void onResponse(BaseItemDto response) {
|
||||
//Create a rowItem and pass to our handler
|
||||
ItemLauncher.launch(new BaseRowItem(0, response), null, -1, TvApp.getApplication().getCurrentActivity(), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private boolean videoPlaying() {
|
||||
return (TvApp.getApplication().getPlaybackController() != null && (TvApp.getApplication().getPlaybackController().isPlaying() || TvApp.getApplication().getPlaybackController().isPaused()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayCommand(ApiClient client, PlayRequest command) {
|
||||
if (videoPlaying()) {
|
||||
TvApp.getApplication().getCurrentActivity().runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
Utils.showToast(TvApp.getApplication().getCurrentActivity(), TvApp.getApplication().getString(R.string.msg_remote_already_playing));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.getItemIds().length > 1) {
|
||||
Timber.i("Playing multiple items by remote request");
|
||||
if (TvApp.getApplication().getCurrentActivity() == null) {
|
||||
Timber.e("No current activity. Cannot play");
|
||||
return;
|
||||
}
|
||||
StdItemQuery query = new StdItemQuery(new ItemFields[]{
|
||||
ItemFields.MediaSources,
|
||||
ItemFields.ChildCount
|
||||
});
|
||||
query.setIds(command.getItemIds());
|
||||
get(ApiClient.class).GetItemsAsync(query, new Response<ItemsResult>() {
|
||||
@Override
|
||||
public void onResponse(ItemsResult response) {
|
||||
if (response.getItems() != null && response.getItems().length > 0) {
|
||||
PlaybackLauncher playbackLauncher = get(PlaybackLauncher.class);
|
||||
if (playbackLauncher.interceptPlayRequest(TvApp.getApplication(), response.getItems()[0])) return;
|
||||
|
||||
//peek at first item to see what type it is
|
||||
switch (response.getItems()[0].getMediaType()) {
|
||||
case "Video":
|
||||
Class activity = get(PlaybackLauncher.class).getPlaybackActivityClass(response.getItems()[0].getBaseItemType());
|
||||
mediaManager.setCurrentVideoQueue(Arrays.asList(response.getItems()));
|
||||
Intent intent = new Intent(TvApp.getApplication().getCurrentActivity(), activity);
|
||||
TvApp.getApplication().getCurrentActivity().startActivity(intent);
|
||||
break;
|
||||
case "Audio":
|
||||
mediaManager.playNow(Arrays.asList(response.getItems()));
|
||||
break;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} else {
|
||||
if (command.getItemIds().length > 0) {
|
||||
Timber.i("Playing single item by remote request");
|
||||
Context context = TvApp.getApplication().getCurrentActivity() != null ? TvApp.getApplication().getCurrentActivity() : TvApp.getApplication();
|
||||
PlaybackHelper.retrieveAndPlay(command.getItemIds()[0], false, command.getStartPositionTicks() != null ? command.getStartPositionTicks() : 0, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageCommand(ApiClient client, MessageCommand command) {
|
||||
new Handler(TvApp.getApplication().getMainLooper()).post(() -> Toast.makeText(TvApp.getApplication(), command.getText(), Toast.LENGTH_LONG).show());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user