Compare commits

..

21 Commits

Author SHA1 Message Date
gradle-update-robot
9ea2b81b28 Update Gradle Wrapper from 7.4.1 to 7.4.2.
Signed-off-by: gradle-update-robot <gradle-update-robot@regolo.cc>
(cherry picked from commit 65ebd3dac8)
2022-04-09 11:26:17 +02:00
Cameron
c568862ee1 fix getSubtitleStreamIndex returning the wrong index (#1594)
* dont use mDefaultSubIndex in getSubtitleStreamIndex

* only set mCurrentOptions SubtitleStreamIndex if subtitles load without error

* clear mDefaultSubIndex in switchSubtitleStream

(cherry picked from commit 5fd89ed8c9)
2022-04-09 11:15:58 +02:00
Niels van Velzen
42b767f619 Fix ASPECT_RATIO_BANNER again
(cherry picked from commit 7d1a653c10)
2022-04-09 11:15:48 +02:00
Niels van Velzen
ebf7fc4244 Add appCategory attribute to manifest
(cherry picked from commit e2cdab31ea)
2022-04-09 11:15:35 +02:00
Niels van Velzen
102642d58f Fix ExpandableTextView focusable when text is not ellipsized
(cherry picked from commit db3af65226)
2022-04-09 11:15:27 +02:00
Cameron
5115e1181e fixed subtitles not clearing properly when switching (again) (#1571)
* fixed subtitles not clearing properly when switching (again)

* edited a couple comments so they're clearer

(cherry picked from commit df015146e2)
2022-04-09 11:15:17 +02:00
David Fairbrother
f70ac357b6 Switch to using audio time stretching by default
This matches libVLC's normal default. Looking back in the project
history this was set to no stretching from the very start @commit
9528b9a3ee

Since libVLC use it by default and this wasn't a bug fix I propose we
can opt in the audio stretching by default. This should only trigger when
a user selects a playback speed that isn't 1x anyway.

(cherry picked from commit cfbadba4e3)
2022-04-09 11:15:03 +02:00
cameron
7136341c3c center align subtitles
(cherry picked from commit 079d004f2a)
2022-04-09 11:14:51 +02:00
Niels van Velzen
9b49af4c42 Fix player crashing when video ends 2022-03-23 20:57:07 +01:00
Cameron
fa59595dd5 only parse exoplayer TracksInfo when needed - instead of once every 0.5-2 seconds (#1556)
* only parse TracksInfo when needed

* only set exoplayerAudioIndex if streamType == MediaStreamType.Audio

(cherry picked from commit 92d37dc5b3)
2022-03-23 17:19:20 +01:00
David Fairbrother
7c3f495036 Add handling for updating end time based on playback speed
Adds handling to update the end time based on the user's current
playback speed.

This is a bit hacky since exoplayer is built on the assumption that we
have bi-directional communication to our UI layer. Instead the
CustomPlaybackTransportControlGlue is uni-directional polling the video
manager.

We unfortunately have to use hacky poll tricks to work-around the fact
we can't wire up on the "onPlaybackParametersChanged" call to notify the
glue class. However, in the short-term a 5s "delay" between setting and
updating is more favourable than setting up reverse listening
support.

Re #1509

(cherry picked from commit fa7956721e)
2022-03-23 17:19:14 +01:00
Niels van Velzen
d05ef3d810 Add Android 12 backup configuration
(cherry picked from commit 26ac67e302)
2022-03-23 17:19:07 +01:00
Niels van Velzen
f5c875959a Fix ASPECT_RATIO_BANNER
(cherry picked from commit 5f1b43c432)
2022-03-23 17:18:57 +01:00
Niels van Velzen
b45700739a Add fastlane structure to repository
(cherry picked from commit 39c21e607a)
2022-03-23 17:18:49 +01:00
Niels van Velzen
a0430a13d1 Add app name and flavor to version in about section
(cherry picked from commit d8695cb1a2)
2022-03-23 17:18:39 +01:00
Niels van Velzen
9dfe855940 Fix imageBlurHashes must not be null crash
(cherry picked from commit 77f31b9893)
2022-03-23 17:18:25 +01:00
cameron
d09c45f74f prefer refresh rates closest to the source refresh rate
(cherry picked from commit be89cf276b)
2022-03-23 17:18:13 +01:00
cameron
16d6b6b2ed prefer changing refresh rate without changing resolution
(cherry picked from commit 85a49fe757)
2022-03-23 17:18:12 +01:00
cameron
75e8938db3 fix crash from tracepot issue a46fe8ca0001267b
ActionsAdapter.clear() crashes when adapter is already empty

(cherry picked from commit f39ba05b38)
2022-03-23 17:17:49 +01:00
cameron
dff36669bf fix crash from tracepot issue 1d115b860001267b
crash from StreamInfo being null

(cherry picked from commit b1250de574)
2022-03-23 17:17:48 +01:00
cameron
1953508209 fix tracepot issue 1a07276a0001267b
fix crash from null leanbackOverlayFragment

(cherry picked from commit a8dea85a1b)
2022-03-23 17:17:47 +01:00
713 changed files with 17907 additions and 20401 deletions

View File

@@ -25,16 +25,17 @@ body:
attributes:
label: Logs
description: |
Please paste your crash logs here if applicable. You can find these in your servers dashboard under "logs".
The file name should start with "upload_org.jellyfin.androidtv".
Please paste your client logs (logcat) here, *NOT* server logs (in most cases).
Learn how to capture those logcats [here](https://wiki.lineageos.org/logcat.html).
Make sure that they don't contain any sensitive information like server URL, auth tokens or passwords.
placeholder: Paste logs…
render: shell
- type: textarea
id: logs-ffmpeg
attributes:
label: FFmpeg logs
description: |
Please paste your FFmpeg logs here. You can find these in your servers dashboard under "logs".
Please paste your FFmpeg logs here. You can find these by going to the dashboard of your Jellyfin server.
placeholder: Paste logs…
render: shell
- type: textarea
@@ -53,7 +54,7 @@ body:
attributes:
label: Application version
description: The version of the installed Jellyfin Android TV app.
placeholder: 0.14.0
placeholder: 0.12.0
validations:
required: true
- type: input
@@ -69,7 +70,7 @@ body:
attributes:
label: Android version
description: Version of the OS and other information (e.g. custom ROM / OEM skin)
placeholder: Android 9, Fire OS 7
placeholder: Android 9, Fire OS 7
validations:
required: true
- type: input
@@ -77,6 +78,6 @@ body:
attributes:
label: Jellyfin server version
description: If on unstable, please specify the commit hash.
placeholder: 10.8.1
placeholder: 10.7.7
validations:
required: true

View File

@@ -24,16 +24,17 @@ body:
attributes:
label: Logs
description: |
Please paste your crash logs here if applicable. You can find these in your servers dashboard under "logs".
The file name should start with "upload_org.jellyfin.androidtv".
Please paste your client logs (logcat) here, *NOT* server logs (in most cases).
Learn how to capture those logcats [here](https://wiki.lineageos.org/logcat.html).
Make sure that they don't contain any sensitive information like server URL, auth tokens or passwords.
placeholder: Paste logs…
render: shell
- type: input
id: app-version
attributes:
label: Application version
description: The version of the installed Jellyfin Android TV app.
placeholder: 0.14.0
placeholder: 0.12.0
validations:
required: true
- type: dropdown
@@ -66,6 +67,6 @@ body:
attributes:
label: Jellyfin server version
description: If on unstable, please specify the commit hash.
placeholder: 10.8.1
placeholder: 10.7.7
validations:
required: true

10
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
labels:
- ci
- github-actions
- dependencies

View File

@@ -1,4 +1,4 @@
name: App / Build
name: App Build
on:
push:
@@ -6,31 +6,24 @@ on:
- master
pull_request:
permissions:
contents: read
jobs:
build:
name: Build
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
steps:
- name: Checkout repository
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
uses: actions/checkout@v3
- name: Setup Java
uses: actions/setup-java@de1bb2b0c5634f0fc4438d7aa9944e68f9bf86cc # tag=v3
uses: actions/setup-java@v2
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef # tag=v2
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@83fd05a356d7e2593de66fc9913b3002723633cb # tag=v3
uses: actions/upload-artifact@v2
with:
name: build-artifacts
retention-days: 14
if-no-files-found: error
path: build/gh-app-publish/
path: app/build/outputs/apk/debug/*.apk

View File

@@ -1,4 +1,4 @@
name: App / Lint
name: App Lint
on:
push:
@@ -7,28 +7,22 @@ on:
- release-*
pull_request:
permissions:
contents: read
security-events: write
jobs:
lint:
name: Lint
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
steps:
- name: Checkout repository
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
uses: actions/checkout@v3
- name: Setup Java
uses: actions/setup-java@de1bb2b0c5634f0fc4438d7aa9944e68f9bf86cc # tag=v3
uses: actions/setup-java@v2
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef # tag=v2
- 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@678fc3afe258fb2e0cdc165ccf77b85719de7b3c # v2
uses: github/codeql-action/upload-sarif@v1
if: ${{ always() }}
with:
sarif_file: .

View File

@@ -1,4 +1,4 @@
name: App / Publish
name: App Publish
on:
push:
@@ -6,36 +6,33 @@ on:
- v*
jobs:
publish:
name: Publish
runs-on: ubuntu-22.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@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
uses: actions/checkout@v3
- name: Setup Java
uses: actions/setup-java@de1bb2b0c5634f0fc4438d7aa9944e68f9bf86cc # tag=v3
uses: actions/setup-java@v2
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef # tag=v2
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
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/apk/release
signingKeyBase64: ${{ secrets.KEYSTORE }}
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
alias: ${{ secrets.KEY_ALIAS }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Sign app bundle
- name: Sign AAB
id: signAab
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # tag=v1
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/bundle/release
signingKeyBase64: ${{ secrets.KEYSTORE }}
@@ -51,13 +48,13 @@ 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@259de5111cb56966d046ced998941e93f91d2c93 # tag=0.4.0
uses: alexellis/upload-assets@0.3.0
env:
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
with:
asset_paths: '["build/jellyfin-publish/*"]'
- name: Upload release archive to repo.jellyfin.org
uses: burnett01/rsync-deployments@0dc935cdecc5f5e571865e60d2a6cdc673704823 # tag=5.2
uses: burnett01/rsync-deployments@5.2
with:
switches: -vrptz
path: build/jellyfin-publish/
@@ -66,7 +63,7 @@ jobs:
remote_user: ${{ secrets.DEPLOY_USER }}
remote_key: ${{ secrets.DEPLOY_KEY }}
- name: Update repo.jellyfin.org symlinks
uses: appleboy/ssh-action@f9010ff7f1bbd7db1a0b4bab661437550cea20c0 # tag=v0.1.5
uses: appleboy/ssh-action@v0.1.4
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}

View File

@@ -1,4 +1,4 @@
name: App / Test
name: App Test
on:
push:
@@ -7,22 +7,17 @@ on:
- release-*
pull_request:
permissions:
contents: read
jobs:
test:
name: Test
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
steps:
- name: Checkout repository
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
uses: actions/checkout@v3
- name: Setup Java
uses: actions/setup-java@de1bb2b0c5634f0fc4438d7aa9944e68f9bf86cc # tag=v3
uses: actions/setup-java@v2
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef # tag=v2
distribution: adopt
java-version: 11
cache: gradle
- name: Run test task
run: ./gradlew test
run: ./gradlew --build-cache --no-daemon --info test

View File

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

View File

@@ -1,4 +1,4 @@
name: Gradle / Validate wrapper
name: Gradle validate
on:
push:
@@ -8,15 +8,11 @@ on:
paths:
- '**/gradlе-wrapper.jar'
permissions:
contents: read
jobs:
validate:
name: Validate
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
steps:
- name: Checkout repository
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
uses: actions/checkout@v3
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@55e685c48d84285a5b0418cd094606e199cca3b6 # tag=v1
uses: gradle/wrapper-validation-action@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@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-22.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
steps:
- uses: eps1lon/actions-label-merge-conflict@fd1f295ee7443d13745804bc49fe158e240f6c6e # tag=v2.1.0
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-22.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
stale:
runs-on: ubuntu-20.04
if: github.repository == 'jellyfin/jellyfin-androidtv'
steps:
- uses: actions/stale@5ebf00ea0e4c1561e9b43a292ed34424fb1d4578 # tag=v6
- uses: actions/stale@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).

View File

@@ -10,7 +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)
# Emby Contributors

View File

@@ -35,9 +35,6 @@
<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>
@@ -77,7 +74,7 @@ Look through the following graphic to see if your native language could use some
./gradlew assembleDebug
```
Add the Android SDK to your PATH environment variable or create the ANDROID_SDK_ROOT variable for
Add the Android SDK to you PATH environment variable or create the ANDROID_SDK_ROOT variable for
this to work.
### Deploy to device/emulator

View File

@@ -1,39 +0,0 @@
buildscript {
repositories {
mavenCentral()
google()
}
dependencies {
classpath libs.android.gradle
}
}
import com.android.tools.profgen.ArtProfileKt
import com.android.tools.profgen.ArtProfileSerializer
import com.android.tools.profgen.DexFile
/**
* This is a temporary workaround for https://issuetracker.google.com/issues/231837768
* which will be fixed in AGP 8.1.0, at which point the workaround may be deleted.
*/
project.afterEvaluate {
tasks.compileReleaseArtProfile.doLast {
outputs.files.each { file ->
if (file.toString().endsWith(".profm")) {
println("Sorting ${file}")
def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
def profile = ArtProfileKt.ArtProfile(file)
def keys = new ArrayList(profile.profileData.keySet())
def sortedData = new LinkedHashMap()
Collections.sort keys, new DexFile.Companion()
keys.each { key -> sortedData[key] = profile.profileData[key] }
new FileOutputStream(file).with {
write(version.magicBytes$profgen)
write(version.versionBytes$profgen)
version.write$profgen(it, sortedData, "")
}
}
}
}
}

View File

@@ -1,21 +1,18 @@
plugins {
id("com.android.application")
kotlin("android")
alias(libs.plugins.kotlin.ksp)
kotlin("kapt")
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.aboutlibraries)
}
// Apply workaround
apply("baselineWorkaround.gradle")
android {
namespace = "org.jellyfin.androidtv"
compileSdk = 33
compileSdk = 31
defaultConfig {
minSdk = 21
targetSdk = 33
targetSdk = 31
// Release version
applicationId = namespace
@@ -24,19 +21,17 @@ android {
setProperty("archivesBaseName", "jellyfin-androidtv-v$versionName")
}
sourceSets["main"].java.srcDirs("src/main/kotlin")
sourceSets["test"].java.srcDirs("src/test/kotlin")
buildFeatures {
viewBinding = true
compose = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get()
}
buildTypes {
val release by getting {
isMinifyEnabled = false
@@ -80,11 +75,6 @@ android {
}
}
aboutLibraries {
// Remove the "generated" timestamp to allow for reproducible builds
excludeFields = arrayOf("generated")
}
val versionTxt by tasks.registering {
val path = buildDir.resolve("version.txt")
@@ -97,11 +87,7 @@ val versionTxt by tasks.registering {
dependencies {
// Jellyfin
implementation(projects.playback.core)
implementation(projects.playback.exoplayer)
implementation(projects.playback.jellyfin)
implementation(projects.playback.ui)
implementation(projects.preference)
implementation(projects.playback)
implementation(libs.jellyfin.apiclient)
implementation(libs.jellyfin.sdk) {
// Change version if desired
@@ -133,23 +119,21 @@ dependencies {
implementation(libs.androidx.window)
implementation(libs.androidx.cardview)
implementation(libs.androidx.startup)
implementation(libs.bundles.androidx.compose)
// Dependency Injection
implementation(libs.bundles.koin)
// GSON
implementation(libs.gson)
// Media players
implementation(libs.exoplayer)
implementation(libs.jellyfin.exoplayer.ffmpegextension)
implementation(libs.libvlc)
// Markdown
implementation(libs.bundles.markwon)
// Image utility
implementation(libs.blurhash)
implementation(libs.glide.core)
ksp(libs.glide.ksp)
kapt(libs.glide.compiler)
implementation(libs.kenburnsview)
// Crash Reporting

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
package="org.jellyfin.androidtv">
<!-- Android TV Integration -->
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
@@ -23,14 +23,13 @@
<uses-permission
android:name="android.permission.ACCESS_WIFI_STATE"
tools:ignore="LeanbackUsesWifi" />
<!-- 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" />
@@ -49,11 +48,11 @@
android:label="@string/app_name"
android:largeHeap="true"
android:roundIcon="@drawable/app_icon_round"
android:supportsRtl="true"
android:theme="@style/Theme.Jellyfin"
android:usesCleartextTraffic="true">
<service
android:name=".auth.service.AuthenticatorService"
android:name=".auth.AuthenticatorService"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
@@ -63,7 +62,6 @@
android:resource="@xml/authenticator" />
</service>
<!-- Screensaver -->
<service
android:name=".integration.dream.LibraryDreamService"
android:exported="true"
@@ -90,9 +88,6 @@
<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
@@ -101,19 +96,16 @@
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:noHistory="true"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustNothing">
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.SEARCH" />
@@ -126,24 +118,73 @@
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:screenOrientation="landscape"
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:screenOrientation="behind"
android:theme="@style/Theme.Jellyfin.Preferences" />
<!-- Playback related activities -->
<activity
android:name=".ui.playback.PlaybackOverlayActivity"
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" />

View File

@@ -1,87 +1,102 @@
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.work.BackoffPolicy
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.await
import com.bumptech.glide.Glide
import com.vanniktech.blurhash.BlurHash
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.acra.ACRA
import org.jellyfin.androidtv.data.eventhandling.SocketHandler
import org.jellyfin.androidtv.data.repository.NotificationsRepository
import org.acra.config.dialog
import org.acra.config.httpSender
import org.acra.config.limiter
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import org.jellyfin.androidtv.auth.SessionRepository
import org.jellyfin.androidtv.integration.LeanbackChannelWorker
import org.jellyfin.androidtv.telemetry.TelemetryService
import org.jellyfin.androidtv.util.AutoBitrate
import org.koin.android.ext.android.get
import org.koin.android.ext.android.getKoin
import org.koin.android.ext.android.inject
import timber.log.Timber
import java.util.concurrent.TimeUnit
@Suppress("unused")
class JellyfinApplication : Application() {
class JellyfinApplication : TvApp() {
override fun onCreate() {
super.onCreate()
// Don't run in ACRA service
if (ACRA.isACRASenderServiceProcess()) return
// 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
getKoin().getAll<ActivityLifecycleCallbacks>().forEach(::registerActivityLifecycleCallbacks)
}
val notificationsRepository by inject<NotificationsRepository>()
notificationsRepository.addDefaultNotifications()
/**
* 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")
get<SessionRepository>().restoreDefaultSession()
}
})
}
/**
* Called from the StartupActivity when the user session is started.
*/
suspend fun onSessionStart() = withContext(Dispatchers.IO) {
@DelicateCoroutinesApi
suspend fun onSessionStart() {
val workManager by inject<WorkManager>()
val autoBitrate by inject<AutoBitrate>()
val socketListener by inject<SocketHandler>()
// Update background worker
launch {
// Cancel all current workers
workManager.cancelAllWork().await()
// Cancel all current workers
workManager.cancelAllWork().await()
// Recreate periodic workers
workManager.enqueueUniquePeriodicWork(
LeanbackChannelWorker.PERIODIC_UPDATE_REQUEST_NAME,
ExistingPeriodicWorkPolicy.REPLACE,
PeriodicWorkRequestBuilder<LeanbackChannelWorker>(1, TimeUnit.HOURS)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES)
.build()
).await()
}
// Update WebSockets
launch { socketListener.updateSession() }
// Recreate periodic workers
workManager.enqueueUniquePeriodicWork(
LeanbackChannelWorker.PERIODIC_UPDATE_REQUEST_NAME,
ExistingPeriodicWorkPolicy.REPLACE,
PeriodicWorkRequestBuilder<LeanbackChannelWorker>(1, TimeUnit.HOURS)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES)
.build()
).await()
// Detect auto bitrate
// running in a different scope to prevent slow startups
ProcessLifecycleOwner.get().lifecycleScope.launch { autoBitrate.detect() }
}
override fun onLowMemory() {
super.onLowMemory()
BlurHash.clearCache()
Glide.with(this).onLowMemory()
}
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
Glide.with(this).onTrimMemory(level)
GlobalScope.launch(Dispatchers.IO) { autoBitrate.detect() }
}
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
TelemetryService.init(this)
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON
httpSender {
uri = "https://collector.tracepot.com/a2eda9d9"
httpMethod = HttpSender.Method.POST
}
dialog {
withResTitle(R.string.acra_dialog_title)
withResText(R.string.acra_dialog_text)
withResTheme(R.style.Theme_Jellyfin)
}
limiter {}
}
}
}

View File

@@ -1,50 +0,0 @@
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

@@ -1,12 +1,9 @@
package org.jellyfin.androidtv
import android.content.Context
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.startup.AppInitializer
import androidx.startup.Initializer
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.auth.repository.SessionRepository
import org.jellyfin.androidtv.auth.SessionRepository
import org.jellyfin.androidtv.di.KoinInitializer
@Suppress("unused")
@@ -17,9 +14,7 @@ class SessionInitializer : Initializer<Unit> {
.koin
// Restore system session
ProcessLifecycleOwner.get().lifecycleScope.launch {
koin.get<SessionRepository>().restoreSession()
}
koin.get<SessionRepository>().restoreDefaultSystemSession()
}
override fun dependencies() = listOf(KoinInitializer::class.java)

View File

@@ -0,0 +1,93 @@
package org.jellyfin.androidtv;
import android.app.Activity;
import android.app.Application;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MediatorLiveData;
import org.jellyfin.androidtv.ui.livetv.TvManager;
import org.jellyfin.androidtv.ui.playback.PlaybackController;
import org.jellyfin.apiclient.model.dto.BaseItemDto;
import org.jellyfin.apiclient.model.dto.UserDto;
public class TvApp extends Application {
public static final int LIVE_TV_GUIDE_OPTION_ID = 1000;
public static final int LIVE_TV_RECORDINGS_OPTION_ID = 2000;
public static final int VIDEO_QUEUE_OPTION_ID = 3000;
public static final int LIVE_TV_SCHEDULE_OPTION_ID = 4000;
public static final int LIVE_TV_SERIES_OPTION_ID = 5000;
private static TvApp app;
private MediatorLiveData<UserDto> currentUser = new MediatorLiveData<UserDto>();
private BaseItemDto lastPlayedItem;
private PlaybackController playbackController;
private Activity currentActivity;
@Override
public void onCreate() {
super.onCreate();
app = this;
}
@Nullable
public static TvApp getApplication() {
return app;
}
@Deprecated
@Nullable
public UserDto getCurrentUser() {
return currentUser.getValue();
}
@Deprecated
public LiveData<UserDto> getCurrentUserLiveData() {
return currentUser;
}
@Deprecated
public void setCurrentUser(UserDto currentUser) {
this.currentUser.postValue(currentUser);
TvManager.clearCache();
}
/**
* @deprecated This function is causing a **lot** of issues because not all activities will set their self as "currentactivity". Try to receive a Context instance instead.
*/
@Deprecated
@Nullable
public Activity getCurrentActivity() {
return currentActivity;
}
public void setCurrentActivity(Activity activity) {
currentActivity = activity;
}
@Nullable
public PlaybackController getPlaybackController() {
return playbackController;
}
public void setPlaybackController(PlaybackController playbackController) {
this.playbackController = playbackController;
}
public boolean canManageRecordings() {
UserDto currentUser = getCurrentUser();
return currentUser != null && currentUser.getPolicy().getEnableLiveTvManagement();
}
@Nullable
public BaseItemDto getLastPlayedItem() {
return lastPlayedItem;
}
public void setLastPlayedItem(BaseItemDto lastPlayedItem) {
this.lastPlayedItem = lastPlayedItem;
}
}

View File

@@ -1,8 +1,7 @@
package org.jellyfin.androidtv.auth.store
package org.jellyfin.androidtv.auth
import android.accounts.Account
import android.accounts.AccountManager
import android.os.Build
import android.os.Bundle
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.auth.model.AccountManagerAccount
@@ -12,7 +11,7 @@ import java.util.UUID
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class AccountManagerStore(
class AccountManagerHelper(
private val accountManager: AccountManager
) {
companion object {
@@ -23,16 +22,8 @@ class AccountManagerStore(
const val ACCOUNT_ACCESS_TOKEN_TYPE = "$ACCOUNT_TYPE.access_token"
}
private fun Array<Account>.filterServerAccount(server: UUID, account: UUID? = null) = filter {
val validServerId = accountManager.getUserData(it, ACCOUNT_DATA_SERVER)?.toUUIDOrNull() == server
val validUserId = account == null || accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == account
validServerId && validUserId
}
private fun getAccountData(account: Account): AccountManagerAccount = AccountManagerAccount(
id = accountManager.getUserData(account, ACCOUNT_DATA_ID).toUUID(),
address = account.name,
server = accountManager.getUserData(account, ACCOUNT_DATA_SERVER).toUUID(),
name = accountManager.getUserData(account, ACCOUNT_DATA_NAME),
accessToken = accountManager.peekAuthToken(account, ACCOUNT_ACCESS_TOKEN_TYPE)
@@ -40,12 +31,11 @@ class AccountManagerStore(
suspend fun putAccount(accountManagerAccount: AccountManagerAccount) {
var androidAccount = accountManager.getAccountsByType(ACCOUNT_TYPE)
.filterServerAccount(accountManagerAccount.server, accountManagerAccount.id)
.firstOrNull()
.firstOrNull { accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == accountManagerAccount.id }
// Update credentials
if (androidAccount == null) {
androidAccount = Account(accountManagerAccount.address, ACCOUNT_TYPE)
androidAccount = Account(accountManagerAccount.name, ACCOUNT_TYPE)
accountManager.addAccountExplicitly(
androidAccount,
@@ -56,14 +46,9 @@ class AccountManagerStore(
}
// Update name
if (androidAccount.name != accountManagerAccount.address) {
if (androidAccount.name != accountManagerAccount.name) {
androidAccount = suspendCoroutine { continuation ->
accountManager.renameAccount(
androidAccount,
accountManagerAccount.address,
{ continuation.resume(it.result) },
null,
)
accountManager.renameAccount(androidAccount, accountManagerAccount.name, { continuation.resume(it.result) }, null)
}
}
@@ -72,28 +57,25 @@ class AccountManagerStore(
accountManager.setAuthToken(androidAccount, ACCOUNT_ACCESS_TOKEN_TYPE, accountManagerAccount.accessToken)
}
fun removeAccount(accountManagerAccount: AccountManagerAccount): Boolean {
fun removeAccount(accountManagerAccount: AccountManagerAccount) {
val androidAccount = accountManager.getAccountsByType(ACCOUNT_TYPE)
.filterServerAccount(accountManagerAccount.server, accountManagerAccount.id)
.firstOrNull()
?: return false
.first { accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == accountManagerAccount.id }
// Remove current account info
@Suppress("DEPRECATION")
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP_MR1)
accountManager.removeAccountExplicitly(androidAccount)
else
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)
.filterServerAccount(server)
.map(::getAccountData)
fun getAccountsByServer(server: UUID) = accountManager.getAccountsByType(ACCOUNT_TYPE).filter { account ->
accountManager.getUserData(account, ACCOUNT_DATA_SERVER)?.toUUIDOrNull() == server
}.map(::getAccountData)
fun getAccount(server: UUID, account: UUID) = accountManager.getAccountsByType(ACCOUNT_TYPE)
.filterServerAccount(server, account)
.firstOrNull()
?.let(::getAccountData)
fun getAccount(id: UUID) = accountManager.getAccountsByType(ACCOUNT_TYPE).firstOrNull { account ->
accountManager.getUserData(account, ACCOUNT_DATA_ID)?.toUUIDOrNull() == id
}?.let(::getAccountData)
}

View File

@@ -0,0 +1,81 @@
package org.jellyfin.androidtv.auth
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.JellyfinApplication
import org.jellyfin.androidtv.util.apiclient.callApi
import org.jellyfin.androidtv.util.apiclient.callApiEmpty
import org.jellyfin.androidtv.util.sdk.legacy
import org.jellyfin.apiclient.interaction.ApiClient
import org.jellyfin.apiclient.model.apiclient.ServerInfo
import org.jellyfin.apiclient.model.dto.UserDto
import org.jellyfin.apiclient.model.entities.MediaType
import org.jellyfin.apiclient.model.session.ClientCapabilities
import org.jellyfin.apiclient.model.session.GeneralCommandType
import org.jellyfin.sdk.model.DeviceInfo
import timber.log.Timber
class ApiBinder(
private val application: JellyfinApplication,
private val api: ApiClient,
private val authenticationStore: AuthenticationStore,
) {
fun updateSession(session: Session?, deviceInfo: DeviceInfo, resultCallback: ((Boolean) -> Unit)? = null) {
GlobalScope.launch(Dispatchers.IO) {
@Suppress("TooGenericExceptionCaught")
val success = try {
updateSessionInternal(session, deviceInfo)
} catch (throwable: Throwable) {
Timber.e(throwable, "Unable to update legacy API session.")
false
}
resultCallback?.invoke(success)
}
}
private suspend fun updateSessionInternal(session: Session?, deviceInfo: DeviceInfo): Boolean {
if (session == null) {
application.currentUser = null
return true
}
val server = authenticationStore.getServer(session.serverId)
if (server == null) {
Timber.e("Could not bind API because server ${session.serverId} was not found in the store.")
return false
}
api.device = 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
})
// Update currentUser DTO
val user = callApi<UserDto> { callback -> api.GetUserAsync(session.userId.toString(), callback) }
application.currentUser = user
callApiEmpty { callback ->
api.ReportCapabilities(ClientCapabilities().apply {
playableMediaTypes = arrayListOf(MediaType.Video, MediaType.Audio)
supportsMediaControl = true
supportedCommands = arrayListOf(
GeneralCommandType.DisplayContent.toString(),
GeneralCommandType.DisplayMessage.toString(),
)
}, callback)
}
// Connect to WebSocket AFTER HTTP connection confirmed working
// to catch exceptions not catchable with the legacy websocket client
api.ensureWebSocket()
return true
}
}

View File

@@ -0,0 +1,276 @@
package org.jellyfin.androidtv.auth
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import org.jellyfin.androidtv.auth.model.AccountManagerAccount
import org.jellyfin.androidtv.auth.model.AuthenticatedState
import org.jellyfin.androidtv.auth.model.AuthenticatingState
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
import org.jellyfin.androidtv.auth.model.AuthenticationStoreUser
import org.jellyfin.androidtv.auth.model.LoginState
import org.jellyfin.androidtv.auth.model.PrivateUser
import org.jellyfin.androidtv.auth.model.RequireSignInState
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.auth.model.ServerVersionNotSupported
import org.jellyfin.androidtv.auth.model.User
import org.jellyfin.androidtv.preference.AuthenticationPreferences
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
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
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.ImageType
import timber.log.Timber
import java.util.Date
import java.util.UUID
interface AuthenticationRepository {
fun getServers(): List<Server>
fun getServer(id: UUID): Server?
fun getUsers(server: UUID): List<PrivateUser>?
fun getUser(server: UUID, user: UUID): PrivateUser?
fun saveServer(id: UUID, name: String, address: String, version: String?, loginDisclaimer: String?)
fun authenticateUser(user: User): Flow<LoginState>
fun authenticateUser(user: User, server: Server): Flow<LoginState>
fun login(server: Server, username: String, password: String = ""): Flow<LoginState>
fun logout(user: User)
fun removeUser(user: User)
fun getUserImageUrl(server: Server, user: User): String?
}
class AuthenticationRepositoryImpl(
private val jellyfin: Jellyfin,
private val sessionRepository: SessionRepository,
private val accountManagerHelper: AccountManagerHelper,
private val authenticationStore: AuthenticationStore,
private val userApiClient: ApiClient,
private val authenticationPreferences: AuthenticationPreferences,
private val defaultDeviceInfo: DeviceInfo,
) : AuthenticationRepository {
private val serverComparator = compareByDescending<Server> { it.dateLastAccessed }.thenBy { it.name }
private val userComparator = compareByDescending<PrivateUser> { it.lastUsed }.thenBy { it.name }
private fun AuthenticationStoreServer.asServer(id: UUID) = Server(
id = id,
name = name,
address = address,
version = version,
loginDisclaimer = loginDisclaimer,
dateLastAccessed = Date(lastUsed),
)
override fun getServers() = authenticationStore.getServers()
.map { (id, entry) -> entry.asServer(id) }
.sortedWith(serverComparator)
override fun getServer(id: UUID) = authenticationStore.getServer(id)?.asServer(id)
private fun mapUser(
serverId: UUID,
userId: UUID,
userInfo: AuthenticationStoreUser,
authInfo: AccountManagerAccount?,
) = PrivateUser(
id = authInfo?.id ?: userId,
serverId = authInfo?.server ?: serverId,
name = userInfo.name,
accessToken = authInfo?.accessToken,
requirePassword = userInfo.requirePassword,
imageTag = userInfo.imageTag,
lastUsed = userInfo.lastUsed,
)
override fun getUsers(server: UUID): List<PrivateUser>? =
authenticationStore.getUsers(server)?.mapNotNull { (userId, userInfo) ->
accountManagerHelper.getAccount(userId).let { authInfo ->
mapUser(server, userId, userInfo, authInfo)
}
}?.sortedWith(userComparator)
override fun getUser(server: UUID, user: UUID): PrivateUser? {
val userInfo = authenticationStore.getUser(server, user) ?: return null
val authInfo = accountManagerHelper.getAccount(user) ?: return null
return mapUser(server, user, userInfo, authInfo)
}
override fun saveServer(id: UUID, name: String, address: String, version: String?, loginDisclaimer: String?) {
val current = authenticationStore.getServer(id)
val server = if (current != null) current.copy(name = name, address = address, version = version, loginDisclaimer = loginDisclaimer, lastUsed = Date().time)
else AuthenticationStoreServer(name, address, version, loginDisclaimer)
authenticationStore.putServer(id, server)
}
/**
* Set the active session to the information in [user] and [server].
* Connects to the server and requests the info of the currently authenticated user.
*
* @return Whether the user information can be retrieved.
*/
private suspend fun setActiveSession(user: User, server: Server): Boolean {
val authenticated = sessionRepository.switchCurrentSession(user.id)
if (authenticated) {
// Update last use in store
authenticationStore.getServer(server.id)?.let { storedServer ->
authenticationStore.putServer(server.id, storedServer.copy(lastUsed = Date().time))
}
authenticationStore.getUser(server.id, user.id)?.let { storedUser ->
authenticationStore.putUser(server.id, user.id, storedUser.copy(lastUsed = Date().time))
}
}
return authenticated
}
override fun authenticateUser(user: User): Flow<LoginState> = flow {
Timber.d("Authenticating serverless user %s", user)
emit(AuthenticatingState)
val server = authenticationStore.getServer(user.serverId)?.let {
Server(
id = user.serverId,
name = it.name,
address = it.address,
version = it.version,
loginDisclaimer = it.loginDisclaimer,
dateLastAccessed = Date(it.lastUsed),
)
}
if (server == null) emit(RequireSignInState)
else if (!server.versionSupported) emit(ServerVersionNotSupported(server))
else emitAll(authenticateUser(user, server))
}
override fun authenticateUser(user: User, server: Server): Flow<LoginState> = flow {
Timber.d("Authenticating user %s", user)
emit(AuthenticatingState)
val account = accountManagerHelper.getAccount(user.id)
when {
!server.versionSupported -> emit(ServerVersionNotSupported(server))
// Access token found, proceed with sign in
!authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate] && account?.accessToken != null -> when {
// Update session
setActiveSession(user, server) -> {
// Update stored user
try {
val userInfo by userApiClient.userApi.getCurrentUser()
val currentUser = authenticationStore.getUser(server.id, user.id)
val updatedUser = currentUser?.copy(
name = userInfo.name!!,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag
) ?: AuthenticationStoreUser(
name = userInfo.name!!,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag
)
authenticationStore.putUser(server.id, user.id, updatedUser)
accountManagerHelper.putAccount(AccountManagerAccount(user.id, server.id, updatedUser.name, account.accessToken))
emit(AuthenticatedState)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to get current user data")
emit(RequireSignInState)
}
}
// Login failed
else -> when {
// No password required - try login
!user.requirePassword -> emitAll(login(server, user.name))
// Require new sign in
else -> emit(RequireSignInState)
}
}
// User is known to not require a password, try a sign in
!user.requirePassword -> emitAll(login(server, user.name))
// Account found without access token, require sign in
else -> emit(RequireSignInState)
}
}
override fun login(server: Server, username: String, password: String) = flow {
if (!server.versionSupported) {
emit(ServerVersionNotSupported(server))
return@flow
}
val api = jellyfin.createApi(server.address, deviceInfo = defaultDeviceInfo.forUser(username))
val result = try {
val response = api.userApi.authenticateUserByName(username, password)
response.content
} catch (err: ApiClientException) {
Timber.e(err, "Unable to sign in as $username")
emit(RequireSignInState)
return@flow
}
val userInfo = result.user ?: return@flow emit(RequireSignInState)
val userId = userInfo.id
val currentUser = authenticationStore.getUser(server.id, userId)
val updatedUser = currentUser?.copy(
name = userInfo.name!!,
lastUsed = Date().time,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag
) ?: AuthenticationStoreUser(
name = userInfo.name!!,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag
)
authenticationStore.putUser(server.id, userId, updatedUser)
val accountManagerAccount = AccountManagerAccount(userId, server.id, updatedUser.name, result.accessToken)
accountManagerHelper.putAccount(accountManagerAccount)
val user = PrivateUser(
id = userId,
serverId = server.id,
name = updatedUser.name,
accessToken = result.accessToken,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag,
lastUsed = Date().time,
)
// We just added the account so it should activate properly although in rare cases it doesn't.
// this is often caused by issues in the platforms account manager
if (setActiveSession(user, server)) emit(AuthenticatedState)
else {
// Try to remove the account and ask for sign in
accountManagerHelper.removeAccount(accountManagerAccount)
emit(RequireSignInState)
}
}
override fun logout(user: User) {
accountManagerHelper.getAccount(user.id)?.let(accountManagerHelper::removeAccount)
}
override fun removeUser(user: User) {
authenticationStore.removeUser(user.serverId, user.id)
logout(user)
}
override fun getUserImageUrl(server: Server, user: User): String? {
return user.imageTag?.let { imageTag ->
jellyfin.createApi(baseUrl = server.address).imageApi.getUserImageUrl(
userId = user.id,
tag = imageTag,
imageType = ImageType.PRIMARY,
maxHeight = ImageUtils.MAX_PRIMARY_IMAGE_HEIGHT
)
}
}
}

View File

@@ -1,4 +1,4 @@
package org.jellyfin.androidtv.auth.model
package org.jellyfin.androidtv.auth
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions

View File

@@ -1,7 +1,6 @@
package org.jellyfin.androidtv.auth.store
package org.jellyfin.androidtv.auth
import android.content.Context
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
@@ -21,7 +20,7 @@ import java.util.UUID
/**
* 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
* combination with the [AccountManagerHelper] to store access tokens and integrate with the
* operating system.
*
* The data is stored in a JSON file located in the applications data directory.
@@ -48,15 +47,8 @@ class AuthenticationStore(
// No store found
if (!storePath.exists()) return emptyMap()
// Parse JSON document
val root = try {
json.parseToJsonElement(storePath.readText()).jsonObject
} catch (e: SerializationException) {
Timber.e(e, "Unable to read JSON")
JsonObject(emptyMap())
}
// Check for version
val root = json.parseToJsonElement(storePath.readText()).jsonObject
return when (root["version"]?.jsonPrimitive?.intOrNull) {
1 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
null -> {
@@ -102,6 +94,10 @@ class AuthenticationStore(
return save()
}
fun containsServer(server: UUID): Boolean = server in store
fun containsUser(server: UUID, user: UUID): Boolean = server in store && user in store[server]!!.users
/**
* Removes the server and stored users from the credential store.
*/

View File

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

View File

@@ -0,0 +1,98 @@
package org.jellyfin.androidtv.auth
import android.content.Context
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import org.jellyfin.androidtv.auth.model.AccountManagerAccount
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
import org.jellyfin.androidtv.auth.model.AuthenticationStoreUser
import org.jellyfin.androidtv.preference.SystemPreferences
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
class LegacyAccountMigration(
private val context: Context,
private val authenticationStore: AuthenticationStore,
private val accountManagerHelper: AccountManagerHelper,
private val systemPreferences: SystemPreferences,
) {
companion object {
const val LEGACY_CREDENTIAL_FILE = "org.jellyfin.androidtv.login.json"
}
suspend fun migrate() {
val path = context.filesDir.resolve(LEGACY_CREDENTIAL_FILE)
if (path.exists() && !systemPreferences[SystemPreferences.legacyCredentialsMigrated]) {
Timber.d("Starting migration of legacy credentials from $path")
val json = Json {
serializersModule = SerializersModule {
contextual(UUIDSerializer())
}
ignoreUnknownKeys = true
}
val root = json.parseToJsonElement(path.readText()).jsonObject
val server = root["serverInfo"]?.jsonObject
val user = root["userDto"]?.jsonObject
val serverId = server?.get("Id")?.jsonPrimitive?.content?.toUUIDOrNull()
if (serverId != null) {
val serverName = server["Name"]?.jsonPrimitive?.content
val serverAddress = server["Address"]?.jsonPrimitive?.content
val serverVersion = server["Version"]?.jsonPrimitive?.content
if (!authenticationStore.containsServer(serverId)) {
Timber.i("Migrating server $serverId")
authenticationStore.putServer(
id = serverId,
server = AuthenticationStoreServer(
name = serverName ?: "",
address = serverAddress ?: "",
loginDisclaimer = null,
version = serverVersion,
lastRefreshed = 0,
)
)
}
val userId = user?.get("Id")?.jsonPrimitive?.content?.toUUIDOrNull()
if (userId != null && !authenticationStore.containsUser(serverId, userId)) {
Timber.i("Migrating user $userId")
val name = user["Name"]?.jsonPrimitive?.content ?: userId.toString()
val requirePassword = user["HasPassword"]?.jsonPrimitive?.booleanOrNull ?: true
val imageTag = user["PrimaryImageTag"]?.jsonPrimitive?.content
val accessToken = user["AccessToken"]?.jsonPrimitive?.content ?: server["AccessToken"]?.jsonPrimitive?.content
authenticationStore.putUser(
server = serverId,
userId = userId,
userInfo = AuthenticationStoreUser(
name = name,
requirePassword = requirePassword,
imageTag = imageTag
)
)
accountManagerHelper.putAccount(AccountManagerAccount(
id = userId,
server = serverId,
name = name,
accessToken = accessToken?.let {
// Convert empty string to null
if (it.isBlank()) null
else it
}
))
}
}
systemPreferences[SystemPreferences.legacyCredentialsMigrated] = true
} else {
Timber.d("Skipping migration of legacy credentials from $path (file does not exist)")
}
}
}

View File

@@ -0,0 +1,200 @@
package org.jellyfin.androidtv.auth
import androidx.lifecycle.LiveData
import androidx.lifecycle.liveData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapNotNull
import org.jellyfin.androidtv.auth.model.ConnectedState
import org.jellyfin.androidtv.auth.model.ConnectingState
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.model.ServerAdditionState
import org.jellyfin.androidtv.auth.model.UnableToConnectState
import org.jellyfin.androidtv.auth.model.User
import org.jellyfin.androidtv.util.sdk.toPublicUser
import org.jellyfin.androidtv.util.sdk.toServer
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.brandingApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.api.ServerDiscoveryInfo
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.serializer.toUUID
import timber.log.Timber
import java.util.Date
import java.util.UUID
interface ServerRepository {
fun getStoredServers(): List<Server>
fun getDiscoveryServers(): Flow<Server>
suspend fun migrateLegacyCredentials()
fun getServerUsers(server: Server): LiveData<List<User>>
fun removeServer(serverId: UUID): Boolean
fun addServer(address: String): Flow<ServerAdditionState>
suspend fun refreshServerInfo(server: Server): Boolean
companion object {
val minimumServerVersion = Jellyfin.minimumVersion.copy(build = null)
}
}
class ServerRepositoryImpl(
private val jellyfin: Jellyfin,
private val authenticationRepository: AuthenticationRepository,
private val authenticationStore: AuthenticationStore,
private val legacyAccountMigration: LegacyAccountMigration,
) : ServerRepository {
override fun getStoredServers() = authenticationRepository.getServers()
override fun getDiscoveryServers() = flow {
val servers = jellyfin.discovery.discoverLocalServers().mapNotNull(ServerDiscoveryInfo::toServer)
emitAll(servers)
}.flowOn(Dispatchers.IO)
private suspend fun getServerPublicUsers(server: Server): List<PublicUser> {
val api = jellyfin.createApi(server.address)
return try {
val users by api.userApi.getPublicUsers()
users.mapNotNull(UserDto::toPublicUser)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to retrieve public users")
emptyList()
}
}
public override suspend fun refreshServerInfo(server: Server): Boolean {
// Only update existing servers
val serverInfo = authenticationStore.getServer(server.id) ?: return false
val now = Date().time
// Only update every 10 minutes
if (now - serverInfo.lastRefreshed < 600000 && serverInfo.version != null) return false
return try {
val api = jellyfin.createApi(server.address)
// Get login disclaimer
val branding by api.brandingApi.getBrandingOptions()
val systemInfo by api.systemApi.getPublicSystemInfo()
authenticationStore.putServer(server.id, serverInfo.copy(
name = systemInfo.serverName ?: serverInfo.name,
version = systemInfo.version ?: serverInfo.version,
loginDisclaimer = branding.loginDisclaimer ?: serverInfo.loginDisclaimer,
lastRefreshed = now
))
} catch (err: ApiClientException) {
Timber.e(err, "Unable to update server")
false
}
}
private fun getServerStoredUsers(server: Server): List<PrivateUser> = authenticationRepository
.getUsers(server.id)
.orEmpty()
override fun getServerUsers(server: Server): LiveData<List<User>> = liveData {
val users = mutableListOf<User>()
users.addAll(getServerStoredUsers(server))
emit(users.toList())
getServerPublicUsers(server)
.sortedBy { it.name }
.forEach { user ->
val index = users.indexOfFirst { it.id == user.id }
if (index == -1) users.add(user)
else {
users[index] = when (val currentUser = users[index]) {
is PublicUser -> user
is PrivateUser -> currentUser.copy(
name = user.name,
requirePassword = user.requirePassword,
imageTag = user.imageTag
)
}
}
}
emit(users.toList())
}
override suspend fun migrateLegacyCredentials() = legacyAccountMigration.migrate()
override fun removeServer(serverId: UUID) = authenticationStore.removeServer(serverId)
override fun addServer(address: String): Flow<ServerAdditionState> = flow {
Timber.d("Adding server %s", address)
emit(ConnectingState(address))
val addressCandidates = jellyfin.discovery.getAddressCandidates(address)
Timber.d("Found ${addressCandidates.size} candidates")
val goodRecommendations = mutableListOf<RecommendedServerInfo>()
val badRecommendations = mutableListOf<RecommendedServerInfo>()
val greatRecommendaton = jellyfin.discovery.getRecommendedServers(addressCandidates).firstOrNull { recommendedServer ->
when (recommendedServer.score) {
RecommendedServerInfoScore.GREAT -> true
RecommendedServerInfoScore.GOOD -> {
goodRecommendations += recommendedServer
false
}
else -> {
badRecommendations += recommendedServer
false
}
}
}
Timber.d(buildString {
append("Recommendations: ")
if (greatRecommendaton == null) append(0)
else append(1)
append(" great, ")
append(goodRecommendations.size)
append(" good, ")
append(badRecommendations.size)
append(" bad")
})
val chosenRecommendation = greatRecommendaton ?: goodRecommendations.firstOrNull()
if (chosenRecommendation != null && chosenRecommendation.systemInfo.isSuccess) {
// Get system info
val systemInfo = chosenRecommendation.systemInfo.getOrThrow()
// Get branding info
val api = jellyfin.createApi(chosenRecommendation.address).brandingApi
val branding by api.getBrandingOptions()
val id = systemInfo.id!!.toUUID()
authenticationRepository.saveServer(
id = id,
name = systemInfo.serverName ?: "Jellyfin Server",
address = chosenRecommendation.address,
version = systemInfo.version,
loginDisclaimer = branding.loginDisclaimer,
)
emit(ConnectedState(id, systemInfo))
} else {
// No great or good recommendations, only add bad recommendations
val addressCandidatesWithIssues = (badRecommendations + goodRecommendations)
.groupBy { it.address }
.mapValues { (_, entry) -> entry.flatMap { server -> server.issues } }
emit(UnableToConnectState(addressCandidatesWithIssues))
}
}
}

View File

@@ -0,0 +1,179 @@
package org.jellyfin.androidtv.auth
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.runBlocking
import org.jellyfin.androidtv.preference.AuthenticationPreferences
import org.jellyfin.androidtv.preference.PreferencesRepository
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.DISABLED
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.LAST_USER
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.SPECIFIC_USER
import org.jellyfin.androidtv.util.sdk.forUser
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
data class Session(
val userId: UUID,
val serverId: UUID,
val accessToken: String,
)
interface SessionRepository {
val currentSession: LiveData<Session?>
val currentSystemSession: LiveData<Session?>
fun restoreDefaultSession()
fun restoreDefaultSystemSession()
suspend fun switchCurrentSession(userId: UUID): Boolean
fun destroyCurrentSession()
}
class SessionRepositoryImpl(
private val authenticationPreferences: AuthenticationPreferences,
private val accountManagerHelper: AccountManagerHelper,
private val apiBinder: ApiBinder,
private val authenticationStore: AuthenticationStore,
private val userApiClient: ApiClient,
private val systemApiClient: ApiClient,
private val preferencesRepository: PreferencesRepository,
private val defaultDeviceInfo: DeviceInfo,
) : SessionRepository {
private val _currentSession = MutableLiveData<Session?>()
override val currentSession: LiveData<Session?> get() = _currentSession
private val _currentSystemSession = MutableLiveData<Session?>()
override val currentSystemSession: LiveData<Session?> get() = _currentSystemSession
override fun restoreDefaultSession() {
Timber.d("Restoring default session")
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return destroyCurrentSession()
val behavior = authenticationPreferences[AuthenticationPreferences.autoLoginUserBehavior]
val userId = authenticationPreferences[AuthenticationPreferences.autoLoginUserId].toUUIDOrNull()
when (behavior) {
DISABLED -> destroyCurrentSession()
LAST_USER -> setCurrentSession(createLastUserSession(), false)
SPECIFIC_USER -> setCurrentSession(createUserSession(userId), false)
}
}
override fun restoreDefaultSystemSession() {
Timber.d("Restoring default system session")
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return setCurrentSession(null, false)
val behavior = authenticationPreferences[AuthenticationPreferences.systemUserBehavior]
val userId = authenticationPreferences[AuthenticationPreferences.systemUserId].toUUIDOrNull()
when (behavior) {
DISABLED -> setCurrentSystemSession(null)
LAST_USER -> setCurrentSystemSession(createLastUserSession())
SPECIFIC_USER -> setCurrentSystemSession(createUserSession(userId))
}
}
override suspend fun switchCurrentSession(userId: UUID): Boolean {
// No change in user - don't switch
if (currentSession.value?.userId == userId) return false
Timber.d("Switching current session to user $userId")
val session = createUserSession(userId)
if (session == null) {
Timber.d("Could not switch to non-existing session for user $userId")
return false
}
return suspendCoroutine { continuation ->
setCurrentSession(session, true) { success ->
continuation.resume(success)
}
}
}
override fun destroyCurrentSession() {
Timber.d("Destroying current session")
_currentSession.value = null
userApiClient.applySession(null)
apiBinder.updateSession(null, userApiClient.deviceInfo)
}
private fun setCurrentSession(session: Session?, includeSystemUser: Boolean, callback: ((Boolean) -> Unit)? = null) {
// No change in session - don't switch
if (session != null && currentSession.value?.userId == session.userId) return
if (session != null) authenticationPreferences[AuthenticationPreferences.lastUserId] = session.userId.toString()
val systemUserBehavior = authenticationPreferences[AuthenticationPreferences.systemUserBehavior]
if (includeSystemUser && systemUserBehavior == LAST_USER) setCurrentSystemSession(session)
// Update session after binding the apiclient settings
val deviceInfo = session?.let { defaultDeviceInfo.forUser(it.userId) } ?: defaultDeviceInfo
apiBinder.updateSession(session, deviceInfo) { success ->
Timber.d("Updating current session. userId=${session?.userId} apiBindingSuccess=${success}")
if (success) {
userApiClient.applySession(session, deviceInfo)
runBlocking { preferencesRepository.onSessionChanged() }
_currentSession.postValue(session)
} else {
userApiClient.applySession(null, deviceInfo)
_currentSession.postValue(null)
}
callback?.invoke(success)
}
}
private fun setCurrentSystemSession(session: Session?) {
// No change in session - don't switch
if (session != null && currentSession.value?.userId == session.userId) return
_currentSystemSession.postValue(session)
systemApiClient.applySession(session)
}
private fun createLastUserSession(): Session? {
val lastUserId = authenticationPreferences[AuthenticationPreferences.lastUserId].toUUIDOrNull()
return createUserSession(lastUserId)
}
private fun createUserSession(userId: UUID?): Session? {
if (userId == null) return null
val account = accountManagerHelper.getAccount(userId)
if (account?.accessToken == null) return null
return Session(
userId = account.id,
serverId = account.server,
accessToken = account.accessToken
)
}
private fun ApiClient.applySession(session: Session?, newDeviceInfo: DeviceInfo = defaultDeviceInfo) {
deviceInfo = newDeviceInfo
if (session == null) {
baseUrl = null
accessToken = null
userId = null
} else {
val server = authenticationStore.getServer(session.serverId)
?: return applySession(null)
baseUrl = server.address
accessToken = session.accessToken
userId = session.userId
}
}
}

View File

@@ -1,48 +0,0 @@
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

@@ -4,7 +4,6 @@ import java.util.UUID
data class AccountManagerAccount(
val id: UUID,
val address: String,
val server: UUID,
val name: String,
val accessToken: String? = null,

View File

@@ -1,6 +0,0 @@
package org.jellyfin.androidtv.auth.model
sealed class AuthenticateMethod
data class AutomaticAuthenticateMethod(val user: User) : AuthenticateMethod()
data class CredentialAuthenticateMethod(val username: String, val password: String = "") : AuthenticateMethod()
data class QuickConnectAuthenticateMethod(val secret: String) : AuthenticateMethod()

View File

@@ -18,8 +18,6 @@ data class AuthenticationStoreServer(
val address: String,
val version: String? = null,
@SerialName("login_disclaimer") val loginDisclaimer: String? = null,
@SerialName("splashscreen_enabled") val splashscreenEnabled: Boolean = false,
@SerialName("setup_completed") val setupCompleted: Boolean = true,
@SerialName("last_used") val lastUsed: Long = Date().time,
@SerialName("last_refreshed") val lastRefreshed: Long = Date().time,
val users: Map<UUID, AuthenticationStoreUser> = emptyMap(),

View File

@@ -1,11 +1,8 @@
package org.jellyfin.androidtv.auth.model
import org.jellyfin.sdk.api.client.exception.ApiClientException
sealed class LoginState
object AuthenticatingState : LoginState()
object RequireSignInState : LoginState()
object ServerUnavailableState : LoginState()
data class ServerVersionNotSupported(val server: Server) : LoginState()
data class ApiClientErrorLoginState(val error: ApiClientException) : LoginState()
object AuthenticatedState : LoginState()

View File

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

View File

@@ -1,6 +1,6 @@
package org.jellyfin.androidtv.auth.model
import org.jellyfin.androidtv.auth.repository.ServerRepository
import org.jellyfin.androidtv.auth.ServerRepository
import org.jellyfin.sdk.model.ServerVersion
import java.util.Date
import java.util.UUID
@@ -14,8 +14,6 @@ data class Server(
var address: String,
val version: String? = null,
val loginDisclaimer: String? = null,
val splashscreenEnabled: Boolean = false,
val setupCompleted: Boolean = true,
var dateLastAccessed: Date = Date(0),
) {
private val serverVersion = version?.let(ServerVersion::fromString)

View File

@@ -13,8 +13,6 @@ sealed class User {
abstract val requirePassword: Boolean
abstract val imageTag: String?
abstract fun withToken(accessToken: String): User
override fun equals(other: Any?) = other is User
&& serverId == other.serverId
&& id == other.id
@@ -37,9 +35,7 @@ data class PrivateUser(
override val requirePassword: Boolean,
override val imageTag: String?,
val lastUsed: Long,
) : User() {
override fun withToken(accessToken: String) = copy(accessToken = accessToken)
}
) : User()
/**
* Represents a user stored server side. Found using the Public User endpoint.
@@ -51,6 +47,4 @@ data class PublicUser(
override val accessToken: String?,
override val requirePassword: Boolean,
override val imageTag: String?,
) : User() {
override fun withToken(accessToken: String) = copy(accessToken = accessToken)
}
) : User()

View File

@@ -1,218 +0,0 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import org.jellyfin.androidtv.auth.model.AccountManagerAccount
import org.jellyfin.androidtv.auth.model.ApiClientErrorLoginState
import org.jellyfin.androidtv.auth.model.AuthenticateMethod
import org.jellyfin.androidtv.auth.model.AuthenticatedState
import org.jellyfin.androidtv.auth.model.AuthenticatingState
import org.jellyfin.androidtv.auth.model.AuthenticationStoreUser
import org.jellyfin.androidtv.auth.model.AutomaticAuthenticateMethod
import org.jellyfin.androidtv.auth.model.CredentialAuthenticateMethod
import org.jellyfin.androidtv.auth.model.LoginState
import org.jellyfin.androidtv.auth.model.PrivateUser
import org.jellyfin.androidtv.auth.model.QuickConnectAuthenticateMethod
import org.jellyfin.androidtv.auth.model.RequireSignInState
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.auth.model.ServerUnavailableState
import org.jellyfin.androidtv.auth.model.ServerVersionNotSupported
import org.jellyfin.androidtv.auth.model.User
import org.jellyfin.androidtv.auth.store.AccountManagerStore
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
import org.jellyfin.androidtv.auth.store.AuthenticationStore
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
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.util.Date
/**
* Repository to manage authentication of the user in the app.
*/
interface AuthenticationRepository {
fun authenticate(server: Server, method: AuthenticateMethod): Flow<LoginState>
fun logout(user: User): Boolean
fun getUserImageUrl(server: Server, user: User): String?
}
class AuthenticationRepositoryImpl(
private val jellyfin: Jellyfin,
private val sessionRepository: SessionRepository,
private val 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)
is QuickConnectAuthenticateMethod -> authenticateQuickConnect(server, method.secret)
}
}
private fun authenticateAutomatic(server: Server, user: User): Flow<LoginState> {
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 account = accountManagerStore.getAccount(server.id, user.id)
// Try login with access token
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)
}
private fun authenticateCredential(server: Server, username: String, password: String) = flow {
val api = jellyfin.createApi(server.address, deviceInfo = defaultDeviceInfo.forUser(username))
val result = try {
val response = api.userApi.authenticateUserByName(username, password)
response.content
} catch (err: TimeoutException) {
Timber.e(err, "Failed to connect to server trying to sign in $username")
emit(ServerUnavailableState)
return@flow
} catch (err: ApiClientException) {
Timber.e(err, "Unable to sign in as $username")
emit(ApiClientErrorLoginState(err))
return@flow
}
emitAll(authenticateAuthenticationResult(server, result))
}
private fun authenticateQuickConnect(server: Server, secret: String) = flow {
val api = jellyfin.createApi(server.address, deviceInfo = defaultDeviceInfo)
val result = try {
val response = api.userApi.authenticateWithQuickConnect(secret)
response.content
} catch (err: TimeoutException) {
Timber.e(err, "Failed to connect to server")
emit(ServerUnavailableState)
return@flow
} catch (err: ApiClientException) {
Timber.e(err, "Unable to sign in with Quick Connect secret")
emit(ApiClientErrorLoginState(err))
return@flow
}
emitAll(authenticateAuthenticationResult(server, result))
}
private fun authenticateAuthenticationResult(server: Server, result: AuthenticationResult) = flow {
val accessToken = result.accessToken ?:return@flow emit(RequireSignInState)
val userInfo = result.user ?: return@flow emit(RequireSignInState)
val user = PrivateUser(
id = userInfo.id,
serverId = server.id,
name = userInfo.name!!,
accessToken = result.accessToken,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag,
lastUsed = Date().time,
)
authenticateFinish(server, userInfo, accessToken)
val success = setActiveSession(user, server)
if (success) emit(AuthenticatedState)
else emit(RequireSignInState)
}
private fun authenticateToken(server: Server, user: User) = flow {
emit(AuthenticatingState)
val success = setActiveSession(user, server)
if (!success) {
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))
}
}
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 = Date().time,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag
) ?: AuthenticationStoreUser(
name = userInfo.name!!,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag
)
authenticationStore.putUser(server.id, userInfo.id, updatedUser)
val accountManagerAccount = AccountManagerAccount(
id = userInfo.id,
address = "${updatedUser.name}@${server.address}",
server = server.id,
name = updatedUser.name,
accessToken = accessToken,
)
accountManagerStore.putAccount(accountManagerAccount)
}
private suspend fun setActiveSession(user: User, server: Server): Boolean {
val authenticated = sessionRepository.switchCurrentSession(server.id, user.id)
if (authenticated) {
// Update last use in store
authenticationStore.getServer(server.id)?.let { storedServer ->
authenticationStore.putServer(server.id, storedServer.copy(lastUsed = Date().time))
}
authenticationStore.getUser(server.id, user.id)?.let { storedUser ->
authenticationStore.putUser(server.id, user.id, storedUser.copy(lastUsed = Date().time))
}
}
return authenticated
}
override fun logout(user: User): Boolean {
val authInfo = accountManagerStore.getAccount(user.serverId, user.id) ?: return false
return accountManagerStore.removeAccount(authInfo)
}
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
)
}
}

View File

@@ -1,243 +0,0 @@
package org.jellyfin.androidtv.auth.repository
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.map
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
import org.jellyfin.androidtv.auth.model.ConnectedState
import org.jellyfin.androidtv.auth.model.ConnectingState
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.auth.model.ServerAdditionState
import org.jellyfin.androidtv.auth.model.UnableToConnectState
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.jellyfin.androidtv.util.sdk.toServer
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.exception.InvalidContentException
import org.jellyfin.sdk.api.client.extensions.brandingApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.api.BrandingOptions
import org.jellyfin.sdk.model.api.ServerDiscoveryInfo
import org.jellyfin.sdk.model.serializer.toUUID
import timber.log.Timber
import java.util.Date
import java.util.UUID
/**
* Repository to maintain servers.
*/
interface ServerRepository {
val storedServers: StateFlow<List<Server>>
val discoveredServers: StateFlow<List<Server>>
suspend fun loadStoredServers()
suspend fun loadDiscoveryServers()
fun addServer(address: String): Flow<ServerAdditionState>
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)
}
}
class ServerRepositoryImpl(
private val jellyfin: Jellyfin,
private val authenticationStore: AuthenticationStore,
) : ServerRepository {
// State
private val _storedServers = MutableStateFlow(emptyList<Server>())
override val storedServers = _storedServers.asStateFlow()
private val _discoveredServers = MutableStateFlow(emptyList<Server>())
override val discoveredServers = _discoveredServers.asStateFlow()
// Loading data
override suspend fun loadStoredServers() {
authenticationStore.getServers()
.map { (id, entry) -> entry.asServer(id) }
.sortedWith(compareByDescending<Server> { it.dateLastAccessed }.thenBy { it.name })
.let { _storedServers.emit(it) }
}
override suspend fun loadDiscoveryServers() {
val servers = mutableListOf<Server>()
jellyfin.discovery
.discoverLocalServers()
.map(ServerDiscoveryInfo::toServer)
.collect { server ->
servers += server
_discoveredServers.emit(servers.toList())
}
}
// Mutating data
override fun addServer(address: String): Flow<ServerAdditionState> = flow {
Timber.d("Adding server %s", address)
emit(ConnectingState(address))
val addressCandidates = jellyfin.discovery.getAddressCandidates(address)
Timber.d("Found ${addressCandidates.size} candidates")
val goodRecommendations = mutableListOf<RecommendedServerInfo>()
val badRecommendations = mutableListOf<RecommendedServerInfo>()
val greatRecommendaton = jellyfin.discovery.getRecommendedServers(addressCandidates).firstOrNull { recommendedServer ->
when (recommendedServer.score) {
RecommendedServerInfoScore.GREAT -> true
RecommendedServerInfoScore.GOOD -> {
goodRecommendations += recommendedServer
false
}
else -> {
badRecommendations += recommendedServer
false
}
}
}
Timber.d(buildString {
append("Recommendations: ")
if (greatRecommendaton == null) append(0)
else append(1)
append(" great, ")
append(goodRecommendations.size)
append(" good, ")
append(badRecommendations.size)
append(" bad")
})
val chosenRecommendation = greatRecommendaton ?: goodRecommendations.firstOrNull()
if (chosenRecommendation != null && chosenRecommendation.systemInfo.isSuccess) {
// Get system info
val systemInfo = chosenRecommendation.systemInfo.getOrThrow()
// Get branding info
val api = jellyfin.createApi(chosenRecommendation.address)
val branding = api.getBrandingOptionsOrDefault()
val id = systemInfo.id!!.toUUID()
val server = authenticationStore.getServer(id)?.copy(
name = systemInfo.serverName ?: "Jellyfin Server",
address = chosenRecommendation.address,
version = systemInfo.version,
loginDisclaimer = branding.loginDisclaimer,
splashscreenEnabled = branding.splashscreenEnabled,
setupCompleted = systemInfo.startupWizardCompleted ?: true,
lastUsed = Date().time
) ?: AuthenticationStoreServer(
name = systemInfo.serverName ?: "Jellyfin Server",
address = chosenRecommendation.address,
version = systemInfo.version,
loginDisclaimer = branding.loginDisclaimer,
splashscreenEnabled = branding.splashscreenEnabled,
setupCompleted = systemInfo.startupWizardCompleted ?: true,
)
authenticationStore.putServer(id, server)
loadStoredServers()
emit(ConnectedState(id, systemInfo))
} else {
// No great or good recommendations, only add bad recommendations
val addressCandidatesWithIssues = (badRecommendations + goodRecommendations)
.groupBy { it.address }
.mapValues { (_, entry) -> entry.flatMap { server -> server.issues } }
emit(UnableToConnectState(addressCandidatesWithIssues))
}
}
override suspend fun getServer(id: UUID): Server? {
val server = authenticationStore.getServer(id) ?: return null
val updatedServer = try {
updateServerInternal(id, server)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to update server")
null
}
return (updatedServer ?: server).asServer(id)
}
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) != null
} catch (err: ApiClientException) {
Timber.e(err, "Unable to update server")
false
}
}
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) return null
val api = jellyfin.createApi(server.address)
// Get login disclaimer
val branding = api.getBrandingOptionsOrDefault()
val systemInfo by api.systemApi.getPublicSystemInfo()
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
}
override suspend fun deleteServer(server: UUID): Boolean {
val success = authenticationStore.removeServer(server)
if (success) loadStoredServers()
return success
}
// Helper functions
private fun AuthenticationStoreServer.asServer(id: UUID) = Server(
id = id,
name = name,
address = address,
version = version,
loginDisclaimer = loginDisclaimer,
splashscreenEnabled = splashscreenEnabled,
setupCompleted = setupCompleted,
dateLastAccessed = Date(lastUsed),
)
/**
* Try to retrieve the branding options. If the response JSON is invalid it will return a default value.
* This makes sure we can still work with older Jellyfin versions.
*/
private suspend fun ApiClient.getBrandingOptionsOrDefault() = try {
brandingApi.getBrandingOptions().content
} catch (exception: InvalidContentException) {
Timber.w(exception, "Invalid branding options response, using default value")
BrandingOptions(
loginDisclaimer = null,
customCss = null,
splashscreenEnabled = false,
)
}
}

View File

@@ -1,71 +0,0 @@
package org.jellyfin.androidtv.auth.repository
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
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.UserDto
import timber.log.Timber
/**
* Repository to maintain users for servers.
* Authentication is done using the [AuthenticationRepository].
*/
interface ServerUserRepository {
//server
fun getStoredServerUsers(server: Server): List<PrivateUser>
suspend fun getPublicServerUsers(server: Server): List<PublicUser>
fun deleteStoredUser(user: PrivateUser)
}
class ServerUserRepositoryImpl(
private val jellyfin: Jellyfin,
private val authenticationStore: AuthenticationStore,
private val accountManagerStore: AccountManagerStore,
) : ServerUserRepository {
override fun getStoredServerUsers(server: Server) = authenticationStore.getUsers(server.id)
?.mapNotNull { (userId, userInfo) ->
val authInfo = accountManagerStore.getAccount(server.id, userId)
PrivateUser(
id = authInfo?.id ?: userId,
serverId = authInfo?.server ?: server.id,
name = userInfo.name,
accessToken = authInfo?.accessToken,
requirePassword = userInfo.requirePassword,
imageTag = userInfo.imageTag,
lastUsed = userInfo.lastUsed,
)
}
?.sortedWith(compareByDescending<PrivateUser> { it.lastUsed }.thenBy { it.name })
.orEmpty()
override suspend fun getPublicServerUsers(server: Server): List<PublicUser> {
// Create a fresh API because the shared one might be authenticated for a different server
val api = jellyfin.createApi(server.address)
return try {
val users by api.userApi.getPublicUsers()
users.mapNotNull(UserDto::toPublicUser)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to retrieve public users")
emptyList()
}
}
override fun deleteStoredUser(user: PrivateUser) {
// Remove user info from store
authenticationStore.removeUser(user.serverId, user.id)
// Remove authentication info from system account manager
accountManagerStore.getAccount(user.serverId, user.id)?.let { accountManagerAccount ->
accountManagerStore.removeAccount(accountManagerAccount)
}
}
}

View File

@@ -1,193 +0,0 @@
package org.jellyfin.androidtv.auth.repository
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 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
import org.jellyfin.androidtv.preference.TelemetryPreferences
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.DISABLED
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.LAST_USER
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.SPECIFIC_USER
import org.jellyfin.androidtv.util.sdk.forUser
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.clientLogApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
data class Session(
val userId: UUID,
val serverId: UUID,
val accessToken: String,
)
enum class SessionRepositoryState {
READY,
RESTORING_SESSION,
SWITCHING_SESSION,
}
interface SessionRepository {
val currentSession: StateFlow<Session?>
val state: StateFlow<SessionRepositoryState>
suspend fun restoreSession()
suspend fun switchCurrentSession(serverId: UUID, 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,
private val defaultDeviceInfo: DeviceInfo,
private val userRepository: UserRepository,
private val serverRepository: ServerRepository,
private val telemetryPreferences: TelemetryPreferences,
) : SessionRepository {
private val currentSessionMutex = Mutex()
private val _currentSession = MutableStateFlow<Session?>(null)
override val currentSession = _currentSession.asStateFlow()
private val _state = MutableStateFlow(SessionRepositoryState.READY)
override val state = _state.asStateFlow()
override suspend fun restoreSession(): Unit = currentSessionMutex.withLock {
Timber.d("Restoring session")
_state.value = SessionRepositoryState.RESTORING_SESSION
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return destroyCurrentSession()
when (authenticationPreferences[AuthenticationPreferences.autoLoginUserBehavior]) {
DISABLED -> destroyCurrentSession()
LAST_USER -> setCurrentSession(createLastUserSession())
SPECIFIC_USER -> {
val serverId = authenticationPreferences[AuthenticationPreferences.autoLoginServerId].toUUIDOrNull()
val userId = authenticationPreferences[AuthenticationPreferences.autoLoginUserId].toUUIDOrNull()
if (serverId != null && userId != null) setCurrentSession(createUserSession(serverId, userId))
}
}
_state.value = SessionRepositoryState.READY
}
override suspend fun switchCurrentSession(serverId: UUID, userId: UUID): Boolean {
// No change in user - don't switch
if (currentSession.value?.userId == userId) return false
_state.value = SessionRepositoryState.SWITCHING_SESSION
Timber.d("Switching current session to user $userId")
val session = createUserSession(serverId, userId)
if (session == null) {
Timber.d("Could not switch to non-existing session for user $userId")
return false
}
val switched = setCurrentSession(session)
_state.value = SessionRepositoryState.READY
return switched
}
override fun destroyCurrentSession() {
Timber.d("Destroying current session")
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 {
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
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
val success = apiBinder.updateSession(session, deviceInfo)
Timber.d("Updating current session. userId=${session?.userId} apiBindingSuccess=${success}")
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
}
} else {
userRepository.updateCurrentUser(null)
}
preferencesRepository.onSessionChanged()
_currentSession.value = session
} else destroyCurrentSession()
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
}
private fun createUserSession(serverId: UUID, userId: UUID): Session? {
val account = accountManagerStore.getAccount(serverId, userId)
if (account?.accessToken == null) return null
return Session(
userId = account.id,
serverId = account.server,
accessToken = account.accessToken
)
}
private fun ApiClient.applySession(session: Session?, newDeviceInfo: DeviceInfo = defaultDeviceInfo) {
deviceInfo = newDeviceInfo
if (session == null) {
baseUrl = null
accessToken = null
userId = null
} else {
val server = authenticationStore.getServer(session.serverId)
?: return applySession(null)
baseUrl = server.address
accessToken = session.accessToken
userId = session.userId
}
}
}

View File

@@ -1,22 +0,0 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.jellyfin.sdk.model.api.UserDto
/**
* Repository to get the current authenticated user.
*/
interface UserRepository {
val currentUser: StateFlow<UserDto?>
fun updateCurrentUser(user: UserDto?)
}
class UserRepositoryImpl : UserRepository {
override val currentUser = MutableStateFlow<UserDto?>(null)
override fun updateCurrentUser(user: UserDto?) {
if (currentUser.value !== user) currentUser.value = user
}
}

View File

@@ -1,79 +0,0 @@
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

@@ -1,43 +0,0 @@
package org.jellyfin.androidtv.auth.store
import android.content.Context
import org.jellyfin.androidtv.auth.model.AuthenticationSortBy
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior
import org.jellyfin.preference.booleanPreference
import org.jellyfin.preference.enumPreference
import org.jellyfin.preference.store.SharedPreferenceStore
import org.jellyfin.preference.stringPreference
class AuthenticationPreferences(context: Context) : SharedPreferenceStore(
sharedPreferences = context.getSharedPreferences("authentication", Context.MODE_PRIVATE)
) {
companion object {
// Preferences
val autoLoginUserBehavior = enumPreference("auto_login_user_behavior", UserSelectBehavior.LAST_USER)
val autoLoginServerId = stringPreference("auto_login_server_id", "")
val autoLoginUserId = stringPreference("auto_login_user_id", "")
val sortBy = enumPreference("sort_by", AuthenticationSortBy.LAST_USE)
val alwaysAuthenticate = booleanPreference("always_authenticate", false)
// Persistent state
val lastServerId = stringPreference("last_server_id", "")
val lastUserId = stringPreference("last_user_id", "")
}
init {
runMigrations {
// v0.15.4 to v0.15.5
migration(toVersion = 2) {
// Unfortunately we cannot migrate the "specific user" login option
// so we'll reset the preference to disabled if it was used
if (it.getString("auto_login_user_behavior", null) === UserSelectBehavior.SPECIFIC_USER.name) {
putString("auto_login_user_id", "")
putString("auto_login_user_behavior", UserSelectBehavior.DISABLED.name)
}
putString("last_user_id", "")
}
}
}
}

View File

@@ -1,31 +0,0 @@
package org.jellyfin.androidtv.auth.ui
import android.content.Intent
import androidx.fragment.app.FragmentActivity
import org.jellyfin.androidtv.auth.repository.SessionRepository
import org.jellyfin.androidtv.auth.repository.UserRepository
import org.jellyfin.androidtv.ui.startup.StartupActivity
import org.koin.android.ext.android.inject
import timber.log.Timber
/**
* Extension function to check authentication. Should be called in [FragmentActivity.onCreate] and
* [FragmentActivity.onResume]. It validates the current session and opens the authentication screen
* when no session is found.
*
* @return whether to proceed creating the activity or not. When `false` is returned the
* [FragmentActivity.finish] function is automatically called.
*/
fun FragmentActivity.validateAuthentication(): Boolean {
val sessionRepository by inject<SessionRepository>()
val userRepository by inject<UserRepository>()
if (sessionRepository.currentSession.value == null || userRepository.currentUser.value == null) {
Timber.w("Activity ${this::class.qualifiedName} started without a session, bouncing to StartupActivity")
startActivity(Intent(this, StartupActivity::class.java))
finish()
return false
}
return true
}

View File

@@ -0,0 +1,12 @@
package org.jellyfin.androidtv.constant;
public enum ChangeTriggerType {
LibraryUpdated,
MoviePlayback,
TvPlayback,
GuideNeedsLoad,
MusicPlayback,
Always,
VideoQueueChange,
FavoriteUpdate
}

View File

@@ -1,12 +0,0 @@
package org.jellyfin.androidtv.constant
enum class ChangeTriggerType {
LibraryUpdated,
MoviePlayback,
TvPlayback,
GuideNeedsLoad,
MusicPlayback,
Always,
VideoQueueChange,
FavoriteUpdate,
}

View File

@@ -1,83 +0,0 @@
package org.jellyfin.androidtv.constant
object Codec {
object Container {
@Suppress("ObjectPropertyName", "ObjectPropertyNaming")
const val `3GP` = "3gp"
const val ASF = "asf"
const val AVI = "avi"
const val DVR_MS = "dvr-ms"
const val M2V = "m2v"
const val M4V = "m4v"
const val MKV = "mkv"
const val MOV = "mov"
const val MP3 = "mp3"
const val MP4 = "mp4"
const val MPEG = "mpeg"
const val MPEGTS = "mpegts"
const val MPG = "mpg"
const val OGM = "ogm"
const val OGV = "ogv"
const val TS = "ts"
const val VOB = "vob"
const val WEBM = "webm"
const val WMV = "wmv"
const val WTV = "wtv"
const val XVID = "xvid"
}
object Audio {
const val AAC = "aac"
const val AAC_LATM = "aac_latm"
const val AC3 = "ac3"
const val ALAC = "alac"
const val APE = "ape"
const val DCA = "dca"
const val DTS = "dts"
const val EAC3 = "eac3"
const val FLAC = "flac"
const val MLP = "mlp"
const val MP2 = "mp2"
const val MP3 = "mp3"
const val MPA = "mpa"
const val OGA = "oga"
const val OGG = "ogg"
const val OPUS = "opus"
const val PCM = "pcm"
const val PCM_ALAW = "pcm_alaw"
const val PCM_MULAW = "pcm_mulaw"
const val PCM_S16LE = "pcm_s16le"
const val PCM_S24LE = "pcm_s24le"
const val TRUEHD = "truehd"
const val WAV = "wav"
const val WEBMA = "webma"
const val WMA = "wma"
const val WMAV2 = "wmav2"
}
object Video {
const val H264 = "h264"
const val HEVC = "hevc"
const val MPEG = "mpeg"
const val MPEG2VIDEO = "mpeg2video"
const val VP8 = "vp8"
const val VP9 = "vp9"
}
object Subtitle {
const val ASS = "ass"
const val DVDSUB = "dvdsub"
const val IDX = "idx"
const val PGS = "pgs"
const val PGSSUB = "pgssub"
const val SMI = "smi"
const val SRT = "srt"
const val SSA = "ssa"
const val SUB = "sub"
const val SUBRIP = "subrip"
const val VTT = "vtt"
const val SMIL = "smil"
const val TTML = "ttml"
const val WEBVTT = "webvtt"
}
}

View File

@@ -0,0 +1,40 @@
package org.jellyfin.androidtv.constant;
public class CodecTypes {
/* Audio Codec Types */
public static final String AAC = "aac";
public static final String AAC_LATM = "aac_latm";
public static final String AC3 = "ac3";
public static final String ALAC = "alac";
public static final String APE = "ape";
public static final String DCA = "dca";
public static final String DTS = "dts";
public static final String EAC3 = "eac3";
public static final String FLAC = "flac";
public static final String MLP = "mlp";
public static final String MP2 = "mp2";
public static final String MP3 = "mp3";
public static final String MPA = "mpa";
public static final String OPUS = "opus";
public static final String PCM = "pcm";
public static final String PCM_ALAW = "pcm_alaw";
public static final String PCM_MULAW = "pcm_mulaw";
public static final String PCM_S16LE = "pcm_s16le";
public static final String PCM_S24LE = "pcm_s24le";
public static final String TRUEHD = "truehd";
public static final String VORBIS = "vorbis";
public static final String WAV = "wav";
public static final String WMA = "wma";
public static final String WMAV2 = "wmav2";
/* Video Codec Types */
public static final String AVC = "avc";
public static final String H264 = "h264";
public static final String H265 = "h265";
public static final String HEVC = "hevc";
public static final String MPEG2 = "mpeg2";
public static final String MPEG2VIDEO = "mpeg2video";
public static final String MPEG4 = "mpeg4";
public static final String VP8 = "vp8";
public static final String VP9 = "vp9";
}

View File

@@ -0,0 +1,29 @@
package org.jellyfin.androidtv.constant;
public class ContainerTypes {
public static final String _3GP = "3gp";
public static final String _3GPP = "3gpp";
public static final String ASF = "asf";
public static final String AVI = "avi";
public static final String DVR_MS = "dvr-ms";
public static final String M2TS = "m2ts";
public static final String M2V = "m2v";
public static final String M4V = "m4v";
public static final String MKV = "mkv";
public static final String MOV = "mov";
public static final String MP4 = "mp4";
public static final String MPEG = "mpeg";
public static final String MPEGTS = "mpegts";
public static final String MPG = "mpg";
public static final String OGA = "oga";
public static final String OGG = "ogg";
public static final String OGM = "ogm";
public static final String OGV = "ogv";
public static final String TS = "ts";
public static final String VOB = "vob";
public static final String WEBM = "webm";
public static final String WEBMA = "webma";
public static final String WMV = "wmv";
public static final String WTV = "wtv";
public static final String XVID = "xvid";
}

View File

@@ -0,0 +1,6 @@
package org.jellyfin.androidtv.constant;
public enum CustomMessage {
RefreshCurrentItem,
ActionComplete,
}

View File

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

View File

@@ -1,8 +1,8 @@
package org.jellyfin.androidtv.constant
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.preference.PreferenceEnum
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
import org.jellyfin.preference.PreferenceEnum
/**
* All possible homesections, "synced" with jellyfin-web.

View File

@@ -5,10 +5,10 @@ import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
enum class ImageType {
/**
* Poster.
* Default.
*/
@EnumDisplayOptions(R.string.image_type_poster)
POSTER,
@EnumDisplayOptions(R.string.image_type_default)
DEFAULT,
/**
* Thumbnail.

View File

@@ -1,8 +0,0 @@
package org.jellyfin.androidtv.constant
object LiveTvOption {
const val LIVE_TV_GUIDE_OPTION_ID = 1000
const val LIVE_TV_RECORDINGS_OPTION_ID = 2000
const val LIVE_TV_SCHEDULE_OPTION_ID = 4000
const val LIVE_TV_SERIES_OPTION_ID = 5000
}

View File

@@ -0,0 +1,5 @@
package org.jellyfin.androidtv.constant;
public class MediaTypes {
public static final String HLS = "hls";
}

View File

@@ -5,10 +5,11 @@ import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
enum class PosterSize {
/**
* Smallest.
* Automatic.
*/
@EnumDisplayOptions(R.string.image_size_smallest)
SMALLEST,
@EnumDisplayOptions(R.string.image_size_auto)
AUTO,
/**
* Small.
*/
@@ -25,11 +26,5 @@ enum class PosterSize {
* Large.
*/
@EnumDisplayOptions(R.string.image_size_large)
LARGE,
/**
* Extra Large.
*/
@EnumDisplayOptions(R.string.image_size_xlarge)
X_LARGE
LARGE
}

View File

@@ -1,27 +0,0 @@
package org.jellyfin.androidtv.constant
import android.content.Context
import org.jellyfin.androidtv.R
@Suppress("MagicNumber")
private val qualityOptions = setOf(
0.0, // auto
120.0, 110.0, 100.0, // 100 >=
90.0, 80.0, 70.0, 60.0, 50.0, 40.0, 30.0, 20.0, 15.0, 10.0, // 10 >=
5.0, 3.0, 2.0, 1.0, // 1 >=
0.72, 0.42 // 0 >=
)
@Suppress("MagicNumber")
fun getQualityProfiles(
context: Context
): Map<String, String> = qualityOptions.associate {
val value = when {
it == 0.0 -> context.getString(R.string.bitrate_auto)
it >= 1.0 -> context.getString(R.string.bitrate_mbit, it)
else -> context.getString(R.string.bitrate_kbit, it * 1000.0)
}
it.toString().removeSuffix(".0") to value
}

View File

@@ -0,0 +1,28 @@
package org.jellyfin.androidtv.constant;
public enum QueryType {
Items,
NextUp,
Views,
Season,
Upcoming,
SimilarSeries,
SimilarMovies,
StaticPeople,
StaticChapters,
Search,
Specials,
Trailers,
LiveTvChannel,
LiveTvProgram,
LiveTvRecording,
LiveTvRecordingGroup,
StaticItems,
Persons,
StaticAudioQueueItems,
AlbumArtists,
AudioPlaylists,
LatestItems,
SeriesTimer,
Premieres
}

View File

@@ -1,28 +0,0 @@
package org.jellyfin.androidtv.constant
enum class QueryType {
Items,
NextUp,
Views,
Season,
Upcoming,
SimilarSeries,
SimilarMovies,
StaticPeople,
StaticChapters,
Search,
Specials,
AdditionalParts,
Trailers,
LiveTvChannel,
LiveTvProgram,
LiveTvRecording,
StaticItems,
Persons,
StaticAudioQueueItems,
AlbumArtists,
AudioPlaylists,
LatestItems,
SeriesTimer,
Premieres,
}

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,31 +0,0 @@
package org.jellyfin.androidtv.data.compat
import org.jellyfin.apiclient.model.dlna.DeviceProfile
import org.jellyfin.apiclient.model.dlna.EncodingContext
import org.jellyfin.sdk.model.api.MediaSourceInfo
open class AudioOptions {
var enableDirectPlay = true
var enableDirectStream = true
var itemId: String? = 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
/**
* The application's configured quality setting
*/
var maxBitrate: Int? = null
var context = EncodingContext.Streaming
}

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.apiclient.model.dlna.PlaybackErrorCode
class PlaybackException : RuntimeException() {
var errorCode = PlaybackErrorCode.NotAllowed
}

View File

@@ -6,11 +6,12 @@ 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(org.jellyfin.sdk.model.api.MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod)
public static SubtitleProfile GetSubtitleProfile(MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod)
{
if (playMethod != PlayMethod.Transcode && !subtitleStream.isExternal())
if (playMethod != PlayMethod.Transcode && !subtitleStream.getIsExternal())
{
// Look for supported embedded subs
for (SubtitleProfile profile : subtitleProfiles)
@@ -25,7 +26,7 @@ public class StreamBuilder
continue;
}
if (subtitleStream.isTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat()) && Utils.equalsIgnoreCase(profile.getFormat(), subtitleStream.getCodec()))
if (subtitleStream.getIsTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat()) && Utils.equalsIgnoreCase(profile.getFormat(), subtitleStream.getCodec()))
{
return profile;
}
@@ -36,12 +37,12 @@ public class StreamBuilder
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);
SubtitleProfile tempVar2 = GetExternalSubtitleProfile(subtitleStream, subtitleProfiles, playMethod, false);
SubtitleProfile tempVar3 = GetExternalSubtitleProfile(subtitleStream, subtitleProfiles, playMethod, true);
return (tempVar2 != null) ? tempVar2 : (tempVar3 != null) ? tempVar3 : tempVar;
}
private static SubtitleProfile getExternalSubtitleProfile(org.jellyfin.sdk.model.api.MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod, boolean allowConversion)
private static SubtitleProfile GetExternalSubtitleProfile(MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod, boolean allowConversion)
{
for (SubtitleProfile profile : subtitleProfiles)
{
@@ -60,7 +61,7 @@ public class StreamBuilder
continue;
}
if ((profile.getMethod() == SubtitleDeliveryMethod.External && subtitleStream.isTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat())) || (profile.getMethod() == SubtitleDeliveryMethod.Hls && subtitleStream.isTextSubtitleStream()))
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());
@@ -74,7 +75,7 @@ public class StreamBuilder
continue;
}
if (subtitleStream.isTextSubtitleStream() && subtitleStream.getSupportsExternalStream() && supportsSubtitleConversionTo(subtitleStream, profile.getFormat()))
if (subtitleStream.getIsTextSubtitleStream() && subtitleStream.getSupportsExternalStream() && subtitleStream.SupportsSubtitleConversionTo(profile.getFormat()))
{
return profile;
}
@@ -83,15 +84,4 @@ public class StreamBuilder
return null;
}
public static boolean supportsSubtitleConversionTo(org.jellyfin.sdk.model.api.MediaStream mediaStream, String codec)
{
if (!mediaStream.isTextSubtitleStream())
{
return false;
}
// Can't convert from this
return !("ass".equalsIgnoreCase(mediaStream.getCodec()) || "ssa".equalsIgnoreCase(mediaStream.getCodec()) || "ass".equalsIgnoreCase(codec) || "ssa".equalsIgnoreCase(codec));
}
}

View File

@@ -7,15 +7,21 @@ 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 org.jellyfin.sdk.model.api.MediaProtocol;
import org.jellyfin.sdk.model.api.MediaSourceInfo;
import org.jellyfin.sdk.model.api.MediaStream;
import org.jellyfin.sdk.model.api.MediaStreamType;
import java.util.ArrayList;
/**
* Class StreamInfo.
*
* @deprecated
*/
@Deprecated
public class StreamInfo {
private static final String START_TIME_TICKS = "StartTimeTicks";
private static final String SUBTITLE_STREAM_INDEX = "SubtitleStreamIndex";
@@ -175,7 +181,7 @@ public class StreamInfo {
return getPlayMethod() == PlayMethod.DirectStream || getPlayMethod() == PlayMethod.DirectPlay;
}
public final String toUrl(String baseUrl, String accessToken) {
public final String ToUrl(String baseUrl, String accessToken) {
if (!Utils.isEmpty(getMediaUrl())) {
return getMediaUrl();
}
@@ -189,7 +195,7 @@ public class StreamInfo {
}
ArrayList<String> list = new ArrayList<String>();
for (NameValuePair pair : buildParams(this, accessToken, false)) {
for (NameValuePair pair : BuildParams(this, accessToken, false)) {
if (Utils.isEmpty(pair.getValue())) {
continue;
}
@@ -210,10 +216,10 @@ public class StreamInfo {
String queryString = Utils.join("&", list);
return getUrl(baseUrl, queryString);
return GetUrl(baseUrl, queryString);
}
private String getUrl(String baseUrl, String queryString) {
private String GetUrl(String baseUrl, String queryString) {
if (Utils.isEmpty(baseUrl)) {
throw new IllegalArgumentException(baseUrl);
}
@@ -230,7 +236,7 @@ public class StreamInfo {
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) {
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());
@@ -287,22 +293,20 @@ public class StreamInfo {
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, String baseUrl, String accessToken) {
return GetSubtitleProfiles(includeSelectedTrackOnly, false, baseUrl, accessToken);
}
public final ArrayList<SubtitleStreamInfo> getSubtitleProfiles(boolean includeSelectedTrackOnly, boolean enableAllProfiles, String baseUrl, String accessToken) {
public final ArrayList<SubtitleStreamInfo> GetSubtitleProfiles(boolean includeSelectedTrackOnly, boolean enableAllProfiles, String baseUrl, String accessToken) {
ArrayList<SubtitleStreamInfo> list = new ArrayList<SubtitleStreamInfo>();
// HLS will preserve timestamps so we can just grab the full subtitle stream
long startPositionTicks = getPlayMethod() == PlayMethod.Transcode ? getStartPositionTicks() : 0;
if (!includeSelectedTrackOnly) {
if (getMediaSource() == null) return list;
for (org.jellyfin.sdk.model.api.MediaStream stream : getMediaSource().getMediaStreams()) {
if (stream.getType() == org.jellyfin.sdk.model.api.MediaStreamType.SUBTITLE) {
addSubtitleProfiles(list, stream, enableAllProfiles, baseUrl, accessToken, startPositionTicks);
for (MediaStream stream : getMediaSource().getMediaStreams()) {
if (stream.getType() == MediaStreamType.Subtitle) {
AddSubtitleProfiles(list, stream, enableAllProfiles, baseUrl, accessToken, startPositionTicks);
}
}
}
@@ -310,22 +314,22 @@ public class StreamInfo {
return list;
}
private void addSubtitleProfiles(ArrayList<SubtitleStreamInfo> list, org.jellyfin.sdk.model.api.MediaStream stream, boolean enableAllProfiles, String baseUrl, String accessToken, long startPositionTicks) {
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});
SubtitleStreamInfo info = GetSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, new SubtitleProfile[]{profile});
list.add(info);
}
} else {
SubtitleStreamInfo info = getSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, getDeviceProfile().getSubtitleProfiles());
SubtitleStreamInfo info = GetSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, getDeviceProfile().getSubtitleProfiles());
list.add(info);
}
}
private SubtitleStreamInfo getSubtitleStreamInfo(org.jellyfin.sdk.model.api.MediaStream stream, String baseUrl, String accessToken, long startPositionTicks, SubtitleProfile[] subtitleProfiles) {
SubtitleProfile subtitleProfile = StreamBuilder.getSubtitleProfile(stream, subtitleProfiles, getPlayMethod());
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");
@@ -336,7 +340,7 @@ public class StreamInfo {
SubtitleStreamInfo info = tempVar;
if (info.getDeliveryMethod() == SubtitleDeliveryMethod.External) {
if (getMediaSource().getProtocol() == MediaProtocol.FILE || !stream.getCodec().equalsIgnoreCase(subtitleProfile.getFormat())) {
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)) {
@@ -350,14 +354,14 @@ public class StreamInfo {
return info;
}
public final ArrayList<MediaStream> getSelectableAudioStreams() {
return getSelectableStreams(MediaStreamType.AUDIO);
public final ArrayList<MediaStream> GetSelectableAudioStreams() {
return GetSelectableStreams(MediaStreamType.Audio);
}
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 ArrayList<MediaStream> GetSelectableStreams(MediaStreamType type) {
ArrayList<MediaStream> list = new ArrayList<MediaStream>();
for (org.jellyfin.sdk.model.api.MediaStream stream : getMediaSource().getMediaStreams()) {
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

@@ -1,12 +0,0 @@
package org.jellyfin.androidtv.data.compat
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod
class SubtitleStreamInfo {
var url: String? = null
var name: String? = null
var format: String? = null
var displayTitle: String? = null
var index = 0
var deliveryMethod = SubtitleDeliveryMethod.Encode
}

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,186 +0,0 @@
package org.jellyfin.androidtv.data.eventhandling
import android.content.Context
import android.widget.Toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
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.util.apiclient.PlaybackHelper
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.sessionApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.sockets.SocketInstance
import org.jellyfin.sdk.api.sockets.SocketInstanceState
import org.jellyfin.sdk.api.sockets.addGeneralCommandsListener
import org.jellyfin.sdk.api.sockets.addListener
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.GeneralCommandType
import org.jellyfin.sdk.model.api.LibraryUpdateInfo
import org.jellyfin.sdk.model.api.PlaystateCommand
import org.jellyfin.sdk.model.constant.MediaType
import org.jellyfin.sdk.model.extensions.getValue
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.jellyfin.sdk.model.socket.LibraryChangedMessage
import org.jellyfin.sdk.model.socket.PlayMessage
import org.jellyfin.sdk.model.socket.PlayStateMessage
import timber.log.Timber
import java.util.UUID
class SocketHandler(
private val context: Context,
private val api: ApiClient,
private val socketInstance: SocketInstance,
private val dataRefreshService: DataRefreshService,
private val mediaManager: MediaManager,
private val playbackControllerContainer: PlaybackControllerContainer,
private val navigationRepository: NavigationRepository,
) {
private val coroutineScope = CoroutineScope(Dispatchers.IO)
val state = socketInstance.state
suspend fun updateSession() {
try {
api.sessionApi.postCapabilities(
playableMediaTypes = listOf(MediaType.Video, MediaType.Audio),
supportsMediaControl = true,
supportedCommands = listOf(
GeneralCommandType.DISPLAY_MESSAGE,
GeneralCommandType.SEND_STRING,
GeneralCommandType.DISPLAY_CONTENT,
),
)
} 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()
}
init {
socketInstance.apply {
// Library
addListener<LibraryChangedMessage> { message -> onLibraryChanged(message.info) }
// Media playback
addListener<PlayMessage> { message -> onPlayMessage(message) }
addListener<PlayStateMessage> { message -> onPlayStateMessage(message) }
// General commands
addGeneralCommandsListener(setOf(GeneralCommandType.DISPLAY_CONTENT)) { message ->
val itemId by message
val itemType by message
val itemUuid = itemId?.toUUIDOrNull()
val itemKind = itemType?.let { type -> BaseItemKind.values().find { value -> value.serialName.equals(type, true) } }
if (itemUuid != null && itemKind != null) onDisplayContent(itemUuid, itemKind)
}
addGeneralCommandsListener(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING)) { message ->
val header by message
val text by message
val string by message
onDisplayMessage(header, text ?: string)
}
}
}
private fun onLibraryChanged(info: LibraryUpdateInfo) {
Timber.d(buildString {
appendLine("Library changed.")
appendLine("Added ${info.itemsAdded.size} items")
appendLine("Removed ${info.itemsRemoved.size} items")
appendLine("Updated ${info.itemsUpdated.size} items")
})
if (info.itemsAdded.any() || info.itemsRemoved.any())
dataRefreshService.lastLibraryChange = System.currentTimeMillis()
}
private fun onPlayMessage(message: PlayMessage) {
val itemId = message.request.itemIds?.firstOrNull() ?: return
PlaybackHelper.retrieveAndPlay(
itemId.toString(),
false,
message.request.startPositionTicks,
context
)
}
@Suppress("ComplexMethod")
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 fun onDisplayContent(itemId: UUID, itemKind: BaseItemKind) {
val playbackController = playbackControllerContainer.playbackController
if (playbackController?.isPlaying == true || playbackController?.isPaused == true) {
Timber.i("Not launching $itemId: playback in progress")
return
}
Timber.i("Launching $itemId")
when (itemKind) {
BaseItemKind.USER_VIEW,
BaseItemKind.COLLECTION_FOLDER -> coroutineScope.launch {
val item by api.userLibraryApi.getItem(itemId = itemId)
ItemLauncher.launchUserView(item)
}
else -> navigationRepository.navigate(Destinations.itemDetails(itemId))
}
}
private fun onDisplayMessage(header: String?, text: String?) {
val toastMessage = buildString {
if (!header.isNullOrBlank()) append(header, ": ")
append(text)
}
runBlocking(Dispatchers.Main) {
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show()
}
}
companion object {
const val TICKS_TO_MS = 10000L
const val REWIND_MS = -11000
const val FORWARD_MS = 30000
}
}

View File

@@ -0,0 +1,218 @@
package org.jellyfin.androidtv.data.eventhandling;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.os.Handler;
import android.os.Looper;
import android.widget.Toast;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.data.model.DataRefreshService;
import org.jellyfin.androidtv.data.querying.StdItemQuery;
import org.jellyfin.androidtv.ui.itemhandling.BaseRowItem;
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher;
import org.jellyfin.androidtv.ui.playback.MediaManager;
import org.jellyfin.androidtv.ui.playback.PlaybackController;
import org.jellyfin.androidtv.ui.playback.PlaybackLauncher;
import org.jellyfin.androidtv.ui.playback.PlaybackOverlayActivity;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.PlaybackHelper;
import org.jellyfin.apiclient.interaction.ApiClient;
import org.jellyfin.apiclient.interaction.ApiEventListener;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.dto.BaseItemDto;
import org.jellyfin.apiclient.model.entities.LibraryUpdateInfo;
import org.jellyfin.apiclient.model.querying.ItemFields;
import org.jellyfin.apiclient.model.querying.ItemsResult;
import org.jellyfin.apiclient.model.session.BrowseRequest;
import org.jellyfin.apiclient.model.session.MessageCommand;
import org.jellyfin.apiclient.model.session.PlayRequest;
import org.jellyfin.apiclient.model.session.PlaystateRequest;
import org.jellyfin.apiclient.model.session.SessionInfoDto;
import org.koin.java.KoinJavaComponent;
import java.util.Arrays;
import timber.log.Timber;
public class TvApiEventListener extends ApiEventListener {
private final DataRefreshService dataRefreshService;
private final MediaManager mediaManager;
private final Handler mainThreadHandler;
public TvApiEventListener(DataRefreshService dataRefreshService, MediaManager mediaManager) {
this.dataRefreshService = dataRefreshService;
this.mediaManager = mediaManager;
//handler to interact with the players (exoplayer doesn't allow access from another thread)
mainThreadHandler = new Handler(Looper.getMainLooper());
}
@Override
public void onPlaybackStopped(ApiClient client, SessionInfoDto info) {
TvApp app = TvApp.getApplication();
Timber.d("Got Playback stopped message from server");
if (info.getUserId().equals(app.getCurrentUser().getId())) {
dataRefreshService.setLastPlayback(System.currentTimeMillis());
if (info.getNowPlayingItem() == null) return;
switch (info.getNowPlayingItem().getType()) {
case "Movie":
dataRefreshService.setLastMoviePlayback(System.currentTimeMillis());
break;
case "Episode":
dataRefreshService.setLastTvPlayback(System.currentTimeMillis());
break;
}
}
}
@Override
public void onLibraryChanged(ApiClient client, LibraryUpdateInfo info) {
Timber.d("Library Changed. Added %o items. Removed %o items. Changed %o items.", info.getItemsAdded().size(), info.getItemsRemoved().size(), info.getItemsUpdated().size());
if (info.getItemsAdded().size() > 0 || info.getItemsRemoved().size() > 0)
dataRefreshService.setLastLibraryChange(System.currentTimeMillis());
}
@Override
public void onPlaystateCommand(ApiClient client, PlaystateRequest command) {
PlaybackController playbackController = TvApp.getApplication().getPlaybackController();
Timber.d("caught playstate command: %s", command.getCommand());
switch (command.getCommand()) {
case Stop:
if (mediaManager.getIsAudioPlayerInitialized())
mainThreadHandler.post(() -> mediaManager.stopAudio(true));
else {
Activity currentActivity = TvApp.getApplication().getCurrentActivity();
if(playbackController != null && mediaManager.hasVideoQueueItems() && playbackController.hasInitializedVideoManager())
mainThreadHandler.post(() -> playbackController.endPlayback());
if(currentActivity instanceof PlaybackOverlayActivity)
currentActivity.finish();
}
break;
case Pause:
case Unpause:
case PlayPause:
if (mediaManager.getIsAudioPlayerInitialized())
mainThreadHandler.post(() -> mediaManager.playPauseAudio());
else if(playbackController != null && mediaManager.hasVideoQueueItems() && playbackController.hasInitializedVideoManager())
mainThreadHandler.post(() -> playbackController.playPause());
break;
case NextTrack:
if (mediaManager.getIsAudioPlayerInitialized() && mediaManager.hasAudioQueueItems())
mainThreadHandler.post(() -> mediaManager.nextAudioItem());
else if(playbackController != null && mediaManager.hasVideoQueueItems() && playbackController.hasInitializedVideoManager())
mainThreadHandler.post(() -> playbackController.next());
break;
case PreviousTrack:
if (mediaManager.getIsAudioPlayerInitialized() && mediaManager.hasAudioQueueItems())
mainThreadHandler.post(() -> mediaManager.prevAudioItem());
else if(playbackController != null && mediaManager.hasVideoQueueItems() && playbackController.hasInitializedVideoManager())
mainThreadHandler.post(() -> playbackController.prev());
break;
case Seek:
if(playbackController != null && mediaManager.hasVideoQueueItems() && playbackController.hasInitializedVideoManager()) {
long pos = command.getSeekPositionTicks() / 10000;
mainThreadHandler.post(() -> playbackController.seek(pos));
}
break;
case Rewind:
if (playbackController != null && mediaManager.hasVideoQueueItems() && playbackController.hasInitializedVideoManager())
mainThreadHandler.post(() -> playbackController.skip(-11000));
break;
case FastForward:
if (playbackController != null && mediaManager.hasVideoQueueItems() && playbackController.hasInitializedVideoManager())
mainThreadHandler.post(() -> playbackController.skip(30000));
break;
}
}
@Override
public void onBrowseCommand(ApiClient client, BrowseRequest command) {
Timber.d("Browse command received");
if (Utils.isEmpty(command.getItemId())) return;
mainThreadHandler.post(() -> {
if (TvApp.getApplication().getCurrentActivity() == null ||
(TvApp.getApplication().getPlaybackController() != null && (TvApp.getApplication().getPlaybackController().isPlaying() || TvApp.getApplication().getPlaybackController().isPaused()))) {
Timber.i("Command ignored due to no activity or playback in progress");
return;
}
client.GetItemAsync(command.getItemId(), TvApp.getApplication().getCurrentUser().getId(), new Response<BaseItemDto>() {
@Override
public void onResponse(BaseItemDto response) {
//Create a rowItem and pass to our handler
ItemLauncher.launch(new BaseRowItem(0, response), null, -1, TvApp.getApplication().getCurrentActivity(), true);
}
});
});
}
@Override
public void onPlayCommand(ApiClient client, PlayRequest command) {
mainThreadHandler.post(() -> {
if (TvApp.getApplication().getPlaybackController() != null && (TvApp.getApplication().getPlaybackController().isPlaying() || TvApp.getApplication().getPlaybackController().isPaused())) {
TvApp.getApplication().getCurrentActivity().runOnUiThread(new Runnable() {
@Override
public void run() {
Utils.showToast(TvApp.getApplication().getCurrentActivity(), TvApp.getApplication().getString(R.string.msg_remote_already_playing));
}
});
return;
}
if (command.getItemIds().length > 1) {
Timber.i("Playing multiple items by remote request");
if (TvApp.getApplication().getCurrentActivity() == null) {
Timber.e("No current activity. Cannot play");
return;
}
int startIndex = command.getStartIndex() == null ? 0 : command.getStartIndex().intValue();
int startPosition = command.getStartPositionTicks() == null || command.getStartPositionTicks().longValue() == 0 ? 0 : Long.valueOf(command.getStartPositionTicks() / 10000L).intValue();
Timber.d("got queue start index: %s position %s", startIndex, startPosition);
StdItemQuery query = new StdItemQuery(new ItemFields[]{
ItemFields.MediaSources,
ItemFields.ChildCount
});
query.setIds(command.getItemIds());
KoinJavaComponent.<ApiClient>get(ApiClient.class).GetItemsAsync(query, new Response<ItemsResult>() {
@Override
public void onResponse(ItemsResult response) {
if (response.getItems() != null && response.getItems().length > 0) {
PlaybackLauncher playbackLauncher = KoinJavaComponent.<PlaybackLauncher>get(PlaybackLauncher.class);
if (playbackLauncher.interceptPlayRequest(TvApp.getApplication(), response.getItems()[0])) return;
//peek at first item to see what type it is
switch (response.getItems()[0].getMediaType()) {
case "Video":
Class activity = KoinJavaComponent.<PlaybackLauncher>get(PlaybackLauncher.class).getPlaybackActivityClass(response.getItems()[0].getBaseItemType());
mediaManager.setCurrentVideoQueue(Arrays.asList(response.getItems()));
Intent intent = new Intent(TvApp.getApplication().getCurrentActivity(), activity);
intent.putExtra("Position", startPosition);
TvApp.getApplication().getCurrentActivity().startActivity(intent);
break;
case "Audio":
mediaManager.playNow(TvApp.getApplication().getCurrentActivity(), Arrays.asList(response.getItems()), startIndex, false);
break;
}
}
}
});
} else {
if (command.getItemIds().length > 0) {
Timber.i("Playing single item by remote request");
Context context = TvApp.getApplication().getCurrentActivity() != null ? TvApp.getApplication().getCurrentActivity() : TvApp.getApplication();
PlaybackHelper.retrieveAndPlay(command.getItemIds()[0], false, command.getStartPositionTicks(), context);
}
}
});
}
@Override
public void onMessageCommand(ApiClient client, MessageCommand command) {
new Handler(TvApp.getApplication().getMainLooper()).post(() -> Toast.makeText(TvApp.getApplication(), command.getText(), Toast.LENGTH_LONG).show());
}
}

View File

@@ -1,6 +0,0 @@
package org.jellyfin.androidtv.data.model
data class AppNotification(
val message: String,
val dismiss: () -> Unit,
)

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,10 +0,0 @@
package org.jellyfin.androidtv.data.model
import java.util.UUID
data class ChapterItemInfo(
val itemId: UUID,
val name: String?,
val startPositionTicks: Long,
val imagePath: String?,
)

View File

@@ -1,7 +1,5 @@
package org.jellyfin.androidtv.data.model
import org.jellyfin.sdk.model.api.BaseItemDto
class DataRefreshService {
var lastDeletedItemId: String? = null
var lastPlayback: Long = 0
@@ -11,5 +9,4 @@ class DataRefreshService {
var lastLibraryChange: Long = 0
var lastVideoQueueChange: Long = 0
var lastFavoriteUpdate: Long = 0
var lastPlayedItem: BaseItemDto? = null
}

View File

@@ -0,0 +1,25 @@
package org.jellyfin.androidtv.data.model;
import org.jellyfin.androidtv.ui.itemdetail.FullDetailsActivity;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.dto.BaseItemDto;
import timber.log.Timber;
public class DetailItemLoadResponse extends Response<BaseItemDto> {
private FullDetailsActivity activity;
public DetailItemLoadResponse(FullDetailsActivity activity) {
this.activity = activity;
}
@Override
public void onResponse(BaseItemDto response) {
activity.setBaseItem(response);
}
@Override
public void onError(Exception exception) {
Timber.e(exception, "Error retrieving full object");
}
}

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,18 +0,0 @@
package org.jellyfin.androidtv.data.model
import org.jellyfin.apiclient.model.querying.ItemFilter
class FilterOptions {
var isFavoriteOnly = false
var isUnwatchedOnly = false
val filters: Array<ItemFilter>?
get() {
if (!isUnwatchedOnly && !isFavoriteOnly) return null
return buildList {
if (isFavoriteOnly) add(ItemFilter.IsFavorite)
if (isUnwatchedOnly) add(ItemFilter.IsUnplayed)
}.toTypedArray()
}
}

View File

@@ -0,0 +1,7 @@
package org.jellyfin.androidtv.data.model;
import android.view.View;
public class GotFocusEvent {
public void gotFocus(View v) {}
}

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

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

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

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

View File

@@ -0,0 +1,25 @@
package org.jellyfin.androidtv.data.querying;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.apiclient.model.querying.ItemFields;
import org.jellyfin.apiclient.model.querying.ItemQuery;
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(TvApp.getApplication().getCurrentUser().getId());
setFields(fields);
}
public StdItemQuery() {
this(null);
}
}

View File

@@ -1,26 +0,0 @@
package org.jellyfin.androidtv.data.querying
import org.jellyfin.androidtv.auth.repository.UserRepository
import org.jellyfin.apiclient.model.querying.ItemFields
import org.jellyfin.apiclient.model.querying.ItemQuery
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
class StdItemQuery @JvmOverloads constructor(
fields: Array<ItemFields> = defaultFields
) : ItemQuery(), KoinComponent {
companion object {
val defaultFields = arrayOf(
ItemFields.PrimaryImageAspectRatio,
ItemFields.Overview,
ItemFields.ItemCounts,
ItemFields.DisplayPreferencesId,
ItemFields.ChildCount
)
}
init {
userId = get<UserRepository>().currentUser.value!!.id.toString()
setFields(fields)
}
}

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

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

View File

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

View File

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

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,37 +0,0 @@
package org.jellyfin.androidtv.data.repository
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
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 -> api.userLibraryApi.markFavoriteItem(itemId = item)
else -> api.userLibraryApi.unmarkFavoriteItem(itemId = item)
}
dataRefreshService.lastFavoriteUpdate = System.currentTimeMillis()
return response
}
override suspend fun setPlayed(item: UUID, played: Boolean): UserItemDataDto {
val response by when {
played -> api.playStateApi.markPlayedItem(itemId = item)
else -> api.playStateApi.markUnplayedItem(itemId = item)
}
return response
}
}

View File

@@ -1,63 +0,0 @@
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.data.model.AppNotification
import org.jellyfin.androidtv.preference.SystemPreferences
interface NotificationsRepository {
val notifications: StateFlow<List<AppNotification>>
fun dismissNotification(item: AppNotification)
fun addDefaultNotifications()
}
class NotificationsRepositoryImpl(
private val context: Context,
private val uiModeManager: UiModeManager,
private val systemPreferences: SystemPreferences,
) : 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, dismiss: () -> Unit = {}) {
notifications.value = notifications.value + AppNotification(message, dismiss)
}
private fun addUiModeNotification() {
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 (invalidUiMode && isTouch && !hasHdmiCec) {
addNotification(context.getString(R.string.app_notification_uimode_invalid))
}
}
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
}
}
}
}

View File

@@ -1,24 +1,23 @@
package org.jellyfin.androidtv.data.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import androidx.lifecycle.LiveData
import androidx.lifecycle.liveData
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.constant.CollectionType
interface UserViewsRepository {
val views: Flow<Collection<BaseItemDto>>
val views: LiveData<Collection<BaseItemDto>>
fun isSupported(collectionType: String?): Boolean
fun allowViewSelection(collectionType: String?): Boolean
fun allowGridView(collectionType: String?): Boolean
}
class UserViewsRepositoryImpl(
private val api: ApiClient,
) : UserViewsRepository {
override val views = flow {
override val views: LiveData<Collection<BaseItemDto>> = liveData {
val views by api.userViewsApi.getUserViews()
val filteredViews = views.items
.orEmpty()
@@ -27,24 +26,13 @@ class UserViewsRepositoryImpl(
}
override fun isSupported(collectionType: String?) = collectionType !in unsupportedCollectionTypes
override fun allowViewSelection(collectionType: String?) = collectionType !in disallowViewSelectionCollectionTypes
override fun allowGridView(collectionType: String?) = collectionType !in disallowGridViewCollectionTypes
override fun allowViewSelection(collectionType: String?) = collectionType != CollectionType.Music
private companion object {
private val unsupportedCollectionTypes = arrayOf(
CollectionType.Books,
CollectionType.Games,
CollectionType.Folders
)
private val disallowViewSelectionCollectionTypes = arrayOf(
CollectionType.LiveTv,
CollectionType.Music,
CollectionType.Photos,
)
private val disallowGridViewCollectionTypes = arrayOf(
CollectionType.LiveTv,
CollectionType.Music
)
}
}

View File

@@ -28,23 +28,19 @@ import kotlinx.coroutines.delay
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.sdk.Jellyfin
import org.jellyfin.apiclient.model.dto.BaseItemDto
import org.jellyfin.apiclient.model.search.SearchHint
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.SearchHint
import org.jellyfin.sdk.model.serializer.toUUID
import timber.log.Timber
import java.util.UUID
import java.util.concurrent.ExecutionException
class BackgroundService(
private val context: Context,
private val jellyfin: Jellyfin,
private val api: ApiClient,
private val apiClient: ApiClient,
private val userPreferences: UserPreferences,
) {
companion object {
@@ -129,13 +125,13 @@ class BackgroundService(
}
// Helper function for [setBackground]
private fun List<String>?.getUrls(itemId: UUID?): List<String> {
private fun ArrayList<String>?.getUrls(itemId: String?): List<String> {
// Check for nullability
if (itemId == null || isNullOrEmpty()) return emptyList()
return mapIndexed { index, tag ->
api.imageApi.getItemImageUrl(
itemId = itemId,
apiClient.imageApi.getItemImageUrl(
itemId = itemId.toUUID(),
imageType = ImageType.BACKDROP,
tag = tag,
imageIndex = index,
@@ -169,7 +165,7 @@ class BackgroundService(
// Manually grab the backdrop URL
val backdropUrls = setOfNotNull(searchHint.backdropImageItemId?.let { itemId ->
api.imageApi.getItemImageUrl(
apiClient.imageApi.getItemImageUrl(
itemId = itemId.toUUID(),
imageType = ImageType.BACKDROP,
tag = searchHint.backdropImageTag,
@@ -179,24 +175,6 @@ class BackgroundService(
loadBackgrounds(backdropUrls)
}
/**
* Use splashscreen from [server] as background.
*/
fun setBackground(server: Server) {
// Check if item is set and backgrounds are enabled
if (!userPreferences[UserPreferences.backdropEnabled])
return clearBackgrounds()
// Check if splashscreen is enabled in (cached) branding options
if (!server.splashscreenEnabled)
return clearBackgrounds()
// 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()

View File

@@ -0,0 +1,14 @@
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.jellyfin.androidtv.ui.shared.CurrentActivityCallbacks
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
single { CurrentActivityCallbacks() } bind Application.ActivityLifecycleCallbacks::class
}

View File

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

View File

@@ -1,37 +1,23 @@
package org.jellyfin.androidtv.di
import androidx.work.WorkManager
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.auth.ServerRepository
import org.jellyfin.androidtv.auth.ServerRepositoryImpl
import org.jellyfin.androidtv.data.eventhandling.TvApiEventListener
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.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.ui.navigation.Destinations
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
import org.jellyfin.androidtv.ui.navigation.NavigationRepositoryImpl
import org.jellyfin.androidtv.ui.picture.PictureViewerViewModel
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
import org.jellyfin.androidtv.ui.playback.nextup.NextUpViewModel
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.MarkdownRenderer
import org.jellyfin.androidtv.ui.startup.LoginViewModel
import org.jellyfin.androidtv.util.sdk.legacy
import org.jellyfin.apiclient.AppInfo
import org.jellyfin.apiclient.android
import org.jellyfin.apiclient.logging.AndroidLogger
import org.jellyfin.apiclient.serialization.GsonJsonSerializer
import org.jellyfin.sdk.android.androidDevice
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
@@ -44,6 +30,8 @@ import org.jellyfin.apiclient.Jellyfin as JellyfinApiClient
import org.jellyfin.sdk.Jellyfin as JellyfinSdk
val defaultDeviceInfo = named("defaultDeviceInfo")
val userApiClient = named("userApiClient")
val systemApiClient = named("systemApiClient")
val appModule = module {
// New SDK
@@ -54,23 +42,23 @@ val appModule = module {
// Add client info
clientInfo = ClientInfo("Android TV", BuildConfig.VERSION_NAME)
deviceInfo = get(defaultDeviceInfo)
// Change server version
minimumServerVersion = ServerRepository.minimumServerVersion
// FIXME (SDK 1.2.0) deviceInfo = get(defaultDeviceInfo)
}
}
single {
single(userApiClient) {
// Create an empty API instance, the actual values are set by the SessionRepository
get<JellyfinSdk>().createApi()
}
single { get<ApiClient>().ws() }
single { SocketHandler(get(), get(), get(), get(), get(), get(), get()) }
single(systemApiClient) {
// Create an empty API instance, the actual values are set by the SessionRepository
get<JellyfinSdk>().createApi()
}
// Old apiclient
single { GsonJsonSerializer() }
single {
JellyfinApiClient {
appInfo = AppInfo("Android TV", BuildConfig.VERSION_NAME)
@@ -81,30 +69,23 @@ val appModule = module {
single {
get<JellyfinApiClient>().createApi(
device = get<DeviceInfo>(defaultDeviceInfo).legacy()
device = get<DeviceInfo>(defaultDeviceInfo).legacy(),
eventListener = TvApiEventListener(get(), get())
)
}
// Non API related
single { MediaManager(get()) }
single { MediaManager() }
single { DataRefreshService() }
single { PlaybackControllerContainer() }
single<UserRepository> { UserRepositoryImpl() }
single<UserViewsRepository> { UserViewsRepositoryImpl(get()) }
single<NotificationsRepository> { NotificationsRepositoryImpl(get(), get(), get()) }
single<ItemMutationRepository> { ItemMutationRepositoryImpl(get(), get()) }
single<CustomMessageRepository> { CustomMessageRepositoryImpl() }
single<NavigationRepository> { NavigationRepositoryImpl(Destinations.home) }
factory { WorkManager.getInstance(androidContext()) }
viewModel { StartupViewModel(get(), get(), get(), get()) }
viewModel { UserLoginViewModel(get(), get(), get(), get(defaultDeviceInfo)) }
viewModel { ServerAddViewModel(get()) }
viewModel { NextUpViewModel(get(), get(), get(), get()) }
viewModel { PictureViewerViewModel(get()) }
single<ServerRepository> { ServerRepositoryImpl(get(), get(), get(), get()) }
single<UserViewsRepository> { UserViewsRepositoryImpl(get(userApiClient)) }
single { BackgroundService(get(), get(), get(), get()) }
viewModel { LoginViewModel(get(), get(), get()) }
viewModel { NextUpViewModel(get(), get(), get(), get(), get()) }
single { MarkdownRenderer(get()) }
single { BackgroundService(get(), get(userApiClient), get()) }
}

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