Compare commits

..

17 Commits

Author SHA1 Message Date
cameron
3da856ec23 fix seekbar not updating when using ff/rw buttons
(cherry picked from commit 4a4efb2467)
2022-11-01 13:11:18 +01:00
Niels van Velzen
8148850206 Fix setExoPlayerTrack exception when allStreams is empty
(cherry picked from commit a73e04c216)
2022-11-01 13:11:08 +01:00
renovate[bot]
eb676a4ff5 Update dependency org.jellyfin.sdk:jellyfin-core to v1.3.7
(cherry picked from commit 7e386babde)
2022-11-01 13:10:33 +01:00
Niels van Velzen
7a478a77a4 Use MediaStream from SDK in MediaInfoCardView and InfoCardPresenter
(cherry picked from commit acc4533868)
2022-10-04 21:38:39 +02:00
Niels van Velzen
9723da673e Fix crash when media info contains a data stream 2022-10-04 20:25:32 +02:00
renovate[bot]
72e45a7b47 Update dependency org.jellyfin.sdk:jellyfin-core to v1.3.6
(cherry picked from commit 7fc9819b63)
2022-10-04 20:25:08 +02:00
Niels van Velzen
4e82f10f18 Use random device id when initializing QuickConnect
(cherry picked from commit 02b9fb60bb)
2022-09-06 19:15:07 +02:00
Niels van Velzen
c1b103e385 Set defaultFocusHighlightEnabled to false in TextUnderButton
(cherry picked from commit 8713cd5317)
2022-09-06 19:14:56 +02:00
Niels van Velzen
0fc8daf45e Set defaultFocusHighlightEnabled to false in DefaultCardView
Fixes an incompatibility with Android 13

(cherry picked from commit 352f472769)
2022-09-06 19:14:55 +02:00
renovate[bot]
bd98065766 Update dependency org.jellyfin.sdk:jellyfin-core to v1.3.5
(cherry picked from commit 1cafe6c539)
2022-08-20 10:31:03 +02:00
Niels van Velzen
4d68e3d31e Properly handle specials in item naming
(cherry picked from commit 14e7391bc2)
2022-08-20 10:30:53 +02:00
Niels van Velzen
9c33bb34ff Don't open library settings for unsupported types
Only collection types that use the grid should open the DisplayPreferencesScreen. Added a special case to open the Live TV (guide) settings as a bonus.

(cherry picked from commit 5bb1ee7819)
2022-08-20 10:30:41 +02:00
Niels van Velzen
9f7891f129 Fix ActivityLifecycleCallback calls on Android 9 and lower
(cherry picked from commit cd33bea725)
2022-08-20 10:30:32 +02:00
Niels van Velzen
11e5b9526d Migrate DefaultLifecycleObserver setup to androidx.startup
(cherry picked from commit a404b99ab1)
2022-08-20 10:30:13 +02:00
Niels van Velzen
a0fb27491c Allow app installation on external storage
(cherry picked from commit e592017602)
2022-08-20 10:30:04 +02:00
Niels van Velzen
b1babfe477 Fix alignment of ClockUserView in live TV guide
(cherry picked from commit 6960f808f2)
2022-08-20 10:29:54 +02:00
Niels van Velzen
ed3b92df08 Catch decoding exception in AuthenticationStore
Fixes corrupt JSON causing the app to crash

(cherry picked from commit 0074062633)
2022-08-20 10:29:46 +02:00
1081 changed files with 34734 additions and 56054 deletions

View File

@@ -2,17 +2,16 @@ root = true
[*]
insert_final_newline = true
max_line_length = 140
trim_trailing_whitespace = true
[{*.kts,*.kt}]
[{*.kts, *.kt}]
charset = utf-8
indent_style = tab
tab_width = 4
# Disable wildcard imports in IntelliJ/Android Studio
ij_kotlin_name_count_to_use_star_import = 1000
ij_kotlin_name_count_to_use_star_import_for_members = 1000
ij_kotlin_packages_to_use_import_on_demand = unset
ij_kotlin_packages_to_use_import_on_demand =
[*.md]
trim_trailing_whitespace = false
trim_trailing_whitespace = false

2
.gitattributes vendored
View File

@@ -5,5 +5,3 @@
CONTRIBUTORS.md merge=union
app/src/main/res/values*/strings.xml merge=union
design/src/main/kotlin/token/* linguist-generated

View File

@@ -4,24 +4,6 @@ labels:
- bug
- playback
body:
- type: checkboxes
id: before-posting
attributes:
label: "This issue respects the following points:"
description: All conditions are **required**.
options:
- label: I have discussed this issue in the [Jellyfin forum](https://forum.jellyfin.org/) and/or [troubleshooting chat](https://jellyfin.org/contact) before opening this issue.
required: true
- label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin-androidtv/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
required: true
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
required: true
- label: This report addresses only a single issue; If you encounter multiple issues, kindly create separate reports for each one.
required: true
- type: markdown
attributes:
value: |
## Bug information
- type: textarea
id: description
attributes:
@@ -38,38 +20,10 @@ body:
Instead, I expect …
validations:
required: true
- type: textarea
id: mediainfo
attributes:
label: Media info of the file
description: |
Please share the media information for the file causing issues. You can use a variety of tools to retrieve this information.
- Use ffprobe (`ffprobe ./file.mp4`)
- Copy the media info from the web interface
placeholder: Paste media info…
render: shell
- type: textarea
id: mediacapabilitiesreport
attributes:
label: Media capabilities report
description: |
Please share the media capability report generated from within the app. To do some follow these steps:
1. Open the Jellyfin for Android TV app
2. Connect to a server **that you have admin access to**
3. Go to Preferences -> Playback -> Advanced playback preferences
4. On the bottom, press the "Report playback capabilities to server" button
5. Go to the logs in your Jellyfin dashboard (in your browser)
6. Open the newest log file, it should be named similar to `upload_Jellyfin Android TV[...].log`
7. Press the "Download" button and paste the file contents
placeholder: Paste capability report contents…
- type: markdown
attributes:
value: |
## Logs
- type: textarea
id: logs
attributes:
label: Client logs
label: Logs
description: |
Please paste your crash logs here if applicable. You can find these in your servers dashboard under "logs".
The file name should start with "upload_org.jellyfin.androidtv".
@@ -83,34 +37,31 @@ body:
Please paste your FFmpeg logs here. You can find these in your servers dashboard under "logs".
placeholder: Paste logs…
render: shell
- type: markdown
- type: textarea
id: mediainfo
attributes:
value: |
## Environment
label: Media info of the file
description: |
Please share the media information for the file causing issues. You can use a variety of tools to retrieve this information.
- Use ffprobe (`ffprobe ./file.mp4`)
- Use the Media Info tool (set to text format, download here: https://mediaarea.net/en/MediaInfo)
- Copy the media info from the web interface
placeholder: Paste media info…
render: shell
- type: input
id: app-version
attributes:
label: Application version
description: The version of the installed Jellyfin Android TV app.
placeholder: 0.18.2
placeholder: 0.14.0
validations:
required: true
- type: dropdown
id: installation-source
attributes:
label: Where did you install the app from?
description: Choose the appropriate app store or installation method.
options:
- Google Play Store
- Amazon Appstore
- F-Droid
- Sideloaded APK
- type: input
id: device-info
attributes:
label: Device information
description: Manufacturer and model
placeholder: Nvidia Shield TV Pro (2019), Fire TV Stick HD (2020)
placeholder: Nvidia Shield Pro (2017), Amazon Fire TV Stick v1 (2014)
validations:
required: true
- type: input
@@ -118,7 +69,7 @@ body:
attributes:
label: Android version
description: Version of the OS and other information (e.g. custom ROM / OEM skin)
placeholder: Android 13, Fire OS 7
placeholder: Android 9, Fire OS 7
validations:
required: true
- type: input
@@ -126,18 +77,6 @@ body:
attributes:
label: Jellyfin server version
description: If on unstable, please specify the commit hash.
placeholder: 10.10.2
placeholder: 10.8.1
validations:
required: true
- type: markdown
attributes:
value: |
## Additional
- type: input
id: other-sources
attributes:
label: Other sources
description: Please link the related forum topics or chat messages that prompted you open this issue.
placeholder: https://forum.jellyfin.org/…
validations:
required: false

View File

@@ -3,24 +3,6 @@ description: Create a bug report
labels:
- bug
body:
- type: checkboxes
id: before-posting
attributes:
label: "This issue respects the following points:"
description: All conditions are **required**.
options:
- label: I have discussed this issue in the [Jellyfin forum](https://forum.jellyfin.org/) and/or [troubleshooting chat](https://jellyfin.org/contact) before opening this issue.
required: true
- label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin-androidtv/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
required: true
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
required: true
- label: This report addresses only a single issue; If you encounter multiple issues, kindly create separate reports for each one.
required: true
- type: markdown
attributes:
value: |
## Bug information
- type: textarea
id: description
attributes:
@@ -46,16 +28,12 @@ body:
The file name should start with "upload_org.jellyfin.androidtv".
Make sure that they don't contain any sensitive information like server URL, auth tokens or passwords.
placeholder: Paste logs…
- type: markdown
attributes:
value: |
## Environment
- type: input
id: app-version
attributes:
label: Application version
description: The version of the installed Jellyfin Android TV app.
placeholder: 0.18.2
placeholder: 0.14.0
validations:
required: true
- type: dropdown
@@ -64,16 +42,15 @@ body:
label: Where did you install the app from?
description: Choose the appropriate app store or installation method.
options:
- Google Play Store
- Google Play
- Amazon Appstore
- F-Droid
- Sideloaded APK
- type: input
id: device-info
attributes:
label: Device information
description: Manufacturer and model
placeholder: Nvidia Shield TV Pro (2019), Fire TV Stick HD (2020)
placeholder: Nvidia Shield Pro (2017), Amazon Fire TV Stick v1 (2014)
validations:
required: true
- type: input
@@ -81,7 +58,7 @@ body:
attributes:
label: Android version
description: Version of the OS and other information (e.g. custom ROM / OEM skin)
placeholder: Android 13, Fire OS 7
placeholder: Android 9, Fire OS 7
validations:
required: true
- type: input
@@ -89,18 +66,6 @@ body:
attributes:
label: Jellyfin server version
description: If on unstable, please specify the commit hash.
placeholder: 10.10.2
placeholder: 10.8.1
validations:
required: true
- type: markdown
attributes:
value: |
## Additional
- type: input
id: other-sources
attributes:
label: Other sources
description: Please link the related forum topics or chat messages that prompted you open this issue.
placeholder: https://forum.jellyfin.org/…
validations:
required: false

View File

@@ -3,54 +3,12 @@ description: Request a new feature
labels:
- enhancement
body:
- type: checkboxes
id: before-posting
attributes:
label: "This request respects the following points:"
description: All conditions are **required**.
options:
- label: This request is **not** already on [GitHub](https://github.com/jellyfin/jellyfin-androidtv/labels/enhancement) _(I've searched it)_.
required: true
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
required: true
- type: textarea
id: description
attributes:
label: Problem description
description: Describe the issue you are experiencing or the gap you are trying to address.
placeholder: |
The Android TV app does not support displaying lyrics, while the Jellyfin web interface does.
This creates an inconsistent user experience across platforms.
label: Describe the feature you'd like
description: |
A clear and concise description of what you want to request.
You can also attach screenshots or screen recordings to help explain your request.
validations:
required: true
- type: textarea
attributes:
label: Proposed solution
description: Provide a detailed description of what you would like to see implemented or changed.
placeholder: |
The app could display lyrics in the screensaver. Additionally, a lyrics section could be integrated into the page that opens when music is played.
validations:
required: true
- type: textarea
attributes:
label: Alternatives considered
description: Outline any other approaches you have thought about or explored to solve the problem.
placeholder: |
An alternative approach could involve adding a dedicated "Lyrics" button in the app, similar to the Jellyfin web interface.
Users could press this button to open a full-screen lyrics view.
validations:
required: true
- type: textarea
attributes:
label: Additional information
description: Include any relevant details, resources, or screenshots that might help in understanding or implementing the request.
placeholder: Add any additional context here.
validations:
required: false
- type: input
id: other-sources
attributes:
label: Other sources
description: If this topic has been discussed outside of GitHub, please link it.
placeholder: https://forum.jellyfin.org/…
validations:
required: false

View File

@@ -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 # -->

View File

@@ -1,10 +1,9 @@
name: App / Build
name: App Build
on:
push:
branches:
- master
- release-*
pull_request:
permissions:
@@ -12,26 +11,22 @@ permissions:
jobs:
build:
name: Build
runs-on: ubuntu-24.04
runs-on: ubuntu-20.04
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@2c7a4878f5d120bd643426d54ae1209b29cc01a3 # tag=v3
with:
distribution: temurin
java-version-file: .tools-versions
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
distribution: adopt
java-version: 11
cache: gradle
- name: Assemble debug APKs
run: ./gradlew assembleDebug
- name: Create publish bundle
run: mkdir -p build/gh-app-publish/; find app/build/ -iname "*.apk" -exec mv "{}" build/gh-app-publish/ \;
run: ./gradlew --build-cache --no-daemon --info assembleDebug
- name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # tag=v3
with:
name: build-artifacts
retention-days: 14
if-no-files-found: error
path: build/gh-app-publish/
path: app/build/outputs/apk/debug/*.apk

View File

@@ -1,4 +1,4 @@
name: App / Lint
name: App Lint
on:
push:
@@ -9,26 +9,26 @@ on:
permissions:
contents: read
security-events: write
jobs:
lint:
name: Lint
runs-on: ubuntu-24.04
permissions:
contents: read # for actions/checkout to fetch code
security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
runs-on: ubuntu-20.04
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@2c7a4878f5d120bd643426d54ae1209b29cc01a3 # tag=v3
with:
distribution: temurin
java-version-file: .tools-versions
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
- name: Run detekt and lint tasks
run: ./gradlew detekt lint
distribution: adopt
java-version: 11
cache: gradle
- name: Run detekt and lint task
run: ./gradlew --build-cache --no-daemon --info detekt lint
- name: Upload SARIF files
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
uses: github/codeql-action/upload-sarif@2ca79b6fa8d3ec278944088b4aa5f46912db5d63 # tag=v2
if: ${{ always() }}
with:
sarif_file: .

View File

@@ -1,4 +1,4 @@
name: App / Publish
name: App Publish
on:
push:
@@ -6,40 +6,33 @@ on:
- v*
jobs:
publish:
name: Publish
runs-on: ubuntu-24.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
build:
runs-on: ubuntu-20.04
if: ${{ github.repository == 'jellyfin/jellyfin-androidtv' }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@2c7a4878f5d120bd643426d54ae1209b29cc01a3 # tag=v3
with:
distribution: temurin
java-version-file: .tools-versions
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
distribution: adopt
java-version: 11
- name: Set JELLYFIN_VERSION
run: echo "JELLYFIN_VERSION=$(echo ${GITHUB_REF#refs/tags/v} | tr / -)" >> $GITHUB_ENV
- name: Assemble release files
run: ./gradlew assemble bundleRelease versionTxt
run: ./gradlew --no-daemon --info assemble bundleRelease versionTxt
- name: Sign APK
id: signApk
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # tag=v1
env:
BUILD_TOOLS_VERSION: "34.0.0"
with:
releaseDirectory: app/build/outputs/apk/release
signingKeyBase64: ${{ secrets.KEYSTORE }}
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
alias: ${{ secrets.KEY_ALIAS }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Sign app bundle
- name: Sign AAB
id: signAab
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # tag=v1
env:
BUILD_TOOLS_VERSION: "34.0.0"
with:
releaseDirectory: app/build/outputs/bundle/release
signingKeyBase64: ${{ secrets.KEYSTORE }}
@@ -55,7 +48,7 @@ jobs:
mv ${{ steps.signAab.outputs.signedReleaseFile }} build/jellyfin-publish/jellyfin-androidtv-v${{ env.JELLYFIN_VERSION }}-release.aab
mv app/build/version.txt build/jellyfin-publish/
- name: Upload release archive to GitHub release
uses: alexellis/upload-assets@13926a61cdb2cb35f5fdef1c06b8b591523236d3 # 0.4.1
uses: alexellis/upload-assets@5586bc227f8525a5e3525e6edf64da5350bfb5b1 # tag=0.3.0
env:
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
with:
@@ -65,24 +58,20 @@ jobs:
with:
switches: -vrptz
path: build/jellyfin-publish/
remote_path: /srv/incoming/androidtv/v${{ env.JELLYFIN_VERSION }}/
remote_host: ${{ secrets.REPO_HOST }}
remote_user: ${{ secrets.REPO_USER }}
remote_key: ${{ secrets.REPO_KEY }}
remote_path: /srv/repository/releases/client/androidtv/versions/v${{ env.JELLYFIN_VERSION }}
remote_host: ${{ secrets.DEPLOY_HOST }}
remote_user: ${{ secrets.DEPLOY_USER }}
remote_key: ${{ secrets.DEPLOY_KEY }}
- name: Update repo.jellyfin.org symlinks
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
uses: appleboy/ssh-action@f9010ff7f1bbd7db1a0b4bab661437550cea20c0 # tag=v0.1.5
with:
host: ${{ secrets.REPO_HOST }}
username: ${{ secrets.REPO_USER }}
key: ${{ secrets.REPO_KEY }}
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
envs: JELLYFIN_VERSION
script_stop: true
script: |
if [ -d "/srv/repository/main/client/androidtv/versions/v${{ env.JELLYFIN_VERSION }}" ] && [ -n "${{ env.JELLYFIN_VERSION }}" ]; then
sudo rm -r /srv/repository/main/client/androidtv/versions/v${{ env.JELLYFIN_VERSION }}
fi
sudo mv /srv/incoming/androidtv/v${{ env.JELLYFIN_VERSION }} /srv/repository/main/client/androidtv/versions/v${{ env.JELLYFIN_VERSION }}
cd /srv/repository/main/client/androidtv;
sudo rm -rf *.apk version.txt;
sudo ln -s versions/v${JELLYFIN_VERSION}/jellyfin-androidtv-v${JELLYFIN_VERSION}-*.apk .;
sudo ln -s versions/v${JELLYFIN_VERSION}/version.txt .;
cd /srv/repository/releases/client/androidtv;
rm -rf *.apk version.txt;
ln -s versions/v${JELLYFIN_VERSION}/jellyfin-androidtv-v${JELLYFIN_VERSION}-*.apk .;
ln -s versions/v${JELLYFIN_VERSION}/version.txt .;

View File

@@ -1,4 +1,4 @@
name: App / Test
name: App Test
on:
push:
@@ -12,17 +12,15 @@ permissions:
jobs:
test:
name: Test
runs-on: ubuntu-24.04
runs-on: ubuntu-20.04
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@2c7a4878f5d120bd643426d54ae1209b29cc01a3 # tag=v3
with:
distribution: temurin
java-version-file: .tools-versions
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
distribution: adopt
java-version: 11
cache: gradle
- name: Run test task
run: ./gradlew test
run: ./gradlew --build-cache --no-daemon --info test

17
.github/workflows/gradlew-update.yaml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Gradle update
on:
schedule:
- cron: '0 4 * * *'
jobs:
update:
runs-on: ubuntu-20.04
if: github.repository == 'jellyfin/jellyfin-androidtv'
steps:
- name: Checkout repository
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
- name: Update Gradle Wrapper
uses: gradle-update/update-gradle-wrapper-action@981c551b17dbcf1940b1b4435afdb79babb7c13a # tag=v1
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}

View File

@@ -1,4 +1,4 @@
name: Gradle / Validate wrapper
name: Gradle validate
on:
push:
@@ -13,10 +13,9 @@ permissions:
jobs:
validate:
name: Validate
runs-on: ubuntu-24.04
runs-on: ubuntu-20.04
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
uses: gradle/wrapper-validation-action@e6e38bacfdf1a337459f332974bb2327a31aaf4b # tag=v1

View File

@@ -0,0 +1,17 @@
name: Merge conflict labeler
on:
push:
pull_request_target:
types:
- synchronize
jobs:
triage:
runs-on: ubuntu-20.04
if: github.repository == 'jellyfin/jellyfin-androidtv'
steps:
- uses: eps1lon/actions-label-merge-conflict@b8bf8341285ec9a4567d4318ba474fee998a6919 # tag=v2.0.1
with:
dirtyLabel: merge conflict
repoToken: ${{ secrets.JF_BOT_TOKEN }}

View File

@@ -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 }}

View File

@@ -1,4 +1,4 @@
name: Repo / Reply stale issue
name: Issue stale check
on:
schedule:
@@ -10,12 +10,11 @@ permissions:
pull-requests: write
jobs:
triage:
name: Triage
runs-on: ubuntu-24.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
stale:
runs-on: ubuntu-20.04
if: github.repository == 'jellyfin/jellyfin-androidtv'
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@9c1b1c6e115ca2af09755448e0dbba24e5061cc8 # tag=v5
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
days-before-stale: 120
@@ -26,7 +25,7 @@ jobs:
stale-issue-label: stale
stale-issue-message: |-
This issue has gone 120 days without comment. To avoid abandoned issues, it will be closed in 21 days if there are no new comments.
If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or master branch, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label.
This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://docs.jellyfin.org/general/getting-help.html).

1
.gitignore vendored
View File

@@ -4,6 +4,5 @@ local.properties
/.idea
.gradle
.kotlin/
build/
captures/

View File

@@ -1 +0,0 @@
java 21

View File

@@ -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

View File

@@ -25,6 +25,9 @@
<a href="https://matrix.to/#/+jellyfin:matrix.org">
<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfin:matrix.org.svg?logo=matrix"/>
</a>
<a href="https://www.reddit.com/r/jellyfin">
<img alt="Join our Subreddit" src="https://img.shields.io/badge/reddit-r%2Fjellyfin-%23FF5700.svg"/>
</a>
<br/>
<a href="https://play.google.com/store/apps/details?id=org.jellyfin.androidtv">
<img width="153" alt="Jellyfin on Google Play" src="https://jellyfin.org/images/store-icons/google-play.png"/>
@@ -32,46 +35,52 @@
<a href="https://www.amazon.com/gp/aw/d/B07TX7Z725">
<img width="153" alt="Jellyfin on Amazon Appstore" src="https://jellyfin.org/images/store-icons/amazon.png"/>
</a>
<a href="https://f-droid.org/en/packages/org.jellyfin.androidtv/">
<img width="153" alt="Jellyfin on F-Droid" src="https://jellyfin.org/images/store-icons/fdroid.png"/>
</a>
<br/>
<a href="https://repo.jellyfin.org/releases/client/androidtv/">Download archive</a>
</p>
Jellyfin Android TV is a Jellyfin client for Android TV, Nvidia Shield, and Amazon Fire TV devices. We welcome all contributions and pull
requests! If you have a larger feature in mind please open an issue so we can discuss the implementation before you start.
## Building
The app uses Gradle and requires the Android SDK. We recommend using Android Studio, which includes all required dependencies, for
development and building. For manual building without Android Studio make sure a compatible JDK and Android SDK are installed and in your
PATH, then use the Gradle wrapper (`./gradlew`) to build the project with the `assembleDebug` Gradle task to generate an apk file:
```shell
./gradlew assembleDebug
```
The task will create an APK file in the `/app/build/outputs/apk/debug` directory. This APK file uses a different app-id from our stable
builds and can be manually installed to your device.
## Branching
The `master` branch is the primary development branch and the target for all pull requests. It is **unstable** and may contain breaking
changes or unresolved bugs. For production deployments and forks, always use the latest `release-x.y.z` branch. Do not base production work
or long-lived forks on `master`.
Release branches are created at the start of a beta cycle and are kept up to date with each published release. Maintainers will cherry-pick
selected changes into release branches as needed for backports. These branches are reused for subsequent patch releases.
Jellyfin Android TV is a Jellyfin client for Android TV, Nvidia Shield, and Amazon Fire TV devices.
We welcome all contributions and pull requests! If you have a larger feature in mind please open an
issue so we can discuss the implementation before you start.
## Translating
Translations can be improved very easily from our [Weblate](https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv)
instance. Look through the following graphic to see if your native language could use some work! We cannot accept changes to translation
files via pull requests.
Translations can be improved very easily from our
[Weblate](https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv) instance.
Look through the following graphic to see if your native language could use some work!
<p align="center">
<a href="https://translate.jellyfin.org/engage/jellyfin-android/">
<img alt="Detailed Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin-android/-/jellyfin-androidtv/multi-auto.svg"/>
</a>
</p>
## Build Process
### Dependencies
- Android Studio
### Build
1. Clone or download this repository
```sh
git clone https://github.com/jellyfin/jellyfin-androidtv.git
cd jellyfin-androidtv
```
2. Open the project in Android Studio and run it from there or build an APK directly through Gradle:
```sh
./gradlew assembleDebug
```
Add the Android SDK to your PATH environment variable or create the ANDROID_SDK_ROOT variable for
this to work.
### Deploy to device/emulator
```sh
./gradlew installDebug
```
*You can also replace the "Debug" with "Release" to get an optimized release binary.*

View File

@@ -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>

View File

@@ -1,29 +1,31 @@
plugins {
alias(libs.plugins.aboutlibraries)
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
id("com.android.application")
kotlin("android")
kotlin("kapt")
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.aboutlibraries)
}
android {
namespace = "org.jellyfin.androidtv"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compileSdk = 31
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
minSdk = 21
targetSdk = 31
// Release version
applicationId = namespace
versionName = project.getVersionName()
versionCode = getVersionCode(versionName!!)
setProperty("archivesBaseName", "jellyfin-androidtv-v$versionName")
}
sourceSets["main"].java.srcDirs("src/main/kotlin")
sourceSets["test"].java.srcDirs("src/test/kotlin")
buildFeatures {
buildConfig = true
viewBinding = true
compose = true
resValues = true
}
compileOptions {
@@ -31,8 +33,8 @@ android {
}
buildTypes {
release {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
val release by getting {
isMinifyEnabled = false
// Set package names used in various XML files
resValue("string", "app_id", namespace!!)
@@ -45,7 +47,7 @@ android {
buildConfigField("boolean", "DEVELOPMENT", "false")
}
debug {
val debug by getting {
// Use different application id to run release and debug at the same time
applicationIdSuffix = ".debug"
@@ -73,10 +75,13 @@ android {
}
}
base.archivesName.set("jellyfin-androidtv-v${project.getVersionName()}")
aboutLibraries {
// Remove the "generated" timestamp to allow for reproducible builds
excludeFields = arrayOf("generated")
}
tasks.register("versionTxt") {
val path = layout.buildDirectory.asFile.get().resolve("version.txt")
val versionTxt by tasks.registering {
val path = buildDir.resolve("version.txt")
doLast {
val versionString = "v${android.defaultConfig.versionName}=${android.defaultConfig.versionCode}"
@@ -87,12 +92,9 @@ tasks.register("versionTxt") {
dependencies {
// Jellyfin
implementation(projects.design)
implementation(projects.playback.core)
implementation(projects.playback.jellyfin)
implementation(projects.playback.media3.exoplayer)
implementation(projects.playback.media3.session)
implementation(projects.playback)
implementation(projects.preference)
implementation(libs.jellyfin.apiclient)
implementation(libs.jellyfin.sdk) {
// Change version if desired
val sdkVersion = findProperty("sdk.version")?.toString()
@@ -110,12 +112,9 @@ dependencies {
// Android(x)
implementation(libs.androidx.core)
implementation(libs.androidx.activity)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.fragment)
implementation(libs.androidx.fragment.compose)
implementation(libs.androidx.leanback.core)
implementation(libs.androidx.leanback.preference)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.preference)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.tvprovider)
@@ -126,25 +125,25 @@ dependencies {
implementation(libs.androidx.window)
implementation(libs.androidx.cardview)
implementation(libs.androidx.startup)
implementation(libs.bundles.androidx.compose)
implementation(libs.accompanist.permissions)
// Dependency Injection
implementation(libs.bundles.koin)
// GSON
implementation(libs.gson)
// Media players
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.datasource.okhttp)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.ui)
implementation(libs.jellyfin.androidx.media3.ffmpeg.decoder)
implementation(libs.libass.media3)
implementation(libs.exoplayer)
implementation(libs.jellyfin.exoplayer.ffmpegextension)
implementation(libs.libvlc)
// Markdown
implementation(libs.bundles.markwon)
// Image utility
implementation(libs.bundles.coil)
implementation(libs.glide.core)
kapt(libs.glide.compiler)
implementation(libs.kenburnsview)
// Crash Reporting
implementation(libs.bundles.acra)
@@ -156,6 +155,10 @@ dependencies {
implementation(libs.timber)
implementation(libs.slf4j.timber)
// Debugging
if (getProperty("leakcanary.enable")?.toBoolean() == true)
debugImplementation(libs.leakcanary)
// Compatibility (desugaring)
coreLibraryDesugaring(libs.android.desugar)

View File

@@ -1 +0,0 @@
-dontwarn org.commonmark.ext.gfm.strikethrough.Strikethrough

View File

@@ -1,26 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="org.jellyfin.androidtv"
android:installLocation="auto">
<!-- Android TV Integration -->
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<!-- AccountManager permissions -->
<uses-permission
android:name="android.permission.AUTHENTICATE_ACCOUNTS"
android:maxSdkVersion="22" />
<uses-permission
android:name="android.permission.GET_ACCOUNTS"
android:maxSdkVersion="22" />
<uses-permission
android:name="android.permission.MANAGE_ACCOUNTS"
android:maxSdkVersion="22" />
<!-- Generic permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission
android:name="android.permission.ACCESS_WIFI_STATE"
tools:ignore="LeanbackUsesWifi" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Search permissions (voice to text) -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Device feature requirements -->
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
@@ -28,36 +38,31 @@
android:name="android.hardware.microphone"
android:required="false" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data
android:host="youtube.com"
android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
</queries>
<application
android:name=".JellyfinApplication"
android:allowBackup="true"
android:appCategory="video"
android:banner="@mipmap/app_banner"
android:banner="@drawable/app_banner"
android:dataExtractionRules="@xml/backup_rules"
android:fullBackupContent="@xml/backup_content"
android:icon="@mipmap/app_icon"
android:icon="@drawable/app_icon"
android:label="@string/app_name"
android:largeHeap="true"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:roundIcon="@drawable/app_icon_round"
android:theme="@style/Theme.Jellyfin"
android:usesCleartextTraffic="true">
<!-- Screensaver -->
<service
android:name=".auth.service.AuthenticatorService"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<service
android:name=".integration.dream.LibraryDreamService"
android:exported="true"
@@ -84,6 +89,9 @@
<meta-data
android:name="org.jellyfin.androidtv.SessionInitializer"
android:value="androidx.startup" />
<meta-data
android:name="org.jellyfin.androidtv.ProcessLifecycleInitializer"
android:value="androidx.startup" />
</provider>
<provider
@@ -92,23 +100,19 @@
android:exported="true"
android:initOrder="10" />
<provider
android:name=".integration.provider.ImageProvider"
android:authorities="${applicationId}.integration.provider.ImageProvider"
android:exported="true"
android:initOrder="10" />
<activity
android:name=".ui.browsing.MainActivity"
android:screenOrientation="landscape" />
<!-- Authentication -->
<activity
android:name=".ui.startup.StartupActivity"
android:exported="true"
android:launchMode="singleTask"
android:noHistory="true"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustNothing">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.SEARCH" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
@@ -118,16 +122,83 @@
android:name="android.app.searchable"
android:resource="@xml/searchable" />
</activity>
<activity-alias
android:name=".startup.StartupActivity"
android:targetActivity=".ui.startup.StartupActivity" />
<!-- Main application -->
<activity
android:name=".ui.browsing.MainActivity"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustNothing" />
android:name=".ui.playback.PlaybackOverlayActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.browsing.GenericFolderActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.browsing.UserViewActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.browsing.CollectionActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.browsing.GroupedItemsActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.preference.PreferencesActivity"
android:theme="@style/Theme.Jellyfin.Preferences" />
<activity
android:name=".ui.browsing.SuggestedMoviesActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.search.SearchActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.itemdetail.FullDetailsActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.livetv.LiveTvGuideActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.browsing.GenericGridActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.browsing.BrowseRecordingsActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.browsing.BrowseScheduleActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.itemdetail.PhotoPlayerActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.itemdetail.ItemListActivity"
android:screenOrientation="landscape" />
<activity
android:name=".ui.playback.AudioNowPlayingActivity"
android:screenOrientation="landscape" />
<activity android:name=".ui.playback.ExternalPlayerActivity" />
<activity
android:name=".ui.playback.nextup.NextUpActivity"
android:noHistory="true"
android:screenOrientation="landscape" />
<!-- Playback Rewrite - Temporary -->
<activity android:name=".ui.playback.rewrite.PlaybackForwardingActivity" />
</application>
</manifest>

View File

@@ -2,6 +2,8 @@ package org.jellyfin.androidtv
import android.app.Application
import android.content.Context
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.work.BackoffPolicy
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
@@ -15,6 +17,7 @@ import org.jellyfin.androidtv.data.eventhandling.SocketHandler
import org.jellyfin.androidtv.data.repository.NotificationsRepository
import org.jellyfin.androidtv.integration.LeanbackChannelWorker
import org.jellyfin.androidtv.telemetry.TelemetryService
import org.jellyfin.androidtv.util.AutoBitrate
import org.koin.android.ext.android.inject
import java.util.concurrent.TimeUnit
@@ -35,6 +38,7 @@ class JellyfinApplication : Application() {
*/
suspend fun onSessionStart() = withContext(Dispatchers.IO) {
val workManager by inject<WorkManager>()
val autoBitrate by inject<AutoBitrate>()
val socketListener by inject<SocketHandler>()
// Update background worker
@@ -45,7 +49,7 @@ class JellyfinApplication : Application() {
// Recreate periodic workers
workManager.enqueueUniquePeriodicWork(
LeanbackChannelWorker.PERIODIC_UPDATE_REQUEST_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
ExistingPeriodicWorkPolicy.REPLACE,
PeriodicWorkRequestBuilder<LeanbackChannelWorker>(1, TimeUnit.HOURS)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES)
.build()
@@ -54,6 +58,10 @@ class JellyfinApplication : Application() {
// Update WebSockets
launch { socketListener.updateSession() }
// Detect auto bitrate
// running in a different scope to prevent slow startups
ProcessLifecycleOwner.get().lifecycleScope.launch { autoBitrate.detect() }
}
override fun attachBaseContext(base: Context?) {

View File

@@ -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)
}
}

View File

@@ -0,0 +1,50 @@
package org.jellyfin.androidtv
import android.app.Application
import android.content.Context
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.startup.AppInitializer
import androidx.startup.Initializer
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.auth.repository.SessionRepository
import org.jellyfin.androidtv.di.KoinInitializer
import timber.log.Timber
@Suppress("unused")
class ProcessLifecycleInitializer : Initializer<Unit> {
override fun create(context: Context) {
val koin = AppInitializer.getInstance(context)
.initializeComponent(KoinInitializer::class.java)
.koin
// Register application lifecycle events
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
/**
* Called by the Process Lifecycle when the app is created. It is called after [onCreate].
*/
override fun onCreate(owner: LifecycleOwner) {
// Register activity lifecycle callbacks
val callbacks = koin.getAll<Application.ActivityLifecycleCallbacks>()
Timber.i("Registering ${callbacks.size} ActivityLifecycleCallbacks")
val app = context.applicationContext as Application
callbacks.forEach { callback -> app.registerActivityLifecycleCallbacks(callback) }
}
/**
* Called by the Process Lifecycle when the app is activated in the foreground (activity opened).
*/
override fun onStart(owner: LifecycleOwner) {
Timber.i("Process lifecycle started")
owner.lifecycleScope.launch {
koin.get<SessionRepository>().restoreSession()
}
}
})
}
override fun dependencies() = listOf(KoinInitializer::class.java)
}

View File

@@ -5,7 +5,6 @@ import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.startup.AppInitializer
import androidx.startup.Initializer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.auth.repository.SessionRepository
import org.jellyfin.androidtv.di.KoinInitializer
@@ -18,8 +17,8 @@ class SessionInitializer : Initializer<Unit> {
.koin
// Restore system session
ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.IO) {
koin.get<SessionRepository>().restoreSession(destroyOnly = false)
ProcessLifecycleOwner.get().lifecycleScope.launch {
koin.get<SessionRepository>().restoreSession()
}
}

View File

@@ -0,0 +1,48 @@
package org.jellyfin.androidtv.auth.apiclient
import org.jellyfin.androidtv.auth.repository.Session
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.jellyfin.androidtv.util.sdk.legacy
import org.jellyfin.apiclient.interaction.ApiClient
import org.jellyfin.apiclient.model.apiclient.ServerInfo
import org.jellyfin.sdk.model.DeviceInfo
import timber.log.Timber
class ApiBinder(
private val api: ApiClient,
private val authenticationStore: AuthenticationStore,
) {
fun updateSession(session: Session?, deviceInfo: DeviceInfo): Boolean {
@Suppress("TooGenericExceptionCaught")
val success = try {
updateSessionInternal(session, deviceInfo)
} catch (throwable: Throwable) {
Timber.e(throwable, "Unable to update legacy API session.")
false
}
return success
}
private fun updateSessionInternal(session: Session?, deviceInfo: DeviceInfo): Boolean {
if (session == null) return true
val server = authenticationStore.getServer(session.serverId)
if (server == null) {
Timber.e("Could not bind API because server ${session.serverId} was not found in the store.")
return false
}
api.device = deviceInfo.legacy()
api.SetAuthenticationInfo(session.accessToken, session.userId.toString())
api.EnableAutomaticNetworking(ServerInfo().apply {
id = session.serverId.toString()
name = server.name
address = server.address.removeSuffix("/")
userId = session.userId.toString()
accessToken = session.accessToken
})
return true
}
}

View File

@@ -0,0 +1,10 @@
package org.jellyfin.androidtv.auth.model
import java.util.UUID
data class AccountManagerAccount(
val id: UUID,
val server: UUID,
val name: String,
val accessToken: String? = null,
)

View File

@@ -1,11 +1,11 @@
package org.jellyfin.androidtv.auth.model
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
enum class AuthenticationSortBy(
override val nameRes: Int
) : PreferenceEnum {
LAST_USE(R.string.last_use),
ALPHABETICAL(R.string.alphabetical);
enum class AuthenticationSortBy {
@EnumDisplayOptions(name = R.string.last_use)
LAST_USE,
@EnumDisplayOptions(name = R.string.alphabetical)
ALPHABETICAL
}

View File

@@ -6,7 +6,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.time.Instant
import java.util.Date
import java.util.UUID
/**
@@ -20,7 +20,7 @@ data class AuthenticationStoreServer(
@SerialName("login_disclaimer") val loginDisclaimer: String? = null,
@SerialName("splashscreen_enabled") val splashscreenEnabled: Boolean = false,
@SerialName("setup_completed") val setupCompleted: Boolean = true,
@SerialName("last_used") val lastUsed: Long = Instant.now().toEpochMilli(),
@SerialName("last_refreshed") val lastRefreshed: Long = Instant.now().toEpochMilli(),
@SerialName("last_used") val lastUsed: Long = Date().time,
@SerialName("last_refreshed") val lastRefreshed: Long = Date().time,
val users: Map<UUID, AuthenticationStoreUser> = emptyMap(),
)

View File

@@ -2,7 +2,7 @@ package org.jellyfin.androidtv.auth.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
import java.util.Date
/**
* Locally stored user information. New properties require default values or deserialization will fail.
@@ -10,7 +10,7 @@ import java.time.Instant
@Serializable
data class AuthenticationStoreUser(
val name: String,
@SerialName("last_used") val lastUsed: Long = Instant.now().toEpochMilli(),
@SerialName("image_tag") val imageTag: String? = null,
@SerialName("access_token") val accessToken: String? = null,
@SerialName("last_used") val lastUsed: Long = Date().time,
@SerialName("require_password") val requirePassword: Boolean = true,
@SerialName("image_tag") val imageTag: String? = null
)

View File

@@ -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()

View File

@@ -3,14 +3,14 @@ package org.jellyfin.androidtv.auth.model
sealed class QuickConnectState
/**
* State unknown until first poll completed.
* State unknown untill first poll completed.
*/
data object UnknownQuickConnectState : QuickConnectState()
object UnknownQuickConnectState : QuickConnectState()
/**
* Server does not have QuickConnect enabled.
*/
data object UnavailableQuickConnectState : QuickConnectState()
object UnavailableQuickConnectState : QuickConnectState()
/**
* Connection is pending.
@@ -20,4 +20,4 @@ data class PendingQuickConnectState(val code: String) : QuickConnectState()
/**
* User connected.
*/
data object ConnectedQuickConnectState : QuickConnectState()
object ConnectedQuickConnectState : QuickConnectState()

View File

@@ -2,7 +2,7 @@ package org.jellyfin.androidtv.auth.model
import org.jellyfin.androidtv.auth.repository.ServerRepository
import org.jellyfin.sdk.model.ServerVersion
import java.time.Instant
import java.util.Date
import java.util.UUID
/**
@@ -16,9 +16,9 @@ data class Server(
val loginDisclaimer: String? = null,
val splashscreenEnabled: Boolean = false,
val setupCompleted: Boolean = true,
var dateLastAccessed: Instant = Instant.MIN,
var dateLastAccessed: Date = Date(0),
) {
val serverVersion = version?.let(ServerVersion::fromString)
private val serverVersion = version?.let(ServerVersion::fromString)
val versionSupported = serverVersion != null && serverVersion >= ServerRepository.minimumServerVersion
operator fun compareTo(other: ServerVersion): Int = serverVersion?.compareTo(other) ?: -1

View File

@@ -10,6 +10,7 @@ sealed class User {
abstract val serverId: UUID
abstract val name: String
abstract val accessToken: String?
abstract val requirePassword: Boolean
abstract val imageTag: String?
abstract fun withToken(accessToken: String): User
@@ -33,6 +34,7 @@ data class PrivateUser(
override val serverId: UUID,
override val name: String,
override val accessToken: String?,
override val requirePassword: Boolean,
override val imageTag: String?,
val lastUsed: Long,
) : User() {
@@ -47,6 +49,7 @@ data class PublicUser(
override val serverId: UUID,
override val name: String,
override val accessToken: String?,
override val requirePassword: Boolean,
override val imageTag: String?,
) : User() {
override fun withToken(accessToken: String) = copy(accessToken = accessToken)

View File

@@ -1,12 +1,10 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import org.jellyfin.androidtv.auth.model.ApiClientErrorLoginState
import org.jellyfin.androidtv.auth.model.AccountManagerAccount
import org.jellyfin.androidtv.auth.model.AuthenticateMethod
import org.jellyfin.androidtv.auth.model.AuthenticatedState
import org.jellyfin.androidtv.auth.model.AuthenticatingState
@@ -21,12 +19,10 @@ import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.auth.model.ServerUnavailableState
import org.jellyfin.androidtv.auth.model.ServerVersionNotSupported
import org.jellyfin.androidtv.auth.model.User
import org.jellyfin.androidtv.auth.store.AccountManagerStore
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.jellyfin.androidtv.util.apiclient.JellyfinImage
import org.jellyfin.androidtv.util.apiclient.JellyfinImageSource
import org.jellyfin.androidtv.util.apiclient.getUrl
import org.jellyfin.androidtv.util.apiclient.primaryImage
import org.jellyfin.androidtv.util.ImageUtils
import org.jellyfin.androidtv.util.sdk.forUser
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
@@ -34,13 +30,14 @@ import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.exception.TimeoutException
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.authenticateWithQuickConnect
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.api.AuthenticationResult
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.UserDto
import timber.log.Timber
import java.time.Instant
import java.util.Date
/**
* Repository to manage authentication of the user in the app.
@@ -54,12 +51,16 @@ interface AuthenticationRepository {
class AuthenticationRepositoryImpl(
private val jellyfin: Jellyfin,
private val sessionRepository: SessionRepository,
private val accountManagerStore: AccountManagerStore,
private val authenticationStore: AuthenticationStore,
private val userApiClient: ApiClient,
private val authenticationPreferences: AuthenticationPreferences,
private val defaultDeviceInfo: DeviceInfo,
) : AuthenticationRepository {
override fun authenticate(server: Server, method: AuthenticateMethod): Flow<LoginState> {
// Check server version first
if (!server.versionSupported) return flowOf(ServerVersionNotSupported(server))
return when (method) {
is AutomaticAuthenticateMethod -> authenticateAutomatic(server, method.user)
is CredentialAuthenticateMethod -> authenticateCredential(server, method.username, method.password)
@@ -68,14 +69,16 @@ class AuthenticationRepositoryImpl(
}
private fun authenticateAutomatic(server: Server, user: User): Flow<LoginState> {
Timber.i("Authenticating user ${user.id}")
Timber.d("Authenticating user %s", user)
// Automatic logic is disabled when the always authenticate preference is enabled
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return flowOf(RequireSignInState)
val authStoreUser = authenticationStore.getUser(server.id, user.id)
val account = accountManagerStore.getAccount(user.id)
// Try login with access token
return if (authStoreUser?.accessToken != null) authenticateToken(server, user.withToken(authStoreUser.accessToken))
return if (account?.accessToken != null) authenticateToken(server, user.withToken(account.accessToken))
// Try login without password
else if (!user.requirePassword) authenticateCredential(server, user.name, "")
// Require login
else flowOf(RequireSignInState)
}
@@ -91,103 +94,93 @@ class AuthenticationRepositoryImpl(
return@flow
} catch (err: ApiClientException) {
Timber.e(err, "Unable to sign in as $username")
emit(ApiClientErrorLoginState(err))
emit(RequireSignInState)
return@flow
}
emitAll(authenticateAuthenticationResult(server, result))
}.flowOn(Dispatchers.IO)
}
private fun authenticateQuickConnect(server: Server, secret: String) = flow {
val api = jellyfin.createApi(server.address, deviceInfo = defaultDeviceInfo)
val result = try {
val response = api.userApi.authenticateWithQuickConnect(secret)
response.content
} catch (err: TimeoutException) {
Timber.e(err, "Failed to connect to server")
emit(ServerUnavailableState)
return@flow
} catch (err: ApiClientException) {
Timber.e(err, "Unable to sign in with Quick Connect secret")
emit(ApiClientErrorLoginState(err))
emit(RequireSignInState)
return@flow
}
emitAll(authenticateAuthenticationResult(server, result))
}.flowOn(Dispatchers.IO)
}
private fun authenticateAuthenticationResult(server: Server, result: AuthenticationResult) = flow {
val accessToken = result.accessToken ?: return@flow emit(RequireSignInState)
val accessToken = result.accessToken ?:return@flow emit(RequireSignInState)
val userInfo = result.user ?: return@flow emit(RequireSignInState)
val user = PrivateUser(
id = userInfo.id,
serverId = server.id,
name = userInfo.name!!,
accessToken = result.accessToken,
imageTag = userInfo.primaryImage?.tag,
lastUsed = Instant.now().toEpochMilli(),
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag,
lastUsed = Date().time,
)
authenticateFinish(server, userInfo, accessToken)
val success = setActiveSession(user, server)
if (success) {
emit(AuthenticatedState)
} else {
Timber.w("Failed to set active session after authenticating")
if (!server.versionSupported) emit(ServerVersionNotSupported(server))
else emit(RequireSignInState)
}
}.flowOn(Dispatchers.IO)
if (success) emit(AuthenticatedState)
else emit(RequireSignInState)
}
private fun authenticateToken(server: Server, user: User) = flow {
emit(AuthenticatingState)
val success = setActiveSession(user, server)
if (!success) {
if (!server.versionSupported) emit(ServerVersionNotSupported(server))
else emit(RequireSignInState)
emit(RequireSignInState)
} else try {
// Update user info
val userInfo by userApiClient.userApi.getCurrentUser()
authenticateFinish(server, userInfo, user.accessToken.orEmpty())
emit(AuthenticatedState)
} catch (err: TimeoutException) {
Timber.e(err, "Failed to connect to server")
emit(ServerUnavailableState)
return@flow
} catch (err: ApiClientException) {
Timber.e(err, "Unable to get current user data")
emit(ApiClientErrorLoginState(err))
emit(RequireSignInState)
}
}.flowOn(Dispatchers.IO)
}
private suspend fun authenticateFinish(server: Server, userInfo: UserDto, accessToken: String) {
val currentUser = authenticationStore.getUser(server.id, userInfo.id)
val updatedUser = currentUser?.copy(
name = userInfo.name!!,
lastUsed = Instant.now().toEpochMilli(),
imageTag = userInfo.primaryImage?.tag,
accessToken = accessToken,
lastUsed = Date().time,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag
) ?: AuthenticationStoreUser(
name = userInfo.name!!,
imageTag = userInfo.primaryImage?.tag,
accessToken = accessToken,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag
)
authenticationStore.putUser(server.id, userInfo.id, updatedUser)
val accountManagerAccount = AccountManagerAccount(userInfo.id, server.id, updatedUser.name, accessToken)
accountManagerStore.putAccount(accountManagerAccount)
}
private suspend fun setActiveSession(user: User, server: Server): Boolean {
val authenticated = sessionRepository.switchCurrentSession(server.id, user.id)
val authenticated = sessionRepository.switchCurrentSession(user.id)
if (authenticated) {
// Update last use in store
authenticationStore.getServer(server.id)?.let { storedServer ->
authenticationStore.putServer(server.id, storedServer.copy(lastUsed = Instant.now().toEpochMilli()))
authenticationStore.putServer(server.id, storedServer.copy(lastUsed = Date().time))
}
authenticationStore.getUser(server.id, user.id)?.let { storedUser ->
authenticationStore.putUser(server.id, user.id, storedUser.copy(lastUsed = Instant.now().toEpochMilli()))
authenticationStore.putUser(server.id, user.id, storedUser.copy(lastUsed = Date().time))
}
}
@@ -195,23 +188,16 @@ class AuthenticationRepositoryImpl(
}
override fun logout(user: User): Boolean {
val authStoreUser = authenticationStore
.getUser(user.serverId, user.id)
?.copy(accessToken = null)
return if (authStoreUser != null) authenticationStore.putUser(user.serverId, user.id, authStoreUser)
else false
val authInfo = accountManagerStore.getAccount(user.id) ?: return false
return accountManagerStore.removeAccount(authInfo)
}
override fun getUserImageUrl(server: Server, user: User): String? = user.imageTag?.let { primaryImageTag ->
JellyfinImage(
item = user.id,
source = JellyfinImageSource.USER,
type = ImageType.PRIMARY,
tag = primaryImageTag,
blurHash = null,
aspectRatio = null,
index = null
override fun getUserImageUrl(server: Server, user: User): String? = user.imageTag?.let { tag ->
jellyfin.createApi(server.address).imageApi.getUserImageUrl(
userId = user.id,
tag = tag,
imageType = ImageType.PRIMARY,
maxHeight = ImageUtils.MAX_PRIMARY_IMAGE_HEIGHT
)
}?.getUrl(jellyfin.createApi(server.address))
}
}

View File

@@ -1,14 +1,12 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
import org.jellyfin.androidtv.auth.model.ConnectedState
import org.jellyfin.androidtv.auth.model.ConnectingState
@@ -25,12 +23,11 @@ import org.jellyfin.sdk.api.client.extensions.brandingApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.api.BrandingOptionsDto
import org.jellyfin.sdk.model.api.BrandingOptions
import org.jellyfin.sdk.model.api.ServerDiscoveryInfo
import org.jellyfin.sdk.model.serializer.toUUID
import timber.log.Timber
import java.time.Instant
import java.util.Date
import java.util.UUID
/**
@@ -39,23 +36,17 @@ import java.util.UUID
interface ServerRepository {
val storedServers: StateFlow<List<Server>>
val discoveredServers: StateFlow<List<Server>>
val currentServer: StateFlow<Server?>
suspend fun loadStoredServers()
suspend fun loadDiscoveryServers()
fun setCurrentServer(server: Server?)
fun addServer(address: String): Flow<ServerAdditionState>
suspend fun getServer(id: UUID, eagerUpdate: Boolean = false): Server?
suspend fun updateServer(server: Server, force: Boolean = false): Boolean
suspend fun getServer(id: UUID): Server?
suspend fun updateServer(server: Server): Boolean
suspend fun deleteServer(server: UUID): Boolean
companion object {
val minimumServerVersion = Jellyfin.minimumVersion.copy(build = null)
val recommendedServerVersion = Jellyfin.apiVersion.copy(build = null)
val upcomingMinimumServerVersion = ServerVersion(10, 11, 0)
}
}
@@ -70,9 +61,6 @@ class ServerRepositoryImpl(
private val _discoveredServers = MutableStateFlow(emptyList<Server>())
override val discoveredServers = _discoveredServers.asStateFlow()
private val _currentServer = MutableStateFlow<Server?>(null)
override val currentServer = _currentServer.asStateFlow()
// Loading data
override suspend fun loadStoredServers() {
authenticationStore.getServers()
@@ -93,29 +81,24 @@ class ServerRepositoryImpl(
}
}
override fun setCurrentServer(server: Server?) {
_currentServer.value = server
}
// Mutating data
override fun addServer(address: String): Flow<ServerAdditionState> = flow {
Timber.i("Adding server %s", address)
Timber.d("Adding server %s", address)
emit(ConnectingState(address))
val addressCandidates = jellyfin.discovery.getAddressCandidates(address)
Timber.i("Found ${addressCandidates.size} candidates")
Timber.d("Found ${addressCandidates.size} candidates")
val goodRecommendations = mutableListOf<RecommendedServerInfo>()
val badRecommendations = mutableListOf<RecommendedServerInfo>()
val greatRecommendation = jellyfin.discovery.getRecommendedServers(addressCandidates).firstOrNull { recommendedServer ->
val greatRecommendaton = jellyfin.discovery.getRecommendedServers(addressCandidates).firstOrNull { recommendedServer ->
when (recommendedServer.score) {
RecommendedServerInfoScore.GREAT -> true
RecommendedServerInfoScore.GOOD -> {
goodRecommendations += recommendedServer
false
}
else -> {
badRecommendations += recommendedServer
false
@@ -123,9 +106,9 @@ class ServerRepositoryImpl(
}
}
Timber.i(buildString {
Timber.d(buildString {
append("Recommendations: ")
if (greatRecommendation == null) append(0)
if (greatRecommendaton == null) append(0)
else append(1)
append(" great, ")
append(goodRecommendations.size)
@@ -134,7 +117,7 @@ class ServerRepositoryImpl(
append(" bad")
})
val chosenRecommendation = greatRecommendation ?: goodRecommendations.firstOrNull()
val chosenRecommendation = greatRecommendaton ?: goodRecommendations.firstOrNull()
if (chosenRecommendation != null && chosenRecommendation.systemInfo.isSuccess) {
// Get system info
val systemInfo = chosenRecommendation.systemInfo.getOrThrow()
@@ -152,7 +135,7 @@ class ServerRepositoryImpl(
loginDisclaimer = branding.loginDisclaimer,
splashscreenEnabled = branding.splashscreenEnabled,
setupCompleted = systemInfo.startupWizardCompleted ?: true,
lastUsed = Instant.now().toEpochMilli()
lastUsed = Date().time
) ?: AuthenticationStoreServer(
name = systemInfo.serverName ?: "Jellyfin Server",
address = chosenRecommendation.address,
@@ -173,17 +156,13 @@ class ServerRepositoryImpl(
.mapValues { (_, entry) -> entry.flatMap { server -> server.issues } }
emit(UnableToConnectState(addressCandidatesWithIssues))
}
}.flowOn(Dispatchers.IO)
}
override suspend fun getServer(id: UUID, eagerUpdate: Boolean): Server? {
override suspend fun getServer(id: UUID): Server? {
val server = authenticationStore.getServer(id) ?: return null
val updatedServer = try {
val forceUpdate = eagerUpdate && server.version
?.let(ServerVersion::fromString)
?.let { version -> version < ServerRepository.minimumServerVersion } == true
updateServerInternal(id, server, forceUpdate)
updateServerInternal(id, server)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to update server")
null
@@ -192,12 +171,12 @@ class ServerRepositoryImpl(
return (updatedServer ?: server).asServer(id)
}
override suspend fun updateServer(server: Server, force: Boolean): Boolean {
override suspend fun updateServer(server: Server): Boolean {
// Only update existing servers
val serverInfo = authenticationStore.getServer(server.id) ?: return false
return try {
updateServerInternal(server.id, serverInfo, force) != null
updateServerInternal(server.id, serverInfo) != null
} catch (err: ApiClientException) {
Timber.e(err, "Unable to update server")
@@ -205,32 +184,25 @@ class ServerRepositoryImpl(
}
}
private suspend fun updateServerInternal(
id: UUID,
server: AuthenticationStoreServer,
forceUpdate: Boolean
): AuthenticationStoreServer? {
val now = Instant.now().toEpochMilli()
private suspend fun updateServerInternal(id: UUID, server: AuthenticationStoreServer): AuthenticationStoreServer? {
val now = Date().time
// Only update every 10 minutes
if (now - server.lastRefreshed < 600000 && server.version != null && !forceUpdate) return null
if (now - server.lastRefreshed < 600000 && server.version != null) return null
val newServer = withContext(Dispatchers.IO) {
val api = jellyfin.createApi(server.address)
val api = jellyfin.createApi(server.address)
// Get login disclaimer
val branding = api.getBrandingOptionsOrDefault()
val systemInfo by api.systemApi.getPublicSystemInfo()
// Get login disclaimer
val branding = api.getBrandingOptionsOrDefault()
val systemInfo by api.systemApi.getPublicSystemInfo()
server.copy(
name = systemInfo.serverName ?: server.name,
version = systemInfo.version ?: server.version,
loginDisclaimer = branding.loginDisclaimer ?: server.loginDisclaimer,
splashscreenEnabled = branding.splashscreenEnabled,
setupCompleted = systemInfo.startupWizardCompleted ?: server.setupCompleted,
lastRefreshed = now
)
}
val newServer = server.copy(
name = systemInfo.serverName ?: server.name,
version = systemInfo.version ?: server.version,
loginDisclaimer = branding.loginDisclaimer ?: server.loginDisclaimer,
splashscreenEnabled = branding.splashscreenEnabled,
setupCompleted = systemInfo.startupWizardCompleted ?: server.setupCompleted,
lastRefreshed = now
)
authenticationStore.putServer(id, newServer)
return newServer
@@ -251,7 +223,7 @@ class ServerRepositoryImpl(
loginDisclaimer = loginDisclaimer,
splashscreenEnabled = splashscreenEnabled,
setupCompleted = setupCompleted,
dateLastAccessed = Instant.ofEpochMilli(lastUsed),
dateLastAccessed = Date(lastUsed),
)
/**
@@ -262,7 +234,7 @@ class ServerRepositoryImpl(
brandingApi.getBrandingOptions().content
} catch (exception: InvalidContentException) {
Timber.w(exception, "Invalid branding options response, using default value")
BrandingOptionsDto(
BrandingOptions(
loginDisclaimer = null,
customCss = null,
splashscreenEnabled = false,

View File

@@ -1,10 +1,9 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.auth.model.PrivateUser
import org.jellyfin.androidtv.auth.model.PublicUser
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.auth.store.AccountManagerStore
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.jellyfin.androidtv.util.sdk.toPublicUser
import org.jellyfin.sdk.Jellyfin
@@ -28,15 +27,17 @@ interface ServerUserRepository {
class ServerUserRepositoryImpl(
private val jellyfin: Jellyfin,
private val authenticationStore: AuthenticationStore,
private val accountManagerStore: AccountManagerStore,
) : ServerUserRepository {
override fun getStoredServerUsers(server: Server) = authenticationStore.getUsers(server.id)
?.mapNotNull { (userId, userInfo) ->
val authInfo = authenticationStore.getUser(server.id, userId)
val authInfo = accountManagerStore.getAccount(userId)
PrivateUser(
id = userId,
serverId = server.id,
id = authInfo?.id ?: userId,
serverId = authInfo?.server ?: server.id,
name = userInfo.name,
accessToken = authInfo?.accessToken,
requirePassword = userInfo.requirePassword,
imageTag = userInfo.imageTag,
lastUsed = userInfo.lastUsed,
)
@@ -49,9 +50,7 @@ class ServerUserRepositoryImpl(
val api = jellyfin.createApi(server.address)
return try {
val users = withContext(Dispatchers.IO) {
api.userApi.getPublicUsers().content
}
val users by api.userApi.getPublicUsers()
users.mapNotNull(UserDto::toPublicUser)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to retrieve public users")
@@ -63,5 +62,10 @@ class ServerUserRepositoryImpl(
override fun deleteStoredUser(user: PrivateUser) {
// Remove user info from store
authenticationStore.removeUser(user.serverId, user.id)
// Remove authentication info from system account manager
accountManagerStore.getAccount(user.id)?.let { accountManagerAccount ->
accountManagerStore.removeAccount(accountManagerAccount)
}
}
}

View File

@@ -1,14 +1,12 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.auth.apiclient.ApiBinder
import org.jellyfin.androidtv.auth.store.AccountManagerStore
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.jellyfin.androidtv.preference.PreferencesRepository
@@ -42,13 +40,15 @@ interface SessionRepository {
val currentSession: StateFlow<Session?>
val state: StateFlow<SessionRepositoryState>
suspend fun restoreSession(destroyOnly: Boolean)
suspend fun switchCurrentSession(serverId: UUID, userId: UUID): Boolean
suspend fun restoreSession()
suspend fun switchCurrentSession(userId: UUID): Boolean
fun destroyCurrentSession()
}
class SessionRepositoryImpl(
private val authenticationPreferences: AuthenticationPreferences,
private val accountManagerStore: AccountManagerStore,
private val apiBinder: ApiBinder,
private val authenticationStore: AuthenticationStore,
private val userApiClient: ApiClient,
private val preferencesRepository: PreferencesRepository,
@@ -63,44 +63,34 @@ class SessionRepositoryImpl(
private val _state = MutableStateFlow(SessionRepositoryState.READY)
override val state = _state.asStateFlow()
override suspend fun restoreSession(destroyOnly: Boolean): Unit = withContext(NonCancellable) {
currentSessionMutex.withLock {
Timber.i("Restoring session")
override suspend fun restoreSession(): Unit = currentSessionMutex.withLock {
Timber.d("Restoring session")
_state.value = SessionRepositoryState.RESTORING_SESSION
_state.value = SessionRepositoryState.RESTORING_SESSION
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return destroyCurrentSession()
val alwaysAuthenticate = authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]
val autoLoginBehavior = authenticationPreferences[AuthenticationPreferences.autoLoginUserBehavior]
val behavior = authenticationPreferences[AuthenticationPreferences.autoLoginUserBehavior]
val userId = authenticationPreferences[AuthenticationPreferences.autoLoginUserId].toUUIDOrNull()
when {
alwaysAuthenticate -> destroyCurrentSession()
autoLoginBehavior == DISABLED -> destroyCurrentSession()
autoLoginBehavior == LAST_USER && !destroyOnly -> setCurrentSession(createLastUserSession())
autoLoginBehavior == SPECIFIC_USER && !destroyOnly -> {
val serverId = authenticationPreferences[AuthenticationPreferences.autoLoginServerId].toUUIDOrNull()
val userId = authenticationPreferences[AuthenticationPreferences.autoLoginUserId].toUUIDOrNull()
if (serverId != null && userId != null) setCurrentSession(createUserSession(serverId, userId))
}
}
_state.value = SessionRepositoryState.READY
when (behavior) {
DISABLED -> destroyCurrentSession()
LAST_USER -> setCurrentSession(createLastUserSession())
SPECIFIC_USER -> setCurrentSession(createUserSession(userId))
}
_state.value = SessionRepositoryState.READY
}
override suspend fun switchCurrentSession(serverId: UUID, userId: UUID): Boolean {
override suspend fun switchCurrentSession(userId: UUID): Boolean {
// No change in user - don't switch
if (currentSession.value?.userId == userId) {
Timber.d("Current session user is the same as the requested user")
return false
}
if (currentSession.value?.userId == userId) return false
_state.value = SessionRepositoryState.SWITCHING_SESSION
Timber.i("Switching current session to user $userId")
Timber.d("Switching current session to user $userId")
val session = createUserSession(serverId, userId)
val session = createUserSession(userId)
if (session == null) {
Timber.w("Could not switch to non-existing session for user $userId")
_state.value = SessionRepositoryState.READY
Timber.d("Could not switch to non-existing session for user $userId")
return false
}
@@ -110,99 +100,91 @@ class SessionRepositoryImpl(
}
override fun destroyCurrentSession() {
Timber.i("Destroying current session")
Timber.d("Destroying current session")
userRepository.setCurrentUser(null)
serverRepository.setCurrentServer(null)
userRepository.updateCurrentUser(null)
_currentSession.value = null
userApiClient.applySession(null)
apiBinder.updateSession(null, userApiClient.deviceInfo)
_state.value = SessionRepositoryState.READY
}
private suspend fun setCurrentSession(session: Session?): Boolean {
var server: Server? = null
if (session != null) {
// No change in session - don't switch
if (currentSession.value?.userId == session.userId) return true
// Update last active user
authenticationPreferences[AuthenticationPreferences.lastServerId] = session.serverId.toString()
authenticationPreferences[AuthenticationPreferences.lastUserId] = session.userId.toString()
// Check if server version is supported
server = serverRepository.getServer(session.serverId, true)
val server = serverRepository.getServer(session.serverId)
if (server == null || !server.versionSupported) return false
}
// Update session after binding the apiclient settings
val deviceInfo = session?.let { defaultDeviceInfo.forUser(it.userId) } ?: defaultDeviceInfo
Timber.i("Updating current session. userId=${session?.userId} server=${server?.serverVersion}")
val success = apiBinder.updateSession(session, deviceInfo)
Timber.d("Updating current session. userId=${session?.userId} apiBindingSuccess=${success}")
val applied = userApiClient.applySession(session, deviceInfo)
if (applied && session != null) {
try {
val user = withContext(Dispatchers.IO) {
userApiClient.userApi.getCurrentUser().content
if (success) {
userApiClient.applySession(session, deviceInfo)
if (session != null) {
// Update crash reporting URL
val crashReportUrl = userApiClient.clientLogApi.logFileUrl(includeCredentials = false)
telemetryPreferences[TelemetryPreferences.crashReportUrl] = crashReportUrl
telemetryPreferences[TelemetryPreferences.crashReportToken] = session.accessToken
try {
val user by userApiClient.userApi.getCurrentUser()
userRepository.updateCurrentUser(user)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to authenticate: bad response when getting user info")
destroyCurrentSession()
return false
}
userRepository.setCurrentUser(user)
serverRepository.setCurrentServer(server)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to authenticate: bad response when getting user info")
destroyCurrentSession()
return false
} else {
userRepository.updateCurrentUser(null)
}
preferencesRepository.onSessionChanged()
_currentSession.value = session
} else destroyCurrentSession()
// Update crash reporting URL
val crashReportUrl = userApiClient.clientLogApi.logFileUrl()
telemetryPreferences[TelemetryPreferences.crashReportUrl] = crashReportUrl
telemetryPreferences[TelemetryPreferences.crashReportToken] = session.accessToken
} else {
userRepository.setCurrentUser(null)
serverRepository.setCurrentServer(null)
}
preferencesRepository.onSessionChanged()
_currentSession.value = session
return true
return success
}
private fun createLastUserSession(): Session? {
val lastUserId = authenticationPreferences[AuthenticationPreferences.lastUserId].toUUIDOrNull()
val lastServerId = authenticationPreferences[AuthenticationPreferences.lastServerId].toUUIDOrNull()
return if (lastUserId != null && lastServerId != null) createUserSession(lastServerId, lastUserId)
else null
return createUserSession(lastUserId)
}
private fun createUserSession(serverId: UUID, userId: UUID): Session? {
val account = authenticationStore.getUser(serverId, userId)
private fun createUserSession(userId: UUID?): Session? {
if (userId == null) return null
val account = accountManagerStore.getAccount(userId)
if (account?.accessToken == null) return null
return Session(
userId = userId,
serverId = serverId,
userId = account.id,
serverId = account.server,
accessToken = account.accessToken
)
}
private fun ApiClient.applySession(session: Session?, newDeviceInfo: DeviceInfo = defaultDeviceInfo): Boolean {
private fun ApiClient.applySession(session: Session?, newDeviceInfo: DeviceInfo = defaultDeviceInfo) {
deviceInfo = newDeviceInfo
if (session == null) {
update(
baseUrl = null,
accessToken = null,
deviceInfo = newDeviceInfo,
)
baseUrl = null
accessToken = null
userId = null
} else {
val server = authenticationStore.getServer(session.serverId)
?: return false
update(
baseUrl = server.address,
accessToken = session.accessToken,
deviceInfo = newDeviceInfo,
)
?: return applySession(null)
baseUrl = server.address
accessToken = session.accessToken
userId = session.userId
}
return true
}
}

View File

@@ -10,13 +10,13 @@ import org.jellyfin.sdk.model.api.UserDto
interface UserRepository {
val currentUser: StateFlow<UserDto?>
fun setCurrentUser(user: UserDto?)
fun updateCurrentUser(user: UserDto?)
}
class UserRepositoryImpl : UserRepository {
override val currentUser = MutableStateFlow<UserDto?>(null)
override fun setCurrentUser(user: UserDto?) {
currentUser.value = user
override fun updateCurrentUser(user: UserDto?) {
if (currentUser.value !== user) currentUser.value = user
}
}

View File

@@ -0,0 +1,79 @@
package org.jellyfin.androidtv.auth.service
import android.accounts.AbstractAccountAuthenticator
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.app.Service
import android.content.Intent
import android.os.Bundle
import android.os.IBinder
import androidx.core.os.bundleOf
import org.jellyfin.androidtv.ui.preference.PreferencesActivity
class AuthenticatorService : Service() {
private val authenticator by lazy {
Authenticator(this)
}
override fun onBind(intent: Intent): IBinder? = authenticator.iBinder
private inner class Authenticator(
private val service: AuthenticatorService
) : AbstractAccountAuthenticator(service) {
private val unsupportedOperationBundle = bundleOf(
AccountManager.KEY_ERROR_CODE to AccountManager.ERROR_CODE_REMOTE_EXCEPTION,
AccountManager.KEY_ERROR_MESSAGE to "Unsupported operation"
)
override fun addAccount(
response: AccountAuthenticatorResponse,
accountType: String,
authTokenType: String?,
requiredFeatures: Array<String>?,
options: Bundle,
): Bundle = unsupportedOperationBundle
override fun confirmCredentials(
response: AccountAuthenticatorResponse,
account: Account,
options: Bundle?,
): Bundle = unsupportedOperationBundle
override fun editProperties(
response: AccountAuthenticatorResponse,
accountType: String,
): Bundle = bundleOf(
AccountManager.KEY_INTENT to Intent(service, PreferencesActivity::class.java)
)
override fun getAccountRemovalAllowed(
response: AccountAuthenticatorResponse,
account: Account,
): Bundle = bundleOf(
AccountManager.KEY_BOOLEAN_RESULT to true
)
override fun getAuthToken(
response: AccountAuthenticatorResponse,
account: Account,
authTokenType: String,
options: Bundle,
): Bundle = unsupportedOperationBundle
override fun getAuthTokenLabel(authTokenType: String): String? = null
override fun hasFeatures(
response: AccountAuthenticatorResponse,
account: Account,
features: Array<String>,
): Bundle = unsupportedOperationBundle
override fun updateCredentials(
response: AccountAuthenticatorResponse,
account: Account,
authTokenType: String?,
options: Bundle?,
): Bundle = unsupportedOperationBundle
}
}

View File

@@ -0,0 +1,82 @@
package org.jellyfin.androidtv.auth.store
import android.accounts.Account
import android.accounts.AccountManager
import android.os.Bundle
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.auth.model.AccountManagerAccount
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class AccountManagerStore(
private val accountManager: AccountManager
) {
companion object {
const val ACCOUNT_TYPE = BuildConfig.APPLICATION_ID
const val ACCOUNT_DATA_ID = "$ACCOUNT_TYPE.id"
const val ACCOUNT_DATA_SERVER = "$ACCOUNT_TYPE.server"
const val ACCOUNT_DATA_NAME = "$ACCOUNT_TYPE.name"
const val ACCOUNT_ACCESS_TOKEN_TYPE = "$ACCOUNT_TYPE.access_token"
}
private fun getAccountData(account: Account): AccountManagerAccount = AccountManagerAccount(
id = accountManager.getUserData(account, ACCOUNT_DATA_ID).toUUID(),
server = accountManager.getUserData(account, ACCOUNT_DATA_SERVER).toUUID(),
name = accountManager.getUserData(account, ACCOUNT_DATA_NAME),
accessToken = accountManager.peekAuthToken(account, ACCOUNT_ACCESS_TOKEN_TYPE)
)
suspend fun putAccount(accountManagerAccount: AccountManagerAccount) {
var androidAccount = accountManager.getAccountsByType(ACCOUNT_TYPE)
.firstOrNull { accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == accountManagerAccount.id }
// Update credentials
if (androidAccount == null) {
androidAccount = Account(accountManagerAccount.name, ACCOUNT_TYPE)
accountManager.addAccountExplicitly(
androidAccount,
"", // Leave password empty
Bundle()
)
accountManager.setUserData(androidAccount, ACCOUNT_DATA_ID, accountManagerAccount.id.toString())
}
// Update name
if (androidAccount.name != accountManagerAccount.name) {
androidAccount = suspendCoroutine { continuation ->
accountManager.renameAccount(androidAccount, accountManagerAccount.name, { continuation.resume(it.result) }, null)
}
}
accountManager.setUserData(androidAccount, ACCOUNT_DATA_NAME, accountManagerAccount.name)
accountManager.setUserData(androidAccount, ACCOUNT_DATA_SERVER, accountManagerAccount.server.toString())
accountManager.setAuthToken(androidAccount, ACCOUNT_ACCESS_TOKEN_TYPE, accountManagerAccount.accessToken)
}
fun removeAccount(accountManagerAccount: AccountManagerAccount): Boolean {
val androidAccount = accountManager.getAccountsByType(ACCOUNT_TYPE)
.firstOrNull { accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == accountManagerAccount.id }
?: return false
// Remove current account info
@Suppress("DEPRECATION")
return if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP_MR1) {
accountManager.removeAccount(androidAccount, null, null)
true
} else accountManager.removeAccountExplicitly(androidAccount)
}
fun getAccounts() = accountManager.getAccountsByType(ACCOUNT_TYPE).map(::getAccountData)
fun getAccountsByServer(server: UUID) = accountManager.getAccountsByType(ACCOUNT_TYPE).filter { account ->
accountManager.getUserData(account, ACCOUNT_DATA_SERVER)?.toUUIDOrNull() == server
}.map(::getAccountData)
fun getAccount(id: UUID) = accountManager.getAccountsByType(ACCOUNT_TYPE).firstOrNull { account ->
accountManager.getUserData(account, ACCOUNT_DATA_ID)?.toUUIDOrNull() == id
}?.let(::getAccountData)
}

View File

@@ -14,30 +14,12 @@ class AuthenticationPreferences(context: Context) : SharedPreferenceStore(
companion object {
// Preferences
val autoLoginUserBehavior = enumPreference("auto_login_user_behavior", UserSelectBehavior.LAST_USER)
val autoLoginServerId = stringPreference("auto_login_server_id", "")
val autoLoginUserId = stringPreference("auto_login_user_id", "")
val sortBy = enumPreference("sort_by", AuthenticationSortBy.LAST_USE)
val alwaysAuthenticate = booleanPreference("always_authenticate", false)
// Persistent state
val lastServerId = stringPreference("last_server_id", "")
val lastUserId = stringPreference("last_user_id", "")
}
init {
runMigrations {
// v0.15.4 to v0.15.5
migration(toVersion = 2) {
// Unfortunately we cannot migrate the "specific user" login option
// so we'll reset the preference to disabled if it was used
if (it.getString("auto_login_user_behavior", null) === UserSelectBehavior.SPECIFIC_USER.name) {
putString("auto_login_user_id", "")
putString("auto_login_user_behavior", UserSelectBehavior.DISABLED.name)
}
putString("last_user_id", "")
}
}
}
}

View File

@@ -2,6 +2,7 @@ package org.jellyfin.androidtv.auth.store
import android.content.Context
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -19,13 +20,14 @@ import timber.log.Timber
import java.util.UUID
/**
* Storage for authentication related entities. Stores servers with users inside, including
* access tokens.
* Storage for authentication related entities. Stores servers with users inside. Should be used in
* combination with the [AccountManagerStore] to store access tokens and integrate with the
* operating system.
*
* The data is stored in a JSON file located in the applications data directory.
*/
class AuthenticationStore(
private val context: Context,
private val context: Context
) {
private val storePath
get() = context.filesDir.resolve("authentication_store.json")
@@ -56,20 +58,11 @@ class AuthenticationStore(
// Check for version
return when (root["version"]?.jsonPrimitive?.intOrNull) {
// Migration was removed, clear stored servers
1 -> {
Timber.e("Migrating from version 1 is no longer possible")
emptyMap()
}
// Current version, return as-is
2 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
1 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
null -> {
Timber.e("Authentication Store is corrupt!")
emptyMap()
}
else -> {
Timber.e("Authentication Store is using an unknown version!")
emptyMap()
@@ -77,10 +70,10 @@ class AuthenticationStore(
}
}
private fun write(servers: Map<UUID, AuthenticationStoreServer>): Boolean {
private fun save(): Boolean {
val root = JsonObject(mapOf(
"version" to JsonPrimitive(2),
"servers" to json.encodeToJsonElement(servers)
"version" to JsonPrimitive(1),
"servers" to json.encodeToJsonElement(store)
))
storePath.writeText(json.encodeToString(root))
@@ -88,10 +81,6 @@ class AuthenticationStore(
return true
}
private fun save(): Boolean {
return write(store)
}
fun getServers(): Map<UUID, AuthenticationStoreServer> = store
fun getUsers(server: UUID): Map<UUID, AuthenticationStoreUser>? = getServer(server)?.users

View File

@@ -4,6 +4,9 @@ enum class ChangeTriggerType {
LibraryUpdated,
MoviePlayback,
TvPlayback,
GuideNeedsLoad,
MusicPlayback,
Always,
VideoQueueChange,
FavoriteUpdate,
}

View File

@@ -7,7 +7,6 @@ object Codec {
const val ASF = "asf"
const val AVI = "avi"
const val DVR_MS = "dvr-ms"
const val HLS = "hls"
const val M2V = "m2v"
const val M4V = "m4v"
const val MKV = "mkv"
@@ -48,10 +47,8 @@ object Codec {
const val PCM_ALAW = "pcm_alaw"
const val PCM_MULAW = "pcm_mulaw"
const val PCM_S16LE = "pcm_s16le"
const val PCM_S20LE = "pcm_s20le"
const val PCM_S24LE = "pcm_s24le"
const val TRUEHD = "truehd"
const val VORBIS = "vorbis"
const val WAV = "wav"
const val WEBMA = "webma"
const val WMA = "wma"
@@ -65,13 +62,10 @@ object Codec {
const val MPEG2VIDEO = "mpeg2video"
const val VP8 = "vp8"
const val VP9 = "vp9"
const val AV1 = "av1"
const val VC1 = "vc1"
}
object Subtitle {
const val ASS = "ass"
const val DVBSUB = "dvbsub"
const val DVDSUB = "dvdsub"
const val IDX = "idx"
const val PGS = "pgs"
@@ -82,8 +76,5 @@ object Codec {
const val SUB = "sub"
const val SUBRIP = "subrip"
const val VTT = "vtt"
const val SMIL = "smil"
const val TTML = "ttml"
const val WEBVTT = "webvtt"
}
}

View File

@@ -1,6 +1,6 @@
package org.jellyfin.androidtv.constant
sealed interface CustomMessage {
data object RefreshCurrentItem : CustomMessage
data object ActionComplete : CustomMessage
enum class CustomMessage {
RefreshCurrentItem,
ActionComplete,
}

View File

@@ -2,6 +2,5 @@ package org.jellyfin.androidtv.constant
object Extras {
const val Folder = "folder"
const val IsLiveTvSeriesRecordings = "is_livetv_series_recordings"
const val IncludeType = "type_include"
}

View File

@@ -1,18 +1,18 @@
package org.jellyfin.androidtv.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
enum class GridDirection(
override val nameRes: Int,
) : PreferenceEnum {
enum class GridDirection {
/**
* Horizontal.
*/
HORIZONTAL(R.string.grid_direction_horizontal),
@EnumDisplayOptions(R.string.grid_direction_horizontal)
HORIZONTAL,
/**
* Vertical.
*/
VERTICAL(R.string.grid_direction_vertical),
@EnumDisplayOptions(R.string.grid_direction_vertical)
VERTICAL,
}

View File

@@ -1,6 +1,7 @@
package org.jellyfin.androidtv.constant
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
import org.jellyfin.preference.PreferenceEnum
/**
@@ -9,17 +10,35 @@ import org.jellyfin.preference.PreferenceEnum
* https://github.com/jellyfin/jellyfin-web/blob/master/src/components/homesections/homesections.js
*/
enum class HomeSectionType(
override val serializedName: String,
override val nameRes: Int,
override val serializedName: String
) : PreferenceEnum {
LATEST_MEDIA("latestmedia", R.string.home_section_latest_media),
LIBRARY_TILES_SMALL("smalllibrarytiles", R.string.home_section_library),
LIBRARY_BUTTONS("librarybuttons", R.string.home_section_library_small),
RESUME("resume", R.string.home_section_resume),
RESUME_AUDIO("resumeaudio", R.string.home_section_resume_audio),
RESUME_BOOK("resumebook", R.string.home_section_resume_book),
ACTIVE_RECORDINGS("activerecordings", R.string.home_section_active_recordings),
NEXT_UP("nextup", R.string.home_section_next_up),
LIVE_TV("livetv", R.string.home_section_livetv),
NONE("none", R.string.home_section_none),
@EnumDisplayOptions(R.string.home_section_latest_media)
LATEST_MEDIA("latestmedia"),
@EnumDisplayOptions(R.string.home_section_library)
LIBRARY_TILES_SMALL("smalllibrarytiles"),
@EnumDisplayOptions(R.string.home_section_library_small)
LIBRARY_BUTTONS("librarybuttons"),
@EnumDisplayOptions(R.string.home_section_resume)
RESUME("resume"),
@EnumDisplayOptions(R.string.home_section_resume_audio)
RESUME_AUDIO("resumeaudio"),
@EnumDisplayOptions(R.string.home_section_resume_book)
RESUME_BOOK("resumebook"),
@EnumDisplayOptions(R.string.home_section_active_recordings)
ACTIVE_RECORDINGS("activerecordings"),
@EnumDisplayOptions(R.string.home_section_next_up)
NEXT_UP("nextup"),
@EnumDisplayOptions(R.string.home_section_livetv)
LIVE_TV("livetv"),
@EnumDisplayOptions(R.string.home_section_none)
NONE("none"),
}

View File

@@ -1,23 +1,24 @@
package org.jellyfin.androidtv.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
enum class ImageType(
override val nameRes: Int,
) : PreferenceEnum {
enum class ImageType {
/**
* Poster.
*/
POSTER(R.string.image_type_poster),
@EnumDisplayOptions(R.string.image_type_poster)
POSTER,
/**
* Thumbnail.
*/
THUMB(R.string.image_type_thumbnail),
@EnumDisplayOptions(R.string.image_type_thumbnail)
THUMB,
/**
* Banner.
*/
BANNER(R.string.image_type_banner),
@EnumDisplayOptions(R.string.image_type_banner)
BANNER
}

View File

@@ -3,6 +3,7 @@ package org.jellyfin.androidtv.constant
object LiveTvOption {
const val LIVE_TV_GUIDE_OPTION_ID = 1000
const val LIVE_TV_RECORDINGS_OPTION_ID = 2000
const val VIDEO_QUEUE_OPTION_ID = 3000
const val LIVE_TV_SCHEDULE_OPTION_ID = 4000
const val LIVE_TV_SERIES_OPTION_ID = 5000
}

View File

@@ -1,32 +1,35 @@
package org.jellyfin.androidtv.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
enum class PosterSize(
override val nameRes: Int,
) : PreferenceEnum {
enum class PosterSize {
/**
* Smallest.
*/
SMALLEST(R.string.image_size_smallest),
@EnumDisplayOptions(R.string.image_size_smallest)
SMALLEST,
/**
* Small.
*/
SMALL(R.string.image_size_small),
@EnumDisplayOptions(R.string.image_size_small)
SMALL,
/**
* Medium.
*/
MED(R.string.image_size_medium),
@EnumDisplayOptions(R.string.image_size_medium)
MED,
/**
* Large.
*/
LARGE(R.string.image_size_large),
@EnumDisplayOptions(R.string.image_size_large)
LARGE,
/**
* Extra Large.
*/
X_LARGE(R.string.image_size_xlarge),
@EnumDisplayOptions(R.string.image_size_xlarge)
X_LARGE
}

View File

@@ -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
}

View File

@@ -17,12 +17,13 @@ enum class QueryType {
LiveTvChannel,
LiveTvProgram,
LiveTvRecording,
LiveTvRecordingGroup,
StaticItems,
Artists,
Persons,
StaticAudioQueueItems,
AlbumArtists,
AudioPlaylists,
LatestItems,
SeriesTimer,
Premieres,
Resume,
}

View File

@@ -0,0 +1,127 @@
package org.jellyfin.androidtv.data.compat;
import org.jellyfin.apiclient.model.dlna.DeviceProfile;
import org.jellyfin.apiclient.model.dlna.EncodingContext;
import org.jellyfin.apiclient.model.dto.MediaSourceInfo;
import java.util.ArrayList;
/**
* Class AudioOptions.
*
* @deprecated
*/
@Deprecated
public class AudioOptions {
public AudioOptions() {
setContext(EncodingContext.Streaming);
setEnableDirectPlay(true);
setEnableDirectStream(true);
}
private boolean EnableDirectPlay;
public final boolean getEnableDirectPlay() {
return EnableDirectPlay;
}
public final void setEnableDirectPlay(boolean value) {
EnableDirectPlay = value;
}
private boolean EnableDirectStream;
public final boolean getEnableDirectStream() {
return EnableDirectStream;
}
public final void setEnableDirectStream(boolean value) {
EnableDirectStream = value;
}
private String ItemId;
public final String getItemId() {
return ItemId;
}
public final void setItemId(String value) {
ItemId = value;
}
private ArrayList<MediaSourceInfo> MediaSources;
public final ArrayList<MediaSourceInfo> getMediaSources() {
return MediaSources;
}
public final void setMediaSources(ArrayList<MediaSourceInfo> value) {
MediaSources = value;
}
private DeviceProfile Profile;
public final DeviceProfile getProfile() {
return Profile;
}
public final void setProfile(DeviceProfile value) {
Profile = value;
}
/**
* Optional. Only needed if a specific AudioStreamIndex or SubtitleStreamIndex are requested.
*/
private String MediaSourceId;
public final String getMediaSourceId() {
return MediaSourceId;
}
public final void setMediaSourceId(String value) {
MediaSourceId = value;
}
/**
* Allows an override of supported number of audio channels
* Example: DeviceProfile supports five channel, but user only has stereo speakers
*/
private Integer MaxAudioChannels = null;
public final Integer getMaxAudioChannels() {
return MaxAudioChannels;
}
public final void setMaxAudioChannels(Integer value) {
MaxAudioChannels = value;
}
/**
* The application's configured quality setting
*/
private Integer MaxBitrate = null;
public final Integer getMaxBitrate() {
return MaxBitrate;
}
public final void setMaxBitrate(Integer value) {
MaxBitrate = value;
}
/**
* Gets or sets the context.
*
* <value>The context.</value>
*/
private EncodingContext Context = EncodingContext.values()[0];
public final EncodingContext getContext() {
return Context;
}
public final void setContext(EncodingContext value) {
Context = value;
}
}

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,87 @@
package org.jellyfin.androidtv.data.compat;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod;
import org.jellyfin.apiclient.model.dlna.SubtitleProfile;
import org.jellyfin.apiclient.model.entities.MediaStream;
import org.jellyfin.apiclient.model.session.PlayMethod;
@Deprecated
public class StreamBuilder
{
public static SubtitleProfile GetSubtitleProfile(MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod)
{
if (playMethod != PlayMethod.Transcode && !subtitleStream.getIsExternal())
{
// Look for supported embedded subs
for (SubtitleProfile profile : subtitleProfiles)
{
if (!profile.SupportsLanguage(subtitleStream.getLanguage()))
{
continue;
}
if (profile.getMethod() != SubtitleDeliveryMethod.Embed)
{
continue;
}
if (subtitleStream.getIsTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat()) && Utils.equalsIgnoreCase(profile.getFormat(), subtitleStream.getCodec()))
{
return profile;
}
}
}
// Look for an external or hls profile that matches the stream type (text/graphical) and doesn't require conversion
SubtitleProfile tempVar = new SubtitleProfile();
tempVar.setMethod(SubtitleDeliveryMethod.Encode);
tempVar.setFormat(subtitleStream.getCodec());
SubtitleProfile tempVar2 = GetExternalSubtitleProfile(subtitleStream, subtitleProfiles, playMethod, false);
SubtitleProfile tempVar3 = GetExternalSubtitleProfile(subtitleStream, subtitleProfiles, playMethod, true);
return (tempVar2 != null) ? tempVar2 : (tempVar3 != null) ? tempVar3 : tempVar;
}
private static SubtitleProfile GetExternalSubtitleProfile(MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod, boolean allowConversion)
{
for (SubtitleProfile profile : subtitleProfiles)
{
if (profile.getMethod() != SubtitleDeliveryMethod.External && profile.getMethod() != SubtitleDeliveryMethod.Hls)
{
continue;
}
if (profile.getMethod() == SubtitleDeliveryMethod.Hls && playMethod != PlayMethod.Transcode)
{
continue;
}
if (!profile.SupportsLanguage(subtitleStream.getLanguage()))
{
continue;
}
if ((profile.getMethod() == SubtitleDeliveryMethod.External && subtitleStream.getIsTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat())) || (profile.getMethod() == SubtitleDeliveryMethod.Hls && subtitleStream.getIsTextSubtitleStream()))
{
boolean requiresConversion = !Utils.equalsIgnoreCase(subtitleStream.getCodec(), profile.getFormat());
if (!requiresConversion)
{
return profile;
}
if (!allowConversion)
{
continue;
}
if (subtitleStream.getIsTextSubtitleStream() && subtitleStream.getSupportsExternalStream() && subtitleStream.SupportsSubtitleConversionTo(profile.getFormat()))
{
return profile;
}
}
}
return null;
}
}

View File

@@ -1,22 +1,43 @@
package org.jellyfin.androidtv.data.compat;
import org.jellyfin.sdk.model.api.MediaSourceInfo;
import org.jellyfin.sdk.model.api.MediaStream;
import org.jellyfin.sdk.model.api.MediaStreamType;
import org.jellyfin.sdk.model.api.PlayMethod;
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.apiclient.model.dlna.DeviceProfile;
import org.jellyfin.apiclient.model.dlna.DlnaProfileType;
import org.jellyfin.apiclient.model.dlna.EncodingContext;
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod;
import org.jellyfin.apiclient.model.dlna.SubtitleProfile;
import org.jellyfin.apiclient.model.dlna.TranscodeSeekInfo;
import org.jellyfin.apiclient.model.dto.MediaSourceInfo;
import org.jellyfin.apiclient.model.dto.NameValuePair;
import org.jellyfin.apiclient.model.entities.MediaStream;
import org.jellyfin.apiclient.model.entities.MediaStreamType;
import org.jellyfin.apiclient.model.mediainfo.MediaProtocol;
import org.jellyfin.apiclient.model.session.PlayMethod;
import java.util.ArrayList;
import java.util.UUID;
/**
* Class StreamInfo.
*
* @deprecated
*/
@Deprecated
public class StreamInfo {
private UUID ItemId;
private static final String START_TIME_TICKS = "StartTimeTicks";
private static final String SUBTITLE_STREAM_INDEX = "SubtitleStreamIndex";
private static final String STATIC = "Static";
public final UUID getItemId() {
public StreamInfo() {
setAudioCodecs(new String[]{});
}
private String ItemId;
public final String getItemId() {
return ItemId;
}
public final void setItemId(UUID value) {
public final void setItemId(String value) {
ItemId = value;
}
@@ -30,14 +51,34 @@ public class StreamInfo {
MediaUrl = value;
}
private PlayMethod playMethod = PlayMethod.DIRECT_PLAY;
private PlayMethod PlayMethod = getPlayMethod().values()[0];
public final PlayMethod getPlayMethod() {
return playMethod;
return PlayMethod;
}
public final void setPlayMethod(PlayMethod value) {
playMethod = value;
PlayMethod = value;
}
private EncodingContext Context = EncodingContext.values()[0];
public final EncodingContext getContext() {
return Context;
}
public final void setContext(EncodingContext value) {
Context = value;
}
private DlnaProfileType MediaType = DlnaProfileType.values()[0];
public final DlnaProfileType getMediaType() {
return MediaType;
}
public final void setMediaType(DlnaProfileType value) {
MediaType = value;
}
private String Container;
@@ -50,6 +91,46 @@ public class StreamInfo {
Container = value;
}
private long StartPositionTicks;
public final long getStartPositionTicks() {
return StartPositionTicks;
}
public final void setStartPositionTicks(long value) {
StartPositionTicks = value;
}
private String[] AudioCodecs;
public final String[] getAudioCodecs() {
return AudioCodecs;
}
public final void setAudioCodecs(String[] value) {
AudioCodecs = value;
}
private DeviceProfile DeviceProfile;
public final DeviceProfile getDeviceProfile() {
return DeviceProfile;
}
public final void setDeviceProfile(DeviceProfile value) {
DeviceProfile = value;
}
private String DeviceId;
public final String getDeviceId() {
return DeviceId;
}
public final void setDeviceId(String value) {
DeviceId = value;
}
private Long RunTimeTicks = null;
public final Long getRunTimeTicks() {
@@ -60,6 +141,12 @@ public class StreamInfo {
RunTimeTicks = value;
}
private TranscodeSeekInfo TranscodeSeekInfo = getTranscodeSeekInfo().values()[0];
public final TranscodeSeekInfo getTranscodeSeekInfo() {
return TranscodeSeekInfo;
}
private MediaSourceInfo MediaSource;
public final MediaSourceInfo getMediaSource() {
@@ -70,10 +157,10 @@ public class StreamInfo {
MediaSource = value;
}
public final org.jellyfin.sdk.model.api.SubtitleDeliveryMethod getSubtitleDeliveryMethod() {
Integer subtitleStreamIndex = MediaSource.getDefaultSubtitleStreamIndex();
if (subtitleStreamIndex == null || subtitleStreamIndex == -1) return SubtitleDeliveryMethod.DROP;
return MediaSource.getMediaStreams().get(subtitleStreamIndex).getDeliveryMethod();
private SubtitleDeliveryMethod SubtitleDeliveryMethod = getSubtitleDeliveryMethod().values()[0];
public final SubtitleDeliveryMethod getSubtitleDeliveryMethod() {
return SubtitleDeliveryMethod;
}
private String PlaySessionId;
@@ -90,14 +177,191 @@ public class StreamInfo {
return getMediaSource() == null ? null : getMediaSource().getId();
}
public final ArrayList<MediaStream> getSelectableAudioStreams() {
return getSelectableStreams(MediaStreamType.AUDIO);
public final boolean getIsDirectStream() {
return getPlayMethod() == PlayMethod.DirectStream || getPlayMethod() == PlayMethod.DirectPlay;
}
public final ArrayList<org.jellyfin.sdk.model.api.MediaStream> getSelectableStreams(MediaStreamType type) {
ArrayList<org.jellyfin.sdk.model.api.MediaStream> list = new ArrayList<org.jellyfin.sdk.model.api.MediaStream>();
public final String ToUrl(String baseUrl, String accessToken) {
if (!Utils.isEmpty(getMediaUrl())) {
return getMediaUrl();
}
for (org.jellyfin.sdk.model.api.MediaStream stream : getMediaSource().getMediaStreams()) {
if (getPlayMethod() == PlayMethod.DirectPlay) {
return getMediaSource().getPath();
}
if (Utils.isEmpty(baseUrl)) {
throw new IllegalArgumentException(baseUrl);
}
ArrayList<String> list = new ArrayList<String>();
for (NameValuePair pair : BuildParams(this, accessToken, false)) {
if (Utils.isEmpty(pair.getValue())) {
continue;
}
// Try to keep the url clean by omitting defaults
if (START_TIME_TICKS.equalsIgnoreCase(pair.getName()) && "0".equalsIgnoreCase(pair.getValue())) {
continue;
}
if (SUBTITLE_STREAM_INDEX.equalsIgnoreCase(pair.getName()) && "-1".equalsIgnoreCase(pair.getValue())) {
continue;
}
if (STATIC.equalsIgnoreCase(pair.getName()) && "false".equalsIgnoreCase(pair.getValue())) {
continue;
}
list.add(String.format("%1$s=%2$s", pair.getName(), pair.getValue()));
}
String queryString = Utils.join("&", list);
return GetUrl(baseUrl, queryString);
}
private String GetUrl(String baseUrl, String queryString) {
if (Utils.isEmpty(baseUrl)) {
throw new IllegalArgumentException(baseUrl);
}
String extension = Utils.isEmpty(getContainer()) ? "" : "." + getContainer();
// remove trailing slashes
baseUrl = baseUrl.replaceAll("[/]+$", "");
if (getMediaType() == DlnaProfileType.Audio) {
return String.format("%1$s/audio/%2$s/stream%3$s?%4$s", baseUrl, getItemId(), extension, queryString);
}
return String.format("%1$s/videos/%2$s/stream%3$s?%4$s", baseUrl, getItemId(), extension, queryString);
}
private static ArrayList<NameValuePair> BuildParams(StreamInfo item, String accessToken, boolean isDlna) {
ArrayList<NameValuePair> list = new ArrayList<NameValuePair>();
String audioCodecs = item.getAudioCodecs().length == 0 ? "" : Utils.join(",", item.getAudioCodecs());
list.add(new NameValuePair("DeviceProfileId", ""));
String tempVar2 = item.getDeviceId();
list.add(new NameValuePair("DeviceId", (tempVar2 != null) ? tempVar2 : ""));
String tempVar3 = item.getMediaSourceId();
list.add(new NameValuePair("MediaSourceId", (tempVar3 != null) ? tempVar3 : ""));
list.add(new NameValuePair(STATIC, Boolean.valueOf(item.getIsDirectStream()).toString().toLowerCase()));
list.add(new NameValuePair("VideoCodec", ""));
list.add(new NameValuePair("AudioCodec", audioCodecs));
list.add(new NameValuePair("AudioStreamIndex", ""));
list.add(new NameValuePair("SubtitleStreamIndex", ""));
list.add(new NameValuePair("VideoBitrate", ""));
list.add(new NameValuePair("AudioBitrate", ""));
list.add(new NameValuePair("MaxAudioChannels", ""));
list.add(new NameValuePair("MaxFramerate", ""));
list.add(new NameValuePair("MaxWidth", ""));
list.add(new NameValuePair("MaxHeight", ""));
list.add(new NameValuePair(START_TIME_TICKS, String.valueOf(item.getStartPositionTicks())));
list.add(new NameValuePair("Level", ""));
list.add(new NameValuePair("MaxRefFrames", ""));
list.add(new NameValuePair("MaxVideoBitDepth", ""));
list.add(new NameValuePair("Profile", ""));
// no longer used
list.add(new NameValuePair("Cabac", ""));
String tempVar6 = item.getPlaySessionId();
list.add(new NameValuePair("PlaySessionId", (tempVar6 != null) ? tempVar6 : ""));
list.add(new NameValuePair("api_key", (accessToken != null) ? accessToken : ""));
String liveStreamId = item.getMediaSource() == null ? null : item.getMediaSource().getLiveStreamId();
list.add(new NameValuePair("LiveStreamId", (liveStreamId != null) ? liveStreamId : ""));
if (isDlna) {
list.add(new NameValuePair("ItemId", item.getItemId()));
}
list.add(new NameValuePair("CopyTimestamps", "false"));
list.add(new NameValuePair("ForceLiveStream", "false"));
list.add(new NameValuePair("SubtitleMethod", ""));
list.add(new NameValuePair("TranscodingMaxAudioChannels", ""));
list.add(new NameValuePair("EnableSubtitlesInManifest", "false"));
String tempVar7 = item.getMediaSource().getETag();
list.add(new NameValuePair("Tag", (tempVar7 != null) ? tempVar7 : ""));
return list;
}
public final ArrayList<SubtitleStreamInfo> GetSubtitleProfiles(boolean includeSelectedTrackOnly, String baseUrl, String accessToken) {
return GetSubtitleProfiles(includeSelectedTrackOnly, false, baseUrl, accessToken);
}
public final ArrayList<SubtitleStreamInfo> GetSubtitleProfiles(boolean includeSelectedTrackOnly, boolean enableAllProfiles, String baseUrl, String accessToken) {
ArrayList<SubtitleStreamInfo> list = new ArrayList<SubtitleStreamInfo>();
// HLS will preserve timestamps so we can just grab the full subtitle stream
long startPositionTicks = getPlayMethod() == PlayMethod.Transcode ? getStartPositionTicks() : 0;
if (!includeSelectedTrackOnly) {
for (MediaStream stream : getMediaSource().getMediaStreams()) {
if (stream.getType() == MediaStreamType.Subtitle) {
AddSubtitleProfiles(list, stream, enableAllProfiles, baseUrl, accessToken, startPositionTicks);
}
}
}
return list;
}
private void AddSubtitleProfiles(ArrayList<SubtitleStreamInfo> list, MediaStream stream, boolean enableAllProfiles, String baseUrl, String accessToken, long startPositionTicks) {
if (enableAllProfiles) {
for (SubtitleProfile profile : getDeviceProfile().getSubtitleProfiles()) {
SubtitleStreamInfo info = GetSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, new SubtitleProfile[]{profile});
list.add(info);
}
} else {
SubtitleStreamInfo info = GetSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, getDeviceProfile().getSubtitleProfiles());
list.add(info);
}
}
private SubtitleStreamInfo GetSubtitleStreamInfo(MediaStream stream, String baseUrl, String accessToken, long startPositionTicks, SubtitleProfile[] subtitleProfiles) {
SubtitleProfile subtitleProfile = StreamBuilder.GetSubtitleProfile(stream, subtitleProfiles, getPlayMethod());
SubtitleStreamInfo tempVar = new SubtitleStreamInfo();
String tempVar2 = stream.getLanguage();
tempVar.setName((tempVar2 != null) ? tempVar2 : "Unknown");
tempVar.setFormat(subtitleProfile.getFormat());
tempVar.setIndex(stream.getIndex());
tempVar.setDeliveryMethod(subtitleProfile.getMethod());
tempVar.setDisplayTitle(stream.getDisplayTitle());
SubtitleStreamInfo info = tempVar;
if (info.getDeliveryMethod() == SubtitleDeliveryMethod.External) {
if (getMediaSource().getProtocol() == MediaProtocol.File || !stream.getCodec().equalsIgnoreCase(subtitleProfile.getFormat())) {
info.setUrl(String.format("%1$s/Videos/%2$s/%3$s/Subtitles/%4$s/%5$s/Stream.%6$s", baseUrl, getItemId(), getMediaSourceId(), String.valueOf(stream.getIndex()), String.valueOf(startPositionTicks), subtitleProfile.getFormat()));
if (!Utils.isEmpty(accessToken)) {
info.setUrl(info.getUrl() + "?api_key=" + accessToken);
}
} else {
info.setUrl(stream.getPath());
}
}
return info;
}
public final ArrayList<MediaStream> GetSelectableAudioStreams() {
return GetSelectableStreams(MediaStreamType.Audio);
}
public final ArrayList<MediaStream> GetSelectableStreams(MediaStreamType type) {
ArrayList<MediaStream> list = new ArrayList<MediaStream>();
for (MediaStream stream : getMediaSource().getMediaStreams()) {
if (type == stream.getType()) {
list.add(stream);
}

View File

@@ -0,0 +1,66 @@
package org.jellyfin.androidtv.data.compat;
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod;
@Deprecated
public class SubtitleStreamInfo {
private String Url;
public final String getUrl() {
return Url;
}
public final void setUrl(String value) {
Url = value;
}
private String Name;
public final String getName() {
return Name;
}
public final void setName(String value) {
Name = value;
}
private String Format;
public final String getFormat() {
return Format;
}
public final void setFormat(String value) {
Format = value;
}
private String DisplayTitle;
public final String getDisplayTitle() {
return DisplayTitle;
}
public final void setDisplayTitle(String value) {
DisplayTitle = value;
}
private int Index;
public final int getIndex() {
return Index;
}
public final void setIndex(int value) {
Index = value;
}
private SubtitleDeliveryMethod DeliveryMethod = SubtitleDeliveryMethod.values()[0];
public final SubtitleDeliveryMethod getDeliveryMethod() {
return DeliveryMethod;
}
public final void setDeliveryMethod(SubtitleDeliveryMethod value) {
DeliveryMethod = value;
}
}

View File

@@ -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;
}
}

View File

@@ -1,6 +0,0 @@
package org.jellyfin.androidtv.data.compat
class VideoOptions : AudioOptions() {
var audioStreamIndex: Int? = null
var subtitleStreamIndex: Int? = null
}

View File

@@ -1,46 +1,33 @@
package org.jellyfin.androidtv.data.eventhandling
import android.content.Context
import android.media.AudioManager
import android.widget.Toast
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.data.model.DataRefreshService
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
import org.jellyfin.androidtv.ui.navigation.Destinations
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
import org.jellyfin.androidtv.ui.playback.setSubtitleIndex
import org.jellyfin.androidtv.util.PlaybackHelper
import org.jellyfin.androidtv.util.apiclient.PlaybackHelper
import org.jellyfin.apiclient.model.entities.MediaType
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.sessionApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.sockets.subscribe
import org.jellyfin.sdk.api.sockets.subscribeGeneralCommand
import org.jellyfin.sdk.api.sockets.subscribeGeneralCommands
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.api.sockets.SocketInstance
import org.jellyfin.sdk.api.sockets.SocketInstanceState
import org.jellyfin.sdk.api.sockets.addGeneralCommandsListener
import org.jellyfin.sdk.api.sockets.addListener
import org.jellyfin.sdk.model.api.GeneralCommandType
import org.jellyfin.sdk.model.api.LibraryChangedMessage
import org.jellyfin.sdk.model.api.LibraryUpdateInfo
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PlayMessage
import org.jellyfin.sdk.model.api.PlaystateCommand
import org.jellyfin.sdk.model.api.PlaystateMessage
import org.jellyfin.sdk.model.extensions.get
import org.jellyfin.sdk.model.extensions.getValue
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.jellyfin.sdk.model.socket.LibraryChangedMessage
import org.jellyfin.sdk.model.socket.PlayMessage
import org.jellyfin.sdk.model.socket.PlayStateMessage
import timber.log.Timber
import java.time.Instant
import java.util.UUID
class SocketHandler(
@@ -49,113 +36,53 @@ class SocketHandler(
private val dataRefreshService: DataRefreshService,
private val mediaManager: MediaManager,
private val playbackControllerContainer: PlaybackControllerContainer,
private val navigationRepository: NavigationRepository,
private val audioManager: AudioManager,
private val itemLauncher: ItemLauncher,
private val playbackHelper: PlaybackHelper,
private val lifecycle: Lifecycle,
) {
init {
lifecycle.coroutineScope.launch(Dispatchers.IO) {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
subscribe(this)
}
}
}
private val coroutineScope = CoroutineScope(Dispatchers.IO)
private var socketInstance: SocketInstance = createInstance()
val state = socketInstance.state
suspend fun updateSession() {
try {
withContext(Dispatchers.IO) {
api.sessionApi.postCapabilities(
playableMediaTypes = listOf(MediaType.VIDEO, MediaType.AUDIO),
supportsMediaControl = true,
supportedCommands = buildList {
add(GeneralCommandType.DISPLAY_CONTENT)
add(GeneralCommandType.SET_SUBTITLE_STREAM_INDEX)
add(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
add(GeneralCommandType.DISPLAY_MESSAGE)
add(GeneralCommandType.SEND_STRING)
// Note: These are used in the PlaySessionSocketService
if (!audioManager.isVolumeFixed) {
add(GeneralCommandType.VOLUME_UP)
add(GeneralCommandType.VOLUME_DOWN)
add(GeneralCommandType.SET_VOLUME)
add(GeneralCommandType.MUTE)
add(GeneralCommandType.UNMUTE)
add(GeneralCommandType.TOGGLE_MUTE)
}
},
)
}
api.sessionApi.postCapabilities(
playableMediaTypes = listOf(MediaType.Video, MediaType.Audio),
supportsMediaControl = true,
supportedCommands = listOf(
GeneralCommandType.DISPLAY_MESSAGE,
GeneralCommandType.SEND_STRING,
),
)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to update capabilities")
return
}
val isOffline = state.value == SocketInstanceState.DISCONNECTED || state.value == SocketInstanceState.ERROR
if (isOffline) socketInstance.reconnect()
else socketInstance.updateCredentials()
}
private fun subscribe(coroutineScope: CoroutineScope) = api.webSocket.apply {
private fun createInstance() = api.ws().apply {
// Library
subscribe<LibraryChangedMessage>()
.onEach { message -> message.data?.let(::onLibraryChanged) }
.launchIn(coroutineScope)
addListener<LibraryChangedMessage> { message -> onLibraryChanged(message.info) }
// Media playback
subscribe<PlayMessage>()
.onEach { message -> onPlayMessage(message) }
.launchIn(coroutineScope)
subscribe<PlaystateMessage>()
.onEach { message -> onPlayStateMessage(message) }
.launchIn(coroutineScope)
subscribeGeneralCommand(GeneralCommandType.SET_SUBTITLE_STREAM_INDEX)
.onEach { message ->
val index = message["index"]?.toIntOrNull() ?: return@onEach
withContext(Dispatchers.Main) {
playbackControllerContainer.playbackController?.setSubtitleIndex(index)
}
}
.launchIn(coroutineScope)
subscribeGeneralCommand(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
.onEach { message ->
val index = message["index"]?.toIntOrNull() ?: return@onEach
withContext(Dispatchers.Main) {
playbackControllerContainer.playbackController?.switchAudioStream(index)
}
}
.launchIn(coroutineScope)
addListener<PlayMessage> { message -> onPlayMessage(message) }
addListener<PlayStateMessage> { message -> onPlayStateMessage(message) }
// General commands
subscribeGeneralCommand(GeneralCommandType.DISPLAY_CONTENT)
.onEach { message ->
val itemId by message
val itemType by message
addGeneralCommandsListener(setOf(GeneralCommandType.DISPLAY_CONTENT)) { message ->
val itemId by message
val itemUuid = itemId?.toUUIDOrNull()
val itemUuid = itemId?.toUUIDOrNull()
val itemKind = itemType?.let { type ->
BaseItemKind.entries.find { value ->
value.serialName.equals(type, true)
}
}
if (itemUuid != null) onDisplayContent(itemUuid)
}
addGeneralCommandsListener(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING)) { message ->
val header by message
val text by message
val string by message
if (itemUuid != null && itemKind != null) onDisplayContent(itemUuid, itemKind)
}
.launchIn(coroutineScope)
subscribeGeneralCommands(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING))
.onEach { message ->
val header by message
val text by message
val string by message
onDisplayMessage(header, text ?: string)
}
.launchIn(coroutineScope)
onDisplayMessage(header, text ?: string)
}
}
private fun onLibraryChanged(info: LibraryUpdateInfo) {
@@ -167,73 +94,64 @@ class SocketHandler(
})
if (info.itemsAdded.any() || info.itemsRemoved.any())
dataRefreshService.lastLibraryChange = Instant.now()
dataRefreshService.lastLibraryChange = System.currentTimeMillis()
}
private fun onPlayMessage(message: PlayMessage) {
val itemIds = message.data?.itemIds ?: return
val itemId = message.request.itemIds?.firstOrNull() ?: return
runCatching {
playbackHelper.retrieveAndPlay(
itemIds,
false,
message.data?.startPositionTicks,
message.data?.startIndex,
context
)
}.onFailure { Timber.w(it, "Failed to start playback") }
PlaybackHelper.retrieveAndPlay(
itemId.toString(),
false,
message.request.startPositionTicks,
context
)
}
@Suppress("ComplexMethod")
private suspend fun onPlayStateMessage(message: PlaystateMessage) = withContext(Dispatchers.Main) {
Timber.i("Received PlayStateMessage with command ${message.data?.command}")
// Audio playback uses (Rewrite)MediaManager, (legacy) video playback uses playbackController
when {
mediaManager.hasAudioQueueItems() -> {
Timber.i("Ignoring PlayStateMessage: should be handled by PlaySessionSocketService")
return@withContext
}
// PlaybackController
else -> {
val playbackController = playbackControllerContainer.playbackController
when (message.data?.command) {
PlaystateCommand.STOP -> playbackController?.endPlayback(true)
PlaystateCommand.PAUSE, PlaystateCommand.UNPAUSE, PlaystateCommand.PLAY_PAUSE -> playbackController?.playPause()
PlaystateCommand.NEXT_TRACK -> playbackController?.next()
PlaystateCommand.PREVIOUS_TRACK -> playbackController?.prev()
PlaystateCommand.SEEK -> playbackController?.seek(
(message.data?.seekPositionTicks ?: 0) / TICKS_TO_MS
)
PlaystateCommand.REWIND -> playbackController?.rewind()
PlaystateCommand.FAST_FORWARD -> playbackController?.fastForward()
null -> Unit
}
}
private fun onPlayStateMessage(message: PlayStateMessage) = coroutineScope.launch(Dispatchers.Main) {
Timber.i("Received PlayStateMessage with command ${message.request.command}")
val playbackController = playbackControllerContainer.playbackController
// Audio playback uses the mediaManager, video playback and live tv use the playbackController
if (mediaManager.isAudioPlayerInitialized) when (message.request.command) {
PlaystateCommand.STOP -> mediaManager.stopAudio(true)
PlaystateCommand.PAUSE, PlaystateCommand.UNPAUSE, PlaystateCommand.PLAY_PAUSE -> mediaManager.playPauseAudio()
PlaystateCommand.NEXT_TRACK -> mediaManager.nextAudioItem()
PlaystateCommand.PREVIOUS_TRACK -> mediaManager.prevAudioItem()
// Not implemented
PlaystateCommand.SEEK,
PlaystateCommand.REWIND,
PlaystateCommand.FAST_FORWARD -> Unit
} else when (message.request.command) {
PlaystateCommand.STOP -> playbackController?.endPlayback(true)
PlaystateCommand.PAUSE, PlaystateCommand.UNPAUSE, PlaystateCommand.PLAY_PAUSE -> playbackController?.playPause()
PlaystateCommand.NEXT_TRACK -> playbackController?.next()
PlaystateCommand.PREVIOUS_TRACK -> playbackController?.prev()
PlaystateCommand.SEEK -> playbackController?.seek(
(message.request.seekPositionTicks ?: 0) / TICKS_TO_MS
)
// FIXME get rewind/forward amount from displayprefs
PlaystateCommand.REWIND -> playbackController?.skip(REWIND_MS)
PlaystateCommand.FAST_FORWARD -> playbackController?.skip(FORWARD_MS)
}
}
private suspend fun onDisplayContent(itemId: UUID, itemKind: BaseItemKind) = withContext(Dispatchers.Main) {
// FIXME: Add an ItemLauncher function that accepts SDK BaseItemDto
// Add "GeneralCommandType.DISPLAY_CONTENT" to supportedCommands in capabilities to receive
// these messages. Otherwise this function is never called.
private fun onDisplayContent(itemId: UUID) {
val playbackController = playbackControllerContainer.playbackController
if (playbackController?.isPlaying == true || playbackController?.isPaused == true) {
Timber.i("Not launching $itemId: playback in progress")
return@withContext
return
}
Timber.i("Launching $itemId")
when (itemKind) {
BaseItemKind.USER_VIEW,
BaseItemKind.COLLECTION_FOLDER -> {
val item by api.userLibraryApi.getItem(itemId = itemId)
itemLauncher.launchUserView(item)
}
else -> navigationRepository.navigate(Destinations.itemDetails(itemId))
coroutineScope.launch {
val item by api.userLibraryApi.getItem(itemId = itemId)
// ItemLauncher.launch(item)
}
}
@@ -250,5 +168,7 @@ class SocketHandler(
companion object {
const val TICKS_TO_MS = 10000L
const val REWIND_MS = -11000
const val FORWARD_MS = 30000
}
}

View File

@@ -2,6 +2,4 @@ package org.jellyfin.androidtv.data.model
data class AppNotification(
val message: String,
val dismiss: () -> Unit,
val public: Boolean,
)

View File

@@ -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;
}
}

View File

@@ -1,11 +0,0 @@
package org.jellyfin.androidtv.data.model
import org.jellyfin.androidtv.util.apiclient.JellyfinImage
import java.util.UUID
data class ChapterItemInfo(
val itemId: UUID,
val name: String?,
val startPositionTicks: Long,
val image: JellyfinImage?,
)

View File

@@ -1,15 +1,15 @@
package org.jellyfin.androidtv.data.model
import org.jellyfin.sdk.model.api.BaseItemDto
import java.time.Instant
import java.util.UUID
class DataRefreshService {
var lastDeletedItemId: UUID? = null
var lastPlayback: Instant? = null
var lastMoviePlayback: Instant? = null
var lastTvPlayback: Instant? = null
var lastLibraryChange: Instant? = null
var lastFavoriteUpdate: Instant? = null
var lastDeletedItemId: String? = null
var lastPlayback: Long = 0
var lastMoviePlayback: Long = 0
var lastTvPlayback: Long = 0
var lastMusicPlayback: Long = 0
var lastLibraryChange: Long = 0
var lastVideoQueueChange: Long = 0
var lastFavoriteUpdate: Long = 0
var lastPlayedItem: BaseItemDto? = null
}

View File

@@ -0,0 +1,37 @@
package org.jellyfin.androidtv.data.model;
import org.jellyfin.apiclient.model.querying.ItemFilter;
import java.util.ArrayList;
import java.util.List;
public class FilterOptions {
private boolean favoriteOnly;
private boolean unwatchedOnly;
public void setFavoriteOnly(boolean value) {
favoriteOnly = value;
}
public void setUnwatchedOnly(boolean value) {
unwatchedOnly = value;
}
public boolean isUnwatchedOnly() {
return unwatchedOnly;
}
public boolean isFavoriteOnly() {
return favoriteOnly;
}
public ItemFilter[] getFilters() {
if (!unwatchedOnly && !favoriteOnly) return null;
List<ItemFilter> filters = new ArrayList<>();
if (favoriteOnly) filters.add(ItemFilter.IsFavorite);
if (unwatchedOnly) filters.add(ItemFilter.IsUnplayed);
return filters.toArray(new ItemFilter[0]);
}
}

View File

@@ -1,15 +0,0 @@
package org.jellyfin.androidtv.data.model
import org.jellyfin.sdk.model.api.ItemFilter
class FilterOptions {
var isFavoriteOnly = false
var isUnwatchedOnly = false
val filters: Set<ItemFilter>
get() = buildSet {
if (isFavoriteOnly) add(ItemFilter.IS_FAVORITE)
if (isUnwatchedOnly) add(ItemFilter.IS_UNPLAYED)
}
}

View File

@@ -0,0 +1,23 @@
package org.jellyfin.androidtv.data.model;
public class InfoItem {
private String label;
private String value;
public InfoItem() {
this("", "");
}
public InfoItem(String label, String value) {
this.label = label;
this.value = value;
}
public String getLabel() {
return label;
}
public String getValue() {
return value;
}
}

View File

@@ -1,6 +0,0 @@
package org.jellyfin.androidtv.data.model
data class InfoItem @JvmOverloads constructor(
val label: String = "",
val value: String = "",
)

View File

@@ -0,0 +1,3 @@
package org.jellyfin.androidtv.data.querying;
data class AdditionalPartsQuery(val itemId: String)

View File

@@ -1,7 +0,0 @@
package org.jellyfin.androidtv.data.querying;
import java.util.UUID
data class GetAdditionalPartsRequest(
val itemId: UUID,
)

View File

@@ -1,3 +0,0 @@
package org.jellyfin.androidtv.data.querying
data object GetSeriesTimersRequest

View File

@@ -1,7 +0,0 @@
package org.jellyfin.androidtv.data.querying
import java.util.UUID
data class GetSpecialsRequest(
val itemId: UUID,
)

View File

@@ -1,7 +0,0 @@
package org.jellyfin.androidtv.data.querying
import java.util.UUID
data class GetTrailersRequest(
val itemId: UUID,
)

View File

@@ -1,3 +0,0 @@
package org.jellyfin.androidtv.data.querying
data object GetUserViewsRequest

View File

@@ -0,0 +1,13 @@
package org.jellyfin.androidtv.data.querying;
public class SpecialsQuery {
private String ItemId;
public SpecialsQuery(String itemId) {
ItemId = itemId;
}
public String getItemId() {
return ItemId;
}
}

View File

@@ -0,0 +1,26 @@
package org.jellyfin.androidtv.data.querying;
import org.jellyfin.androidtv.auth.repository.UserRepository;
import org.jellyfin.apiclient.model.querying.ItemFields;
import org.jellyfin.apiclient.model.querying.ItemQuery;
import org.koin.java.KoinJavaComponent;
public class StdItemQuery extends ItemQuery {
public StdItemQuery(ItemFields[] fields) {
if (fields == null) {
fields = new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.Overview,
ItemFields.ItemCounts,
ItemFields.DisplayPreferencesId,
ItemFields.ChildCount
};
}
setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
setFields(fields);
}
public StdItemQuery() {
this(null);
}
}

View File

@@ -0,0 +1,13 @@
package org.jellyfin.androidtv.data.querying;
public class TrailersQuery {
private String ItemId;
public TrailersQuery(String itemId) {
ItemId = itemId;
}
public String getItemId() {
return ItemId;
}
}

View File

@@ -0,0 +1,5 @@
package org.jellyfin.androidtv.data.querying;
public class ViewQuery {
//Dummy indicator class
}

View File

@@ -1,23 +0,0 @@
package org.jellyfin.androidtv.data.repository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.jellyfin.androidtv.constant.CustomMessage
interface CustomMessageRepository {
val message: StateFlow<CustomMessage?>
fun pushMessage(message: CustomMessage)
}
class CustomMessageRepositoryImpl : CustomMessageRepository {
private val _message = MutableStateFlow<CustomMessage?>(null)
override val message get() = _message.asStateFlow()
override fun pushMessage(message: CustomMessage) {
// Make sure to re-emit the same message if requested
if (_message.value == message) _message.value = null
_message.value = message
}
}

View File

@@ -1,62 +0,0 @@
package org.jellyfin.androidtv.data.repository
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.ResolveInfo
import androidx.core.net.toUri
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.util.componentName
class ExternalAppRepository(
private val userPreferences: UserPreferences,
) {
companion object {
const val SAMPLE_VIDEO_URL = "http://jellyfin.local/query.mp4"
const val MEDIA_TYPE_VIDEO = "video/*"
}
private val externalPlayerAppIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndTypeAndNormalize(SAMPLE_VIDEO_URL.toUri(), MEDIA_TYPE_VIDEO)
}
fun getExternalPlayerApps(context: Context): List<ResolveInfo> = context.packageManager
.queryIntentActivities(externalPlayerAppIntent, 0)
// Hide apps with priority below zero (system stubs)
.filter { it.priority >= 0 }
fun getCurrentExternalPlayerApp(context: Context): ActivityInfo? {
// Validate if external app should be used at all
val useExternalPlayer = userPreferences[UserPreferences.useExternalPlayer]
if (!useExternalPlayer) return null
// Resolve external app information
val resolvedInfo = userPreferences[UserPreferences.externalPlayerComponentName]
.takeIf { it.isNotEmpty() }
?.let(ComponentName::unflattenFromString)
?.runCatching { context.packageManager.getActivityInfo(this, 0) }
?.getOrNull()
if (resolvedInfo != null) return resolvedInfo
// Fallback in case the app is uninstalled or unavailable for some other reason
val externalApps = getExternalPlayerApps(context)
// System default
val systemDefault = externalApps.find { it.isDefault }?.activityInfo
if (systemDefault != null) return systemDefault
// First compatible, or none
return externalApps.firstOrNull()?.activityInfo
}
fun setExternalPlayerapp(activityInfo: ActivityInfo?) {
if (activityInfo == null) {
userPreferences[UserPreferences.useExternalPlayer] = false
userPreferences[UserPreferences.externalPlayerComponentName] = ""
} else {
userPreferences[UserPreferences.useExternalPlayer] = true
userPreferences[UserPreferences.externalPlayerComponentName] = activityInfo.componentName.flattenToShortString()
}
}
}

View File

@@ -1,40 +0,0 @@
package org.jellyfin.androidtv.data.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.data.model.DataRefreshService
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.UserItemDataDto
import java.time.Instant
interface ItemMutationRepository {
suspend fun setFavorite(item: UUID, favorite: Boolean): UserItemDataDto
suspend fun setPlayed(item: UUID, played: Boolean): UserItemDataDto
}
class ItemMutationRepositoryImpl(
private val api: ApiClient,
private val dataRefreshService: DataRefreshService,
) : ItemMutationRepository {
override suspend fun setFavorite(item: UUID, favorite: Boolean): UserItemDataDto {
val response by when {
favorite -> withContext(Dispatchers.IO) { api.userLibraryApi.markFavoriteItem(itemId = item) }
else -> withContext(Dispatchers.IO) { api.userLibraryApi.unmarkFavoriteItem(itemId = item) }
}
dataRefreshService.lastFavoriteUpdate = Instant.now()
return response
}
override suspend fun setPlayed(item: UUID, played: Boolean): UserItemDataDto {
val response by when {
played -> withContext(Dispatchers.IO) { api.playStateApi.markPlayedItem(itemId = item) }
else -> withContext(Dispatchers.IO) { api.playStateApi.markUnplayedItem(itemId = item) }
}
return response
}
}

View File

@@ -1,25 +0,0 @@
package org.jellyfin.androidtv.data.repository
import org.jellyfin.sdk.model.api.ItemFields
object ItemRepository {
val itemFields = setOf(
ItemFields.CAN_DELETE,
ItemFields.CHANNEL_INFO,
ItemFields.CHAPTERS,
ItemFields.CHILD_COUNT,
ItemFields.CUMULATIVE_RUN_TIME_TICKS,
ItemFields.DATE_CREATED,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.GENRES,
ItemFields.ITEM_COUNTS,
ItemFields.MEDIA_SOURCE_COUNT,
ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_STREAMS,
ItemFields.OVERVIEW,
ItemFields.PATH,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.TAGLINES,
ItemFields.TRICKPLAY,
)
}

View File

@@ -1,95 +1,46 @@
package org.jellyfin.androidtv.data.repository
import android.app.UiModeManager
import android.content.Context
import android.content.res.Configuration
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.auth.repository.ServerRepository
import org.jellyfin.androidtv.data.model.AppNotification
import org.jellyfin.androidtv.preference.SystemPreferences
import org.jellyfin.androidtv.util.isTvDevice
import org.jellyfin.sdk.model.ServerVersion
interface NotificationsRepository {
val notifications: StateFlow<List<AppNotification>>
fun dismissNotification(item: AppNotification)
fun addDefaultNotifications()
fun updateServerNotifications(server: Server?)
}
class NotificationsRepositoryImpl(
private val context: Context,
private val systemPreferences: SystemPreferences,
private val uiModeManager: UiModeManager,
) : NotificationsRepository {
override val notifications = MutableStateFlow(emptyList<AppNotification>())
override fun dismissNotification(item: AppNotification) {
notifications.value = notifications.value.filter { it != item }
item.dismiss()
}
override fun addDefaultNotifications() {
addUiModeNotification()
addBetaNotification()
}
private fun addNotification(
message: String,
public: Boolean = false,
dismiss: () -> Unit = {}
): AppNotification {
val notification = AppNotification(message, dismiss, public)
notifications.value += notification
return notification
}
private fun removeNotification(notification: AppNotification) {
notifications.value -= notification
private fun addNotification(message: String) {
notifications.value = notifications.value + AppNotification(message)
}
private fun addUiModeNotification() {
val disableUiModeWarning = systemPreferences[SystemPreferences.disableUiModeWarning]
val supportedUiModes = setOf(Configuration.UI_MODE_TYPE_TELEVISION, Configuration.UI_MODE_TYPE_UNDEFINED)
val invalidUiMode = !supportedUiModes.contains(uiModeManager.currentModeType)
val isTouch = context.packageManager.hasSystemFeature("android.hardware.touchscreen")
val hasHdmiCec = context.packageManager.hasSystemFeature("android.hardware.hdmi.cec")
if (!context.isTvDevice() && !disableUiModeWarning) {
addNotification(
context.getString(R.string.app_notification_uimode_invalid),
public = true
)
}
}
private fun addBetaNotification() {
val dismissedVersion = systemPreferences[SystemPreferences.dismissedBetaNotificationVersion]
val currentVersion = BuildConfig.VERSION_NAME
val isBeta = currentVersion.lowercase().contains("beta")
if (isBeta && currentVersion != dismissedVersion) {
addNotification(context.getString(R.string.app_notification_beta, currentVersion)) {
systemPreferences[SystemPreferences.dismissedBetaNotificationVersion] =
currentVersion
}
}
}
// Update server notification
private var _updateServerNotification: AppNotification? = null
override fun updateServerNotifications(server: Server?) {
// Remove current update notification
_updateServerNotification?.let(::removeNotification)
val currentServerVersion = server?.version?.let(ServerVersion::fromString) ?: return
if (currentServerVersion < ServerRepository.upcomingMinimumServerVersion) {
_updateServerNotification =
addNotification(
message = context.getString(
R.string.app_notification_update_soon,
currentServerVersion,
ServerRepository.upcomingMinimumServerVersion
),
)
if (invalidUiMode && isTouch && !hasHdmiCec) {
addNotification(context.getString(R.string.app_notification_uimode_invalid))
}
}
}

View File

@@ -1,20 +1,18 @@
package org.jellyfin.androidtv.data.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import org.jellyfin.apiclient.model.entities.CollectionType
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.CollectionType
interface UserViewsRepository {
val views: Flow<Collection<BaseItemDto>>
fun isSupported(collectionType: CollectionType?): Boolean
fun allowViewSelection(collectionType: CollectionType?): Boolean
fun allowGridView(collectionType: CollectionType?): Boolean
fun isSupported(collectionType: String?): Boolean
fun allowViewSelection(collectionType: String?): Boolean
fun allowGridView(collectionType: String?): Boolean
}
class UserViewsRepositoryImpl(
@@ -23,29 +21,31 @@ class UserViewsRepositoryImpl(
override val views = flow {
val views by api.userViewsApi.getUserViews()
val filteredViews = views.items
.orEmpty()
.filter { isSupported(it.collectionType) }
emit(filteredViews)
}.flowOn(Dispatchers.IO)
}
override fun isSupported(collectionType: CollectionType?) = collectionType !in unsupportedCollectionTypes
override fun allowViewSelection(collectionType: CollectionType?) = collectionType !in disallowViewSelectionCollectionTypes
override fun allowGridView(collectionType: CollectionType?) = collectionType !in disallowGridViewCollectionTypes
override fun isSupported(collectionType: String?) = collectionType !in unsupportedCollectionTypes
override fun allowViewSelection(collectionType: String?) = collectionType !in disallowViewSelectionCollectionTypes
override fun allowGridView(collectionType: String?) = collectionType !in disallowGridViewCollectionTypes
private companion object {
private val unsupportedCollectionTypes = arrayOf(
CollectionType.BOOKS,
CollectionType.FOLDERS
CollectionType.Books,
CollectionType.Games,
CollectionType.Folders
)
private val disallowViewSelectionCollectionTypes = arrayOf(
CollectionType.LIVETV,
CollectionType.MUSIC,
CollectionType.PHOTOS,
CollectionType.livetv,
CollectionType.Music,
CollectionType.Photos,
)
private val disallowGridViewCollectionTypes = arrayOf(
CollectionType.LIVETV,
CollectionType.MUSIC
CollectionType.livetv,
CollectionType.Music
)
}
}

View File

@@ -1,78 +1,191 @@
package org.jellyfin.androidtv.data.service
import android.animation.ValueAnimator
import android.content.Context
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import coil3.ImageLoader
import coil3.request.ImageRequest
import coil3.toBitmap
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.util.Size
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.annotation.MainThread
import androidx.core.animation.doOnEnd
import androidx.core.content.ContextCompat
import androidx.core.graphics.BlendModeColorFilterCompat
import androidx.core.graphics.BlendModeCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.drawable.toDrawable
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.add
import androidx.window.layout.WindowMetricsCalculator
import com.bumptech.glide.Glide
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.constant.BackdropBehavior
import org.jellyfin.androidtv.util.apiclient.getUrl
import org.jellyfin.androidtv.util.apiclient.itemBackdropImages
import org.jellyfin.androidtv.util.apiclient.parentBackdropImages
import org.jellyfin.androidtv.util.sdk.compat.asSdk
import org.jellyfin.apiclient.model.search.SearchHint
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.BaseItemDto
import java.time.Instant
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.serializer.toUUID
import timber.log.Timber
import java.util.UUID
import java.util.concurrent.ExecutionException
import org.jellyfin.apiclient.model.dto.BaseItemDto as LegacyBaseItemDto
class BackgroundService(
private val context: Context,
private val jellyfin: Jellyfin,
private val api: ApiClient,
private val userPreferences: UserPreferences,
private val imageLoader: ImageLoader,
) {
companion object {
val SLIDESHOW_DURATION = 30.seconds
val TRANSITION_DURATION = 800.milliseconds
const val TRANSITION_DURATION = 400L // 0.4 seconds
const val SLIDESHOW_DURATION = 30000L // 30 seconds
const val UPDATE_INTERVAL = 500L // 0.5 seconds
val FRAGMENT_TAG = BackgroundServiceFragment::class.qualifiedName!!
}
// Async
private val scope = MainScope()
private var loadBackgroundsJob: Job? = null
private var updateBackgroundTimerJob: Job? = null
private var lastBackgroundTimerUpdate = 0L
private var lastBackgroundUpdate = 0L
// Current background data
private var _backgrounds = emptyList<ImageBitmap>()
private var _currentIndex = 0
private var _currentBackground = MutableStateFlow<ImageBitmap?>(null)
private var _blurBackground = MutableStateFlow(false)
private var _enabled = MutableStateFlow(true)
val currentBackground get() = _currentBackground.asStateFlow()
val blurBackground get() = _blurBackground.asStateFlow()
val enabled get() = _enabled.asStateFlow()
// All background drawables currently showing
internal val backgrounds = mutableListOf<Drawable>()
// Current background index
private var currentIndex = 0
// Preferred display size, set when calling [attach].
private var windowSize = Size(0, 0)
private var windowBackground: Drawable = ColorDrawable(Color.BLACK)
// Background layers
internal val backgroundDrawable = ContextCompat.getDrawable(context, R.drawable.layer_background) as LayerDrawable
// Filter to darken backgrounds
private val colorFilter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(
ContextCompat.getColor(context, R.color.background_filter),
BlendModeCompat.SRC_ATOP
)
// Animation
@Suppress("MagicNumber")
private val backgroundAnimator = ValueAnimator.ofInt(0, 255).apply {
interpolator = AccelerateDecelerateInterpolator()
duration = TRANSITION_DURATION
addUpdateListener { animation ->
// Set alpha
val value = animation.animatedValue as Int
backgroundDrawable.findDrawableByLayerId(R.id.background_next).alpha = value
backgroundDrawable.invalidateSelf()
}
doOnEnd {
// Set next as current and clear next
val drawable = backgroundDrawable.findDrawableByLayerId(R.id.background_next)
backgroundDrawable.setDrawableByLayerId(R.id.background_current, drawable)
backgroundDrawable.setDrawableByLayerId(R.id.background_next, ColorDrawable(Color.TRANSPARENT))
backgroundDrawable.invalidateSelf()
}
}
/**
* Attach the bakground to [activity].
*/
fun attach(activity: FragmentActivity) {
// Set default background to current if it's not layered
val current = activity.window.decorView.background
windowBackground = if (current !is LayerDrawable) current.copy() else ColorDrawable(Color.BLACK)
backgroundDrawable.setDrawableByLayerId(R.id.background_static, windowBackground)
// Store size of window manager for this activity
windowSize = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(activity).let {
Size(it.bounds.width(), it.bounds.height())
}
// Add a fragment to the activity to automatically set the background on resume
val fragment = activity.supportFragmentManager.findFragmentByTag(FRAGMENT_TAG)
if (fragment == null) {
// Add fragment
Timber.i("Adding BackgroundServiceFragment to activity")
activity.supportFragmentManager
.beginTransaction()
.add<BackgroundServiceFragment>(FRAGMENT_TAG)
.commit()
}
}
// Helper function for [setBackground]
private fun List<String>?.getUrls(itemId: UUID?): List<String> {
// Check for nullability
if (itemId == null || isNullOrEmpty()) return emptyList()
return mapIndexed { index, tag ->
api.imageApi.getItemImageUrl(
itemId = itemId,
imageType = ImageType.BACKDROP,
tag = tag,
imageIndex = index,
)
}
}
/**
* Use all available backdrops from [baseItem] as background.
*/
@Deprecated(
"Use the SDK instead of the legacy apiclient",
ReplaceWith("setBackground(baseItem?.asSdk())", "org.jellyfin.androidtv.util.sdk.compat.asSdk")
)
fun setBackground(baseItem: LegacyBaseItemDto?) = setBackground(baseItem?.asSdk())
/**
* Use all available backdrops from [baseItem] as background.
*/
fun setBackground(baseItem: BaseItemDto?) {
val backdropBehavior = userPreferences[UserPreferences.backdropBehavior]
// Check if item is set and backgrounds are enabled
if (baseItem == null || backdropBehavior == BackdropBehavior.DISABLED)
if (baseItem == null || !userPreferences[UserPreferences.backdropEnabled])
return clearBackgrounds()
// Enable blur for backdrops
_blurBackground.value = backdropBehavior == BackdropBehavior.BACKDROP_WITH_BLUR
// Get all backdrop urls
val backdropUrls = (baseItem.itemBackdropImages + baseItem.parentBackdropImages)
.map { it.getUrl(api) }
.toSet()
val itemBackdropUrls = baseItem.backdropImageTags.getUrls(baseItem.id)
val parentBackdropUrls = baseItem.parentBackdropImageTags.getUrls(baseItem.parentBackdropItemId)
val backdropUrls = itemBackdropUrls.union(parentBackdropUrls)
loadBackgrounds(backdropUrls)
}
/**
* Use backdrop from [searchHint] as background.
*/
fun setBackground(searchHint: SearchHint) {
// Check if item is set and backgrounds are enabled
if (!userPreferences[UserPreferences.backdropEnabled])
return clearBackgrounds()
// Manually grab the backdrop URL
val backdropUrls = setOfNotNull(searchHint.backdropImageItemId?.let { itemId ->
api.imageApi.getItemImageUrl(
itemId = itemId.toUUID(),
imageType = ImageType.BACKDROP,
tag = searchHint.backdropImageTag,
)
})
loadBackgrounds(backdropUrls)
}
@@ -82,89 +195,111 @@ class BackgroundService(
*/
fun setBackground(server: Server) {
// Check if item is set and backgrounds are enabled
if (userPreferences[UserPreferences.backdropBehavior] == BackdropBehavior.DISABLED)
if (!userPreferences[UserPreferences.backdropEnabled])
return clearBackgrounds()
// Check if splashscreen is enabled in (cached) branding options
if (!server.splashscreenEnabled)
return clearBackgrounds()
// Disable blur on splashscreen
_blurBackground.value = false
// Manually grab the backdrop URL
val api = jellyfin.createApi(baseUrl = server.address)
val splashscreenUrl = api.imageApi.getSplashscreenUrl()
loadBackgrounds(setOf(splashscreenUrl))
}
private fun loadBackgrounds(backdropUrls: Set<String>) {
if (backdropUrls.isEmpty()) return clearBackgrounds()
// Re-enable backgrounds if disabled
_enabled.value = true
// Cancel current loading job
loadBackgroundsJob?.cancel()
loadBackgroundsJob = scope.launch(Dispatchers.IO) {
_backgrounds = backdropUrls.mapNotNull { url ->
imageLoader.execute(
request = ImageRequest.Builder(context).data(url).build()
).image?.toBitmap()?.asImageBitmap()
}
val backdropDrawables = backdropUrls
.map { url ->
Glide.with(context)
.load(url)
.override(windowSize.width, windowSize.height)
.centerCrop()
.submit()
}
.map { future ->
async {
try {
future.get()
} catch (ex: ExecutionException) {
Timber.e(ex, "There was an error fetching the background image.")
null
}
}
}
.awaitAll()
.filterNotNull()
.onEach { it.colorFilter = colorFilter }
// Go to first background
_currentIndex = 0
update()
backgrounds.clear()
backgrounds.addAll(backdropDrawables)
withContext(Dispatchers.Main) {
// Go to first background
currentIndex = 0
update()
}
}
}
fun clearBackgrounds() {
loadBackgroundsJob?.cancel()
// Re-enable backgrounds if disabled
_enabled.value = true
if (backgrounds.isEmpty()) return
if (_backgrounds.isEmpty()) return
_backgrounds = emptyList()
backgrounds.clear()
update()
}
/**
* Disable the showing of backgrounds until any function manipulating the backgrounds is called.
*/
fun disable() {
_enabled.value = false
}
@MainThread
internal fun update() {
val now = Instant.now().toEpochMilli()
if (lastBackgroundTimerUpdate > now - TRANSITION_DURATION.inWholeMilliseconds)
return setTimer((lastBackgroundTimerUpdate - now).milliseconds + TRANSITION_DURATION, false)
val now = System.currentTimeMillis()
if (lastBackgroundUpdate > now - UPDATE_INTERVAL)
return setTimer(lastBackgroundUpdate - now + UPDATE_INTERVAL, false)
lastBackgroundTimerUpdate = now
lastBackgroundUpdate = now
// Snapshot the current state if an animation is running and draw the new
// background on top.
if (backgroundAnimator.isRunning) {
val current = backgroundDrawable
.toBitmap(windowSize.width, windowSize.height)
.toDrawable(context.resources)
backgroundAnimator.end()
backgroundDrawable.setDrawableByLayerId(R.id.background_current, current)
}
// Get next background to show
if (_currentIndex >= _backgrounds.size) _currentIndex = 0
if (currentIndex >= backgrounds.size) currentIndex = 0
// Set background
_currentBackground.value = _backgrounds.getOrNull(_currentIndex)
backgroundDrawable.setDrawableByLayerId(
R.id.background_next,
backgrounds.getOrElse(currentIndex) { windowBackground.copy() }
)
// Animate
backgroundAnimator.start()
// Set timer for next background
if (_backgrounds.size > 1) setTimer()
if (backgrounds.size > 1) setTimer()
else updateBackgroundTimerJob?.cancel()
}
private fun setTimer(updateDelay: Duration = SLIDESHOW_DURATION, increaseIndex: Boolean = true) {
private fun setTimer(updateDelay: Long = SLIDESHOW_DURATION, increaseIndex: Boolean = true) {
updateBackgroundTimerJob?.cancel()
updateBackgroundTimerJob = scope.launch {
delay(updateDelay)
if (increaseIndex) _currentIndex++
if (increaseIndex) currentIndex++
update()
}
}
private fun Drawable.copy() = constantState!!.newDrawable().mutate()
}

View File

@@ -0,0 +1,43 @@
package org.jellyfin.androidtv.data.service
import android.graphics.drawable.Drawable
import androidx.fragment.app.Fragment
import org.koin.android.ext.android.inject
import timber.log.Timber
/**
* Fragment used to hook into the activity lifecycle and set the background when neccesary.
*/
internal class BackgroundServiceFragment : Fragment() {
private val backgroundService by inject<BackgroundService>()
private var backgrounds: Array<Drawable>? = null
override fun onResume() {
super.onResume()
if (backgrounds != null) {
Timber.d("Restoring active backgrounds")
backgroundService.backgrounds.clear()
backgroundService.backgrounds.addAll(backgrounds!!)
backgroundService.update()
}
activity?.window?.decorView?.apply {
Timber.d("Restoring background drawable")
// We need to force the system to add a new callback
// this won't happen if we set the background to the same one
// so we set it to null first
if (background == backgroundService.backgroundDrawable) background = null
background = backgroundService.backgroundDrawable
}
}
override fun onPause() {
super.onPause()
backgrounds = backgroundService.backgrounds.toTypedArray()
}
}

View File

@@ -0,0 +1,12 @@
package org.jellyfin.androidtv.di
import android.app.Application
import org.jellyfin.androidtv.ui.shared.AppThemeCallbacks
import org.jellyfin.androidtv.ui.shared.AuthenticatedUserCallbacks
import org.koin.dsl.bind
import org.koin.dsl.module
val activityLifecycleCallbacksModule = module {
single { AuthenticatedUserCallbacks(get()) } bind Application.ActivityLifecycleCallbacks::class
single { AppThemeCallbacks(get()) } bind Application.ActivityLifecycleCallbacks::class
}

View File

@@ -1,7 +1,7 @@
package org.jellyfin.androidtv.di
import android.accounts.AccountManager
import android.app.UiModeManager
import android.media.AudioManager
import androidx.core.content.getSystemService
import androidx.work.WorkManager
import org.koin.android.ext.koin.androidApplication
@@ -11,7 +11,7 @@ import org.koin.dsl.module
* Provides DI for Android system components
*/
val androidModule = module {
factory { androidApplication().getSystemService<AccountManager>()!! }
factory { androidApplication().getSystemService<UiModeManager>()!! }
factory { androidApplication().getSystemService<AudioManager>()!! }
factory { WorkManager.getInstance(get()) }
}

View File

@@ -1,165 +1,100 @@
package org.jellyfin.androidtv.di
import android.content.Context
import android.os.Build
import androidx.lifecycle.ProcessLifecycleOwner
import coil3.ImageLoader
import coil3.annotation.ExperimentalCoilApi
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.NetworkFetcher
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.serviceLoaderEnabled
import coil3.svg.SvgDecoder
import coil3.util.Logger
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.auth.repository.ServerRepository
import org.jellyfin.androidtv.auth.repository.UserRepository
import org.jellyfin.androidtv.auth.repository.UserRepositoryImpl
import org.jellyfin.androidtv.data.eventhandling.SocketHandler
import org.jellyfin.androidtv.data.model.DataRefreshService
import org.jellyfin.androidtv.data.repository.CustomMessageRepository
import org.jellyfin.androidtv.data.repository.CustomMessageRepositoryImpl
import org.jellyfin.androidtv.data.repository.ExternalAppRepository
import org.jellyfin.androidtv.data.repository.ItemMutationRepository
import org.jellyfin.androidtv.data.repository.ItemMutationRepositoryImpl
import org.jellyfin.androidtv.data.repository.NotificationsRepository
import org.jellyfin.androidtv.data.repository.NotificationsRepositoryImpl
import org.jellyfin.androidtv.data.repository.UserViewsRepository
import org.jellyfin.androidtv.data.repository.UserViewsRepositoryImpl
import org.jellyfin.androidtv.data.service.BackgroundService
import org.jellyfin.androidtv.integration.dream.DreamViewModel
import org.jellyfin.androidtv.ui.InteractionTrackerViewModel
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
import org.jellyfin.androidtv.ui.navigation.Destinations
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
import org.jellyfin.androidtv.ui.navigation.NavigationRepositoryImpl
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
import org.jellyfin.androidtv.ui.playback.nextup.NextUpViewModel
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepository
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepositoryImpl
import org.jellyfin.androidtv.ui.playback.stillwatching.StillWatchingViewModel
import org.jellyfin.androidtv.ui.player.photo.PhotoPlayerViewModel
import org.jellyfin.androidtv.ui.search.SearchFragmentDelegate
import org.jellyfin.androidtv.ui.search.SearchRepository
import org.jellyfin.androidtv.ui.search.SearchRepositoryImpl
import org.jellyfin.androidtv.ui.search.SearchViewModel
import org.jellyfin.androidtv.ui.settings.compat.SettingsViewModel
import org.jellyfin.androidtv.ui.startup.ServerAddViewModel
import org.jellyfin.androidtv.ui.startup.StartupViewModel
import org.jellyfin.androidtv.ui.startup.UserLoginViewModel
import org.jellyfin.androidtv.util.KeyProcessor
import org.jellyfin.androidtv.util.MarkdownRenderer
import org.jellyfin.androidtv.util.PlaybackHelper
import org.jellyfin.androidtv.util.apiclient.ReportingHelper
import org.jellyfin.androidtv.util.coil.CoilTimberLogger
import org.jellyfin.androidtv.util.coil.createCoilConnectivityChecker
import org.jellyfin.androidtv.util.sdk.SdkPlaybackHelper
import org.jellyfin.androidtv.util.sdk.legacy
import org.jellyfin.apiclient.AppInfo
import org.jellyfin.apiclient.android
import org.jellyfin.apiclient.interaction.ApiEventListener
import org.jellyfin.apiclient.logging.AndroidLogger
import org.jellyfin.apiclient.serialization.GsonJsonSerializer
import org.jellyfin.sdk.android.androidDevice
import org.jellyfin.sdk.api.client.HttpClientOptions
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import org.koin.android.ext.koin.androidApplication
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.viewModel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.jellyfin.apiclient.Jellyfin as JellyfinApiClient
import org.jellyfin.sdk.Jellyfin as JellyfinSdk
val defaultDeviceInfo = named("defaultDeviceInfo")
val appModule = module {
// SDK
// New SDK
single(defaultDeviceInfo) { androidDevice(get()) }
single { OkHttpFactory() }
single { HttpClientOptions() }
single {
createJellyfin {
context = androidContext()
// Add client info
val clientName = buildString {
append("Jellyfin Android TV")
if (BuildConfig.DEBUG) append(" (debug)")
}
clientInfo = ClientInfo(clientName, BuildConfig.VERSION_NAME)
clientInfo = ClientInfo("Android TV", BuildConfig.VERSION_NAME)
deviceInfo = get(defaultDeviceInfo)
// Change server version
minimumServerVersion = ServerRepository.minimumServerVersion
// Use our own shared factory instance
apiClientFactory = get<OkHttpFactory>()
socketConnectionFactory = get<OkHttpFactory>()
}
}
single {
// Create an empty API instance, the actual values are set by the SessionRepository
get<JellyfinSdk>().createApi(httpClientOptions = get<HttpClientOptions>())
get<JellyfinSdk>().createApi()
}
single { SocketHandler(get(), get(), get(), get(), get(), get(), get(), get(), get(), ProcessLifecycleOwner.get().lifecycle) }
single { SocketHandler(get(), get(), get(), get(), get()) }
// Old apiclient
single { GsonJsonSerializer() }
// Coil (images)
single {
val okHttpFactory = get<OkHttpFactory>()
val httpClientOptions = get<HttpClientOptions>()
JellyfinApiClient {
appInfo = AppInfo("Android TV", BuildConfig.VERSION_NAME)
logger = AndroidLogger()
android(androidApplication())
}
}
@OptIn(ExperimentalCoilApi::class)
OkHttpNetworkFetcherFactory(
callFactory = { okHttpFactory.createClient(httpClientOptions) },
connectivityChecker = ::createCoilConnectivityChecker,
single {
get<JellyfinApiClient>().createApi(
device = get<DeviceInfo>(defaultDeviceInfo).legacy(),
eventListener = ApiEventListener()
)
}
single {
ImageLoader.Builder(androidContext()).apply {
serviceLoaderEnabled(false)
logger(CoilTimberLogger(if (BuildConfig.DEBUG) Logger.Level.Warn else Logger.Level.Error))
components {
add(get<NetworkFetcher.Factory>())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(AnimatedImageDecoder.Factory())
else add(GifDecoder.Factory())
add(SvgDecoder.Factory())
}
}.build()
}
// Non API related
single { MediaManager(get()) }
single { DataRefreshService() }
single { PlaybackControllerContainer() }
single { InteractionTrackerViewModel(get(), get()) }
single<UserRepository> { UserRepositoryImpl() }
single<UserViewsRepository> { UserViewsRepositoryImpl(get()) }
single<NotificationsRepository> { NotificationsRepositoryImpl(get(), get()) }
single<ItemMutationRepository> { ItemMutationRepositoryImpl(get(), get()) }
single<CustomMessageRepository> { CustomMessageRepositoryImpl() }
single<NavigationRepository> { NavigationRepositoryImpl(Destinations.home) }
single<SearchRepository> { SearchRepositoryImpl(get()) }
single<MediaSegmentRepository> { MediaSegmentRepositoryImpl(get(), get()) }
single<ExternalAppRepository> { ExternalAppRepository(get()) }
viewModel { StartupViewModel(get(), get(), get(), get()) }
viewModel { UserLoginViewModel(get(), get(), get(), get(defaultDeviceInfo)) }
viewModel { ServerAddViewModel(get()) }
viewModel { NextUpViewModel(get(), get(), get()) }
viewModel { StillWatchingViewModel(get(), get(), get(), get()) }
viewModel { PhotoPlayerViewModel(get()) }
viewModel { SearchViewModel(get()) }
viewModel { DreamViewModel(get(), get(), get(), get(), get()) }
viewModel { SettingsViewModel() }
viewModel { NextUpViewModel(get(), get(), get(), get()) }
single { BackgroundService(get(), get(), get(), get(), get()) }
single { BackgroundService(get(), get(), get(), get()) }
single { MarkdownRenderer(get()) }
single { ItemLauncher() }
single { KeyProcessor() }
single { ReportingHelper(get(), get()) }
single<PlaybackHelper> { SdkPlaybackHelper(get(), get(), get(), get()) }
factory { (context: Context) -> SearchFragmentDelegate(context, get(), get()) }
}

View File

@@ -1,5 +1,6 @@
package org.jellyfin.androidtv.di
import org.jellyfin.androidtv.auth.apiclient.ApiBinder
import org.jellyfin.androidtv.auth.repository.AuthenticationRepository
import org.jellyfin.androidtv.auth.repository.AuthenticationRepositoryImpl
import org.jellyfin.androidtv.auth.repository.ServerRepository
@@ -8,25 +9,24 @@ import org.jellyfin.androidtv.auth.repository.ServerUserRepository
import org.jellyfin.androidtv.auth.repository.ServerUserRepositoryImpl
import org.jellyfin.androidtv.auth.repository.SessionRepository
import org.jellyfin.androidtv.auth.repository.SessionRepositoryImpl
import org.jellyfin.androidtv.auth.store.AccountManagerStore
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.koin.dsl.module
val authModule = module {
single { AuthenticationStore(get()) }
single { AccountManagerStore(get()) }
single { AuthenticationPreferences(get()) }
single<AuthenticationRepository> {
AuthenticationRepositoryImpl(get(), get(), get(), get(), get(), get(defaultDeviceInfo))
AuthenticationRepositoryImpl(get(), get(), get(), get(), get(), get(), get(defaultDeviceInfo))
}
single<ServerRepository> { ServerRepositoryImpl(get(), get()) }
single<ServerUserRepository> { ServerUserRepositoryImpl(get(), get()) }
single<ServerUserRepository> { ServerUserRepositoryImpl(get(), get(), get()) }
single<SessionRepository> {
SessionRepositoryImpl(get(), get(), get(), get(), get(defaultDeviceInfo), get(), get(), get())
SessionRepositoryImpl(get(), get(), get(), get(), get(), get(), get(defaultDeviceInfo), get(), get(), get())
}
factory {
val serverRepository = get<ServerRepository>()
serverRepository.currentServer.value?.serverVersion ?: ServerRepository.minimumServerVersion
}
single { ApiBinder(get(), get()) }
}

View File

@@ -12,6 +12,7 @@ class KoinInitializer : Initializer<KoinApplication> {
androidContext(context)
modules(
activityLifecycleCallbacksModule,
androidModule,
appModule,
authModule,

View File

@@ -1,96 +1,22 @@
package org.jellyfin.androidtv.di
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.media3.datasource.HttpDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.UserSettingPreferences
import org.jellyfin.androidtv.ui.browsing.MainActivity
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.ui.playback.PlaybackLauncher
import org.jellyfin.androidtv.ui.playback.VideoQueueManager
import org.jellyfin.androidtv.ui.playback.rewrite.RewriteMediaManager
import org.jellyfin.androidtv.util.profile.createDeviceProfile
import org.jellyfin.playback.core.playbackManager
import org.jellyfin.playback.jellyfin.jellyfinPlugin
import org.jellyfin.playback.media3.exoplayer.ExoPlayerOptions
import org.jellyfin.playback.media3.exoplayer.exoPlayerPlugin
import org.jellyfin.playback.media3.session.MediaSessionOptions
import org.jellyfin.playback.media3.session.media3SessionPlugin
import org.jellyfin.sdk.api.client.HttpClientOptions
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.model.api.MediaSegmentType
import org.koin.android.ext.koin.androidContext
import org.koin.core.scope.Scope
import org.jellyfin.androidtv.ui.playback.GarbagePlaybackLauncher
import org.jellyfin.androidtv.ui.playback.PlaybackManager
import org.jellyfin.androidtv.ui.playback.RewritePlaybackLauncher
import org.koin.dsl.module
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import org.jellyfin.androidtv.ui.playback.PlaybackManager as LegacyPlaybackManager
val playbackModule = module {
single { LegacyPlaybackManager(get()) }
single { VideoQueueManager() }
single<MediaManager> { RewriteMediaManager(get(), get()) }
single { PlaybackLauncher(get(), get(), get(), get()) }
single<HttpDataSource.Factory> {
val okHttpFactory = get<OkHttpFactory>()
val httpClientOptions = get<HttpClientOptions>().copy(
// Disable request timeout for media playback as this causes issues with Live TV
requestTimeout = Duration.ZERO
)
OkHttpDataSource.Factory(okHttpFactory.createClient(httpClientOptions))
single {
PlaybackManager(get())
}
single { createPlaybackManager() }
}
factory {
val preferences = get<UserPreferences>()
val useRewrite = preferences[UserPreferences.playbackRewriteEnabled] && BuildConfig.DEVELOPMENT
fun Scope.createPlaybackManager() = playbackManager(androidContext()) {
val activityIntent = Intent(get(), MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(get(), 0, activityIntent, PendingIntent.FLAG_IMMUTABLE)
val notificationChannelId = "session"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
notificationChannelId,
notificationChannelId,
NotificationManager.IMPORTANCE_LOW
)
channel.setShowBadge(false)
NotificationManagerCompat.from(get()).createNotificationChannel(channel)
if (useRewrite) RewritePlaybackLauncher()
else GarbagePlaybackLauncher(get())
}
val userPreferences = get<UserPreferences>()
val exoPlayerOptions = ExoPlayerOptions(
preferFfmpeg = userPreferences[UserPreferences.preferExoPlayerFfmpeg],
enableLibass = userPreferences[UserPreferences.assDirectPlay],
enableDebugLogging = userPreferences[UserPreferences.debuggingEnabled],
baseDataSourceFactory = get<HttpDataSource.Factory>(),
)
install(exoPlayerPlugin(get(), exoPlayerOptions))
val mediaSessionOptions = MediaSessionOptions(
channelId = notificationChannelId,
notificationId = 1,
iconSmall = R.drawable.app_icon_foreground,
openIntent = pendingIntent,
)
install(media3SessionPlugin(get(), mediaSessionOptions))
val deviceProfileBuilder = { createDeviceProfile(androidContext(), userPreferences, get()) }
install(jellyfinPlugin(get(), deviceProfileBuilder, setOf(MediaSegmentType.INTRO), ProcessLifecycleOwner.get().lifecycle))
// Options
val userSettingPreferences = get<UserSettingPreferences>()
defaultRewindAmount = { userSettingPreferences[UserSettingPreferences.skipBackLength].milliseconds }
defaultFastForwardAmount = { userSettingPreferences[UserSettingPreferences.skipForwardLength].milliseconds }
}

View File

@@ -1,8 +1,10 @@
package org.jellyfin.androidtv.di
import org.jellyfin.androidtv.util.AutoBitrate
import org.jellyfin.androidtv.util.ImageHelper
import org.koin.dsl.module
val utilsModule = module {
single { AutoBitrate(get()) }
single { ImageHelper(get()) }
}

View File

@@ -1,13 +1,10 @@
package org.jellyfin.androidtv.integration
import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.core.content.edit
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
@@ -23,37 +20,29 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.data.repository.ItemRepository
import org.jellyfin.androidtv.data.repository.UserViewsRepository
import org.jellyfin.androidtv.integration.provider.ImageProvider
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.ui.startup.StartupActivity
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.androidtv.util.apiclient.getUrl
import org.jellyfin.androidtv.util.apiclient.itemImages
import org.jellyfin.androidtv.util.apiclient.parentImages
import org.jellyfin.androidtv.util.ImageUtils
import org.jellyfin.androidtv.util.dp
import org.jellyfin.androidtv.util.sdk.isUsable
import org.jellyfin.androidtv.util.stripHtml
import org.jellyfin.apiclient.model.entities.MediaType
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.exception.TimeoutException
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageFormat
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.api.ItemFields
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import timber.log.Timber
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.time.Duration
import java.time.ZoneOffset
/**
* Manages channels on the android tv home screen.
@@ -65,13 +54,17 @@ class LeanbackChannelWorker(
workerParams: WorkerParameters,
) : CoroutineWorker(context, workerParams), KoinComponent {
companion object {
/**
* Amount of ticks found in a millisecond, used for calculation.
*/
private const val TICKS_IN_MILLISECOND = 10000
const val PERIODIC_UPDATE_REQUEST_NAME = "LeanbackChannelPeriodicUpdateRequest"
}
private val api by inject<ApiClient>()
private val userPreferences by inject<UserPreferences>()
private val userViewsRepository by inject<UserViewsRepository>()
private val imageHelper by inject<ImageHelper>()
/**
* Check if the app can use Leanback features and is API level 26 or higher.
@@ -93,76 +86,13 @@ class LeanbackChannelWorker(
else -> try {
// Get next up episodes
val (resumeItems, nextUpItems) = getNextUpItems()
// Get latest media
val (latestEpisodes, latestMovies, latestMedia) = getLatestMedia()
val myMedia = getMyMedia()
// Delete current items from the channels
// Delete current items from the My Media and Next Up channels
context.contentResolver.delete(TvContractCompat.PreviewPrograms.CONTENT_URI, null, null)
// Get channel URIs
val latestMediaChannel = getChannelUri(
"latest_media", Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.home_section_latest_media))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build(),
default = true
)
val myMediaChannel = getChannelUri(
"my_media", Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_my_media))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build()
)
val nextUpChannel = getChannelUri(
"next_up", Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_next_up))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build()
)
val latestMoviesChannel = getChannelUri(
"latest_movies", Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_movies))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build()
)
val latestEpisodesChannel = getChannelUri(
"latest_episodes", Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_new_episodes))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build()
)
val preferParentThumb = userPreferences[UserPreferences.seriesThumbnailsEnabled]
// Add new items
arrayOf(
nextUpItems to nextUpChannel,
latestMedia to latestMediaChannel,
latestMovies to latestMoviesChannel,
latestEpisodes to latestEpisodesChannel,
myMedia to myMediaChannel,
).forEach { (items, channel) ->
if (channel == null) {
Timber.e("Skipping channel because it was not available")
} else {
items.map { item ->
createPreviewProgram(
channel,
item,
preferParentThumb
)
}.let {
context.contentResolver.bulkInsert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
it.toTypedArray()
)
}
}
}
// Update various channels
updateMyMedia()
updateNextUp(nextUpItems)
updateWatchNext(resumeItems + nextUpItems)
// Success!
@@ -183,46 +113,28 @@ class LeanbackChannelWorker(
* update or create the channel. The [name] parameter is used to store the id and should be
* unique.
*/
private fun getChannelUri(name: String, settings: Channel, default: Boolean = false): Uri? {
private fun getChannelUri(name: String, settings: Channel, default: Boolean = false): Uri {
val store = context.getSharedPreferences("leanback_channels", Context.MODE_PRIVATE)
var uri: Uri? = null
// Try and re-use our existing channel definition
if (store.contains(name)) {
uri = store.getString(name, null)?.toUri()
if (uri != null) {
val result = context.contentResolver.update(uri, settings.toContentValues(), null, null)
// If we did not affect exactly 1 row there might be something wrong, so recreate it
if (result != 1) uri = null
val uri = if (store.contains(name)) {
// Retrieve uri and update content resolver
Uri.parse(store.getString(name, null)).also { uri ->
context.contentResolver.update(uri, settings.toContentValues(), null, null)
}
}
if (uri == null) {
// Create new channel
uri = context.contentResolver.insert(
TvContractCompat.Channels.CONTENT_URI,
settings.toContentValues()
)
// Set as default row to display (we can request one row to automatically be added to the home screen)
if (uri != null && default) {
TvContractCompat.requestChannelBrowsable(context, ContentUris.parseId(uri))
} else {
// Create new channel and save uri
context.contentResolver.insert(TvContractCompat.Channels.CONTENT_URI, settings.toContentValues())!!.also { uri ->
store.edit().putString(name, uri.toString()).apply()
if (default) {
// Set as default row to display (we can request one row to automatically be added to the home screen)
TvContractCompat.requestChannelBrowsable(context, ContentUris.parseId(uri))
}
}
// Save uri to shared preferences
store.edit { putString(name, uri?.toString()) }
}
// Update logo
if (uri != null) {
ResourcesCompat.getDrawable(context.resources, R.mipmap.app_icon, context.theme)?.let {
ChannelLogoUtils.storeChannelLogo(
context,
ContentUris.parseId(uri),
it.toBitmap(80.dp(context), 80.dp(context))
)
}
ResourcesCompat.getDrawable(context.resources, R.drawable.app_icon, context.theme)?.let {
ChannelLogoUtils.storeChannelLogo(context, ContentUris.parseId(uri), it.toBitmap(80.dp(context), 80.dp(context)))
}
return uri
@@ -232,26 +144,74 @@ class LeanbackChannelWorker(
* Updates the "my media" row with current media libraries.
*/
@Suppress("RestrictedApi")
private suspend fun getMyMedia(): List<BaseItemDto> {
private suspend fun updateMyMedia() {
// Get channel
val channelUri = getChannelUri("my_media", Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_my_media))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build(), default = true)
val response by api.userViewsApi.getUserViews(includeHidden = false)
// Add new items
return response.items
val items = response.items
.orEmpty()
.filter { userViewsRepository.isSupported(it.collectionType) }
.map { item ->
val imageUri = if (item.imageTags?.contains(ImageType.PRIMARY) == true)
api.imageApi.getItemImageUrl(item.id, ImageType.PRIMARY).toUri()
else
ImageUtils.getResourceUrl(context, R.drawable.tile_land_tv).toUri()
PreviewProgram.Builder()
.setChannelId(ContentUris.parseId(channelUri))
.setType(TvContractCompat.PreviewPrograms.TYPE_CHANNEL)
.setTitle(item.name)
.setPosterArtUri(imageUri)
.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
.setIntent(Intent(context, StartupActivity::class.java).apply {
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
putExtra(StartupActivity.EXTRA_ITEM_IS_USER_VIEW, true)
})
.build()
.toContentValues()
}.toTypedArray()
context.contentResolver.bulkInsert(TvContractCompat.PreviewPrograms.CONTENT_URI, items)
}
/**
* Gets the poster art for an item. Uses the [preferParentThumb] parameter to fetch the series
* image when preferred.
*/
private fun BaseItemDto.getPosterArtImageUrl(
preferParentThumb: Boolean
): Uri = when {
type == BaseItemKind.MOVIE || type == BaseItemKind.SERIES -> itemImages[ImageType.PRIMARY]
(preferParentThumb || !itemImages.contains(ImageType.PRIMARY)) && parentImages.contains(ImageType.THUMB) -> parentImages[ImageType.THUMB]
else -> itemImages[ImageType.PRIMARY]
}.let { image ->
ImageProvider.getImageUri(image?.getUrl(api) ?: imageHelper.getResourceUrl(context, R.drawable.tile_land_tv))
private fun BaseItemDto.getPosterArtImageUrl(preferParentThumb: Boolean): Uri {
if (type == BaseItemKind.MOVIE) {
return api.imageApi.getItemImageUrl(
itemId = id,
imageType = ImageType.PRIMARY,
format = ImageFormat.WEBP,
width = 106.dp(context),
height = 153.dp(context),
).let(Uri::parse)
}
return when {
(preferParentThumb || imageTags?.contains(ImageType.PRIMARY) != true)
&& parentThumbItemId != null -> api.imageApi.getItemImageUrl(
itemId = parentThumbItemId!!,
imageType = ImageType.THUMB,
format = ImageFormat.WEBP,
width = 272.dp(context),
height = 153.dp(context),
)
else -> api.imageApi.getItemImageUrl(
itemId = id,
imageType = ImageType.PRIMARY,
format = ImageFormat.WEBP,
width = 272.dp(context),
height = 153.dp(context),
)
}.let(Uri::parse)
}
/**
@@ -259,129 +219,73 @@ class LeanbackChannelWorker(
* 1. resume items
* 2. next up items
*/
private suspend fun getNextUpItems(): Pair<List<BaseItemDto>, List<BaseItemDto>> =
withContext(Dispatchers.IO) {
val resume = async {
api.itemsApi.getResumeItems(
fields = ItemRepository.itemFields,
imageTypeLimit = 1,
limit = 10,
mediaTypes = listOf(MediaType.VIDEO),
includeItemTypes = listOf(BaseItemKind.EPISODE, BaseItemKind.MOVIE),
excludeActiveSessions = true,
).content.items
}
val nextUp = async {
api.tvShowsApi.getNextUp(
imageTypeLimit = 1,
limit = 10,
enableResumable = false,
fields = ItemRepository.itemFields,
).content.items
}
// Concat
Pair(resume.await(), nextUp.await())
private suspend fun getNextUpItems(): Pair<List<BaseItemDto>, List<BaseItemDto>> = withContext(Dispatchers.IO) {
val resume = async {
api.itemsApi.getResumeItems(
fields = listOf(ItemFields.DATE_CREATED),
imageTypeLimit = 1,
limit = 10,
mediaTypes = listOf(MediaType.Video),
includeItemTypes = listOf(BaseItemKind.EPISODE, BaseItemKind.MOVIE),
excludeActiveSessions = true,
).content.items.orEmpty()
}
private suspend fun getLatestMedia(): Triple<List<BaseItemDto>, List<BaseItemDto>, List<BaseItemDto>> =
withContext(Dispatchers.IO) {
val latestEpisodes = async {
api.userLibraryApi.getLatestMedia(
fields = ItemRepository.itemFields,
limit = 50,
includeItemTypes = listOf(BaseItemKind.EPISODE),
isPlayed = false
).content
}
val latestMovies = async {
api.userLibraryApi.getLatestMedia(
fields = ItemRepository.itemFields,
limit = 50,
includeItemTypes = listOf(BaseItemKind.MOVIE),
isPlayed = false
).content
}
val latestMedia = async {
api.userLibraryApi.getLatestMedia(
fields = ItemRepository.itemFields,
limit = 50,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
isPlayed = false
).content
}
// Concat
Triple(latestEpisodes.await(), latestMovies.await(), latestMedia.await())
val nextup = async {
api.tvShowsApi.getNextUp(
userId = api.userId,
imageTypeLimit = 1,
limit = 10,
fields = listOf(ItemFields.DATE_CREATED),
).content.items.orEmpty()
}
@SuppressLint("RestrictedApi")
private fun createPreviewProgram(
channelUri: Uri,
item: BaseItemDto,
preferParentThumb: Boolean
): ContentValues {
val imageUri = item.getPosterArtImageUrl(preferParentThumb)
val seasonString = item.parentIndexNumber?.toString().orEmpty()
// Concat
Pair(resume.await(), nextup.await())
}
val episodeString = when {
item.indexNumberEnd != null && item.indexNumber != null ->
"${item.indexNumber}-${item.indexNumberEnd}"
/**
* Updates the "next up" row with current episodes. Uses the [nextUpItems] parameter to store
* items returned by a NextUpQuery().
*/
@Suppress("RestrictedApi")
private fun updateNextUp(nextUpItems: List<BaseItemDto>) {
val preferParentThumb = userPreferences[UserPreferences.seriesThumbnailsEnabled]
else -> item.indexNumber?.toString().orEmpty()
}
// Get channel
val channelUri = getChannelUri("next_up", Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_next_up))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build())
return PreviewProgram.Builder()
.setChannelId(ContentUris.parseId(channelUri))
.setType(
when (item.type) {
BaseItemKind.SERIES -> WatchNextPrograms.TYPE_TV_SERIES
BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE
BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE
BaseItemKind.AUDIO -> WatchNextPrograms.TYPE_TRACK
BaseItemKind.PLAYLIST -> WatchNextPrograms.TYPE_PLAYLIST
else -> WatchNextPrograms.TYPE_CHANNEL
}
)
.setTitle(item.seriesName ?: item.name)
.setEpisodeTitle(if (item.type == BaseItemKind.EPISODE) item.name else null)
.setDescription(item.overview?.stripHtml())
.setReleaseDate(
if (item.premiereDate != null) DateTimeFormatter.ISO_DATE.format(item.premiereDate)
else null
)
.setPosterArtUri(imageUri)
.setPosterArtAspectRatio(
when (item.type) {
BaseItemKind.COLLECTION_FOLDER,
BaseItemKind.EPISODE -> TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9
// Add new items
nextUpItems.map { item ->
val imageUri = item.getPosterArtImageUrl(preferParentThumb)
else -> TvContractCompat.PreviewPrograms.ASPECT_RATIO_MOVIE_POSTER
}
)
.setIntent(Intent(context, StartupActivity::class.java).apply {
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
putExtra(StartupActivity.EXTRA_ITEM_IS_USER_VIEW, item.type == BaseItemKind.COLLECTION_FOLDER)
})
.setDurationMillis(
if (item.runTimeTicks?.ticks != null) {
// If we are resuming, we need to show remaining time, cause GoogleTV
// ignores setLastPlaybackPositionMillis
val duration = item.runTimeTicks?.ticks ?: Duration.ZERO
val playbackPosition = item.userData?.playbackPositionTicks?.ticks
?: Duration.ZERO
(duration - playbackPosition).inWholeMilliseconds.toInt()
} else 0
)
.apply {
if ((item.parentIndexNumber ?: 0) > 0)
setSeasonNumber(seasonString, item.parentIndexNumber!!)
if ((item.indexNumber ?: 0) > 0)
setEpisodeNumber(episodeString, item.indexNumber!!)
}.build().toContentValues()
val seasonString = item.parentIndexNumber?.toString().orEmpty()
val episodeString = when {
item.indexNumberEnd != null && item.indexNumber != null ->
"${item.indexNumber}-${item.indexNumberEnd}"
else -> item.indexNumber?.toString().orEmpty()
}
PreviewProgram.Builder()
.setChannelId(ContentUris.parseId(channelUri))
.setType(WatchNextPrograms.TYPE_TV_EPISODE)
.setTitle(item.seriesName)
.setEpisodeTitle(item.name)
.setSeasonNumber(seasonString, item.parentIndexNumber ?: 0)
.setEpisodeNumber(episodeString, item.indexNumber ?: 0)
.setPosterArtUri(imageUri)
.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
.setIntent(Intent(context, StartupActivity::class.java).apply {
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
})
.build()
.toContentValues()
}.let { context.contentResolver.bulkInsert(TvContractCompat.PreviewPrograms.CONTENT_URI, it.toTypedArray()) }
}
/**
@@ -389,127 +293,70 @@ class LeanbackChannelWorker(
* or other types of media. Uses the [nextUpItems] parameter to store items returned by a
* NextUpQuery().
*/
@SuppressLint("RestrictedApi")
private fun updateWatchNext(nextUpItems: List<BaseItemDto>) {
deletePrograms(nextUpItems)
// Delete current items
context.contentResolver.delete(WatchNextPrograms.CONTENT_URI, null, null)
// Get current watch next state
val currentWatchNextPrograms = getCurrentWatchNext()
// Create all programs in nextUpItems but not in watch next
val programsToAdd = nextUpItems
.filter { next -> currentWatchNextPrograms.none { it.internalProviderId == next.id.toString() } }
// Add new items
context.contentResolver.bulkInsert(
WatchNextPrograms.CONTENT_URI,
programsToAdd.map { item -> getBaseItemAsWatchNextProgram(item).toContentValues() }
.toTypedArray())
}
/**
* Delete stale programs from the watch next row. Items that don't need to be touched are
* kept as is, so they keep their ordering in the watch next row.
*/
@SuppressLint("RestrictedApi")
private fun deletePrograms(nextUpItems: List<BaseItemDto>) {
// Retrieve current watch next row
val currentWatchNextPrograms = getCurrentWatchNext()
// Find all stale programs to delete
val deletedByUser = currentWatchNextPrograms.filter { !it.isBrowsable }
val noLongerInWatchNext =
currentWatchNextPrograms.filter { (nextUpItems).none { next -> it.internalProviderId == next.id.toString() } }
val continueWatching = currentWatchNextPrograms.filter { it.watchNextType == WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE }
// Delete the programs
(deletedByUser + noLongerInWatchNext + continueWatching)
.forEach { context.contentResolver.delete(TvContractCompat.buildWatchNextProgramUri(it.id), null, null) }
}
/**
* Retrieves the current watch next row state.
*/
@SuppressLint("RestrictedApi")
private fun getCurrentWatchNext(): MutableList<WatchNextProgram> {
val currentWatchNextPrograms: MutableList<WatchNextProgram> = mutableListOf()
context.contentResolver.query(WatchNextPrograms.CONTENT_URI, WatchNextProgram.PROJECTION, null, null, null)
.use { cursor ->
if (cursor != null && cursor.moveToFirst()) {
do {
currentWatchNextPrograms.add(WatchNextProgram.fromCursor(cursor))
} while (cursor.moveToNext())
}
}
return currentWatchNextPrograms
nextUpItems.map { item -> getBaseItemAsWatchNextProgram(item).toContentValues() }.toTypedArray()
)
}
/**
* Convert [BaseItemDto] to [WatchNextProgram]. Assumes the item type is "episode".
*/
@Suppress("RestrictedApi")
private fun getBaseItemAsWatchNextProgram(item: BaseItemDto) =
WatchNextProgram.Builder().apply {
val preferParentThumb = userPreferences[UserPreferences.seriesThumbnailsEnabled]
private fun getBaseItemAsWatchNextProgram(item: BaseItemDto) = WatchNextProgram.Builder().apply {
val preferParentThumb = userPreferences[UserPreferences.seriesThumbnailsEnabled]
setInternalProviderId(item.id.toString())
setInternalProviderId(item.id.toString())
// Poster size & type
if (item.type == BaseItemKind.EPISODE) {
setType(WatchNextPrograms.TYPE_TV_EPISODE)
setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_16_9)
} else if (item.type == BaseItemKind.MOVIE) {
setType(WatchNextPrograms.TYPE_MOVIE)
setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_MOVIE_POSTER)
// Poster size & type
if (item.type == BaseItemKind.EPISODE) {
setType(WatchNextPrograms.TYPE_TV_EPISODE)
setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_16_9)
} else if (item.type == BaseItemKind.MOVIE) {
setType(WatchNextPrograms.TYPE_MOVIE)
setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_MOVIE_POSTER)
}
// Name
if (item.seriesName != null) setTitle("${item.seriesName} - ${item.name}")
else setTitle(item.name)
// Poster
setPosterArtUri(item.getPosterArtImageUrl(preferParentThumb))
// Use date created or fallback to current time if unavailable
var engagement = item.dateCreated
when {
// User has started playing the episode
item.userData?.playbackPositionTicks ?: 0 > 0 -> {
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE)
setLastPlaybackPositionMillis((item.userData!!.playbackPositionTicks / TICKS_IN_MILLISECOND).toInt())
// Use last played date to prioritze
engagement = item.userData?.lastPlayedDate
}
// First episode of the season
item.indexNumber == 1 -> setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEW)
// Default
else -> setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
}
// Name and episode details
if (item.seriesName != null) {
setTitle(item.seriesName)
setEpisodeTitle(item.name)
setLastEngagementTimeUtcMillis(engagement?.toInstant(ZoneOffset.UTC)?.toEpochMilli()
?: System.currentTimeMillis())
item.indexNumber?.takeIf { it > 0 }?.let { setEpisodeNumber(it) }
item.parentIndexNumber?.takeIf { it > 0 }?.let { setSeasonNumber(it) }
} else {
setTitle(item.name)
}
// Episode runtime has been determined
item.runTimeTicks?.let { runTimeTicks ->
setDurationMillis((runTimeTicks / TICKS_IN_MILLISECOND).toInt())
}
setDescription(item.overview?.stripHtml())
// Poster
setPosterArtUri(item.getPosterArtImageUrl(preferParentThumb))
when {
// User has started playing the episode
(item.userData?.playbackPositionTicks ?: 0) > 0 -> {
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE)
setLastPlaybackPositionMillis(item.userData!!.playbackPositionTicks.ticks.inWholeMilliseconds.toInt())
// Use last played date to prioritize
setLastEngagementTimeUtcMillis(
item.userData?.lastPlayedDate?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
?: Instant.now().toEpochMilli()
)
}
// First episode of the season
item.indexNumber == 1 -> {
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEW)
setLastEngagementTimeUtcMillis(
item.dateCreated?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
?: Instant.now().toEpochMilli()
)
}
// Default
else -> {
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
setLastEngagementTimeUtcMillis(Instant.now().toEpochMilli())
}
}
// Runtime has been determined
item.runTimeTicks?.ticks?.let { setDurationMillis(it.inWholeMilliseconds.toInt()) }
// Set intent to open the episode
setIntent(Intent(context, StartupActivity::class.java).apply {
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
})
}.build()
// Set intent to open the episode
setIntent(Intent(context, StartupActivity::class.java).apply {
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
})
}.build()
}

Some files were not shown because too many files have changed in this diff Show More