Compare commits

..

39 Commits

Author SHA1 Message Date
Niels van Velzen
b2b60bf78a Add support for retrieving resume items in BaseRowAdapter using the SDK
(cherry picked from commit 1d44c83501)
2023-04-30 16:31:18 +02:00
Niels van Velzen
e2ede5b7be Allow changing the (a)ac3 bitstreaming option on all devices
(cherry picked from commit 1d91af6956)
2023-04-27 19:36:30 +02:00
renovate[bot]
985e4ef9a7 Update dependency org.jellyfin.exoplayer:exoplayer-ffmpeg-extension to v2.18.6+1
(cherry picked from commit 936356f835)
2023-04-27 17:22:46 +02:00
Niels van Velzen
7d5895134b Fix ExoPlayerProfile not always listing SubRip and SRT as external
(cherry picked from commit 991447f797)
2023-04-27 17:22:45 +02:00
Niels van Velzen
dc5bf6b47d Fix ExpandableTextView cutting content
(cherry picked from commit 3d5142c108)
2023-04-27 17:22:43 +02:00
renovate[bot]
147fc4f772 Update dependency org.jellyfin.sdk:jellyfin-core to v1.4.2
(cherry picked from commit 84efc6a693)
2023-04-27 17:22:42 +02:00
Niels van Velzen
ecead3fad8 Fix ExoPlayer extensions not used for music playback (backport) 2023-04-27 15:24:12 +02:00
Niels van Velzen
834bebb03f Add DVB subtitle support in ExoPlayer
(cherry picked from commit 93979e1d38)
2023-04-23 15:40:58 +02:00
Niels van Velzen
a001846da7 Enable PGS subtitles in ExoPlayer
(cherry picked from commit d63ac02525)
2023-04-23 10:58:46 +02:00
Niels van Velzen
5c7bf941ea Fix switching ExoPlayer subtitle track
(cherry picked from commit e903d8c347)
2023-04-23 10:58:44 +02:00
Niels van Velzen
53b92184a4 Fix offsetStreamIndex when selecting last track
(cherry picked from commit f862a17223)
2023-04-23 10:58:43 +02:00
renovate[bot]
1e369480fd Update dependency com.google.android.exoplayer:exoplayer to v2.18.6
(cherry picked from commit 4699454d5d)
2023-04-23 10:58:26 +02:00
Niels van Velzen
e00f6d3f40 Fix app crash when playback stop is requested without an active player
(cherry picked from commit f99451d22e)
2023-04-23 10:55:33 +02:00
Niels van Velzen
5e85a876bd Add F-Droid badge to README
(cherry picked from commit 1b44358f10)
2023-04-03 21:04:43 +02:00
Niels van Velzen
5249209f3a Fix "null" on Live TV guide "load channels" button 2023-04-03 19:32:56 +02:00
Niels van Velzen
276e138d7a Set screenOrientation for PreferencesActivity
(cherry picked from commit fb651b173c)
2023-04-03 19:31:41 +02:00
Niels van Velzen
00cc8f14ec Fix app closing when music queue ends
(cherry picked from commit 25e8738818)
2023-04-03 19:31:36 +02:00
Niels van Velzen
f14a8fa6b8 Store accounts in Account Manager by address
(cherry picked from commit 6806f69c62)
2023-04-03 19:31:23 +02:00
Niels van Velzen
6191044057 Always check server id when retrieving user data from AccountManagerStore
(cherry picked from commit 6712eeb781)
2023-03-12 10:34:04 +01:00
Niels van Velzen
d0365ced7e Fix SearchFragment not restoring properly
(cherry picked from commit 0a30b50904)
2023-03-12 10:34:03 +01:00
Niels van Velzen
9c8143368f Add workaround for non reproducible baseline profiles in AGP
(cherry picked from commit eada9ea21c)
2023-03-12 10:34:03 +01:00
omm
266d5738a6 Add IncludeType param when sending number query
(cherry picked from commit 68f6b3a3ef)
2023-03-12 10:34:03 +01:00
renovate[bot]
e34442a432 Update dependency com.google.android.exoplayer:exoplayer to v2.18.4
(cherry picked from commit 7a5ee0b229)
2023-03-12 10:34:02 +01:00
David Fairbrother
12e4c96304 Accept KEYCODE_ENTER as alt to KEYCODE_CENTER
Adds KEYCODE_ENTER wherever we accept KEYCODE_DPAD_CENTER. This was
reported in #2526 where third-party remotes will use the former instead
of the latter value.

I've added this to all places we accept CENTER, since our most of our
Kotlin code already accepts this as an alternative and I can't forsee a
case where we would want these to accept different things.

This was tested in the emulator to ensure we've not broken the existing
mapping, but short of using the enter key (which works) I can't verify
this fixes the original issue.

(cherry picked from commit b3bc4b0d8c)
2023-03-12 10:33:34 +01:00
Niels van Velzen
857ad84771 Add missing mediaManager injection to EnhancedBrowseFragment 2023-02-26 17:10:48 +01:00
Niels van Velzen
7dfd3b9179 Mark LibVLC as experimental
(cherry picked from commit 74a7898fcd)
2023-02-26 17:08:16 +01:00
Niels van Velzen
63de8d16d2 Fix theme not applied before creating views
(cherry picked from commit 328072a36c)
2023-02-26 16:59:46 +01:00
Niels van Velzen
b771bb4c27 Only attach background service in activities
(cherry picked from commit bbc7f3eea4)
2023-02-26 16:59:42 +01:00
Niels van Velzen
2b01ac9ffc Don't show "invalid credentials" message when authentication fails with an SDK exception
(cherry picked from commit 84dc7fc48d)
2023-02-26 16:59:34 +01:00
renovate[bot]
b857fb4f1c Update dependency com.google.android.exoplayer:exoplayer to v2.18.3
(cherry picked from commit f10cbc05d7)
2023-02-26 16:59:24 +01:00
Timmeh
19707eb9c8 Fix old items are shown when new filter is applied. (#2472)
* clear rows after applying new filter.

* Always set current item to toolbar if current item is filtered out.

(cherry picked from commit 215b0e9f2c)
2023-02-26 16:58:34 +01:00
Niels van Velzen
c60d210e87 Fix rare picture viewer crash when album is empty
(cherry picked from commit e3d5262b5b)
2023-02-26 16:58:16 +01:00
Niels van Velzen
cb046c26c5 Change opening menu to ACTION_UP instead of ACTION_DOWN
(cherry picked from commit 22b4ecf0ec)
2023-01-24 21:23:20 +01:00
Niels van Velzen
3d97b5abb0 Use HEADER_ACCEPT from SDK as Glide Accept header value
(cherry picked from commit a7b06d39e1)
2023-01-24 21:23:18 +01:00
Niels van Velzen
da8c7c2837 Set Accept header in Glide requests
(cherry picked from commit 3839d1c5d1)
2023-01-24 21:23:17 +01:00
renovate[bot]
d9c11d86ad Update dependency org.jellyfin.sdk:jellyfin-core to v1.4.1
(cherry picked from commit 80a2dd5ac1)
2023-01-24 21:23:15 +01:00
Niels van Velzen
b9172845d8 Increase amount of logs added to crash reports
Some stacktraces with Kotlin coroutines can get big. Right now we have 100 lines and this often is not enough to see what happened before the exception. Increasing the count to 250 should hopefully fix that.

(cherry picked from commit 5b83a6de9a)
2023-01-24 21:23:15 +01:00
Niels van Velzen
3d3fa76771 Remove experimental audio codecs from transcoding profile
(cherry picked from commit 3c70b8abf5)
2023-01-10 13:45:37 +01:00
Niels van Velzen
521183d427 Fix BrowseGridFragment focus not restoring when going back
(cherry picked from commit b5b87fb625)
2023-01-10 13:45:36 +01:00
641 changed files with 17894 additions and 25216 deletions

View File

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

View File

@@ -15,20 +15,20 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
- name: Setup Java
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@de1bb2b0c5634f0fc4438d7aa9944e68f9bf86cc # tag=v3
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef # tag=v2
- name: Assemble debug APKs
run: ./gradlew assembleDebug
- name: Create publish bundle
run: mkdir -p build/gh-app-publish/; find app/build/ -iname "*.apk" -exec mv "{}" build/gh-app-publish/ \;
- name: Upload artifacts
uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
uses: actions/upload-artifact@83fd05a356d7e2593de66fc9913b3002723633cb # tag=v3
with:
name: build-artifacts
retention-days: 14

View File

@@ -17,18 +17,18 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
- name: Setup Java
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@de1bb2b0c5634f0fc4438d7aa9944e68f9bf86cc # tag=v3
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef # tag=v2
- name: Run detekt and lint tasks
run: ./gradlew detekt lint
- name: Upload SARIF files
uses: github/codeql-action/upload-sarif@5cf07d8b700b67e235fbb65cbc84f69c0cf10464 # v3.25.14
uses: github/codeql-action/upload-sarif@678fc3afe258fb2e0cdc165ccf77b85719de7b3c # v2
if: ${{ always() }}
with:
sarif_file: .

View File

@@ -12,14 +12,14 @@ jobs:
if: ${{ contains(github.repository_owner, 'jellyfin') }}
steps:
- name: Checkout repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
- name: Setup Java
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@de1bb2b0c5634f0fc4438d7aa9944e68f9bf86cc # tag=v3
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef # tag=v2
- name: Set JELLYFIN_VERSION
run: echo "JELLYFIN_VERSION=$(echo ${GITHUB_REF#refs/tags/v} | tr / -)" >> $GITHUB_ENV
- name: Assemble release files
@@ -27,8 +27,6 @@ jobs:
- name: Sign APK
id: signApk
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # tag=v1
env:
BUILD_TOOLS_VERSION: "34.0.0"
with:
releaseDirectory: app/build/outputs/apk/release
signingKeyBase64: ${{ secrets.KEYSTORE }}
@@ -38,8 +36,6 @@ jobs:
- name: Sign app bundle
id: signAab
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # tag=v1
env:
BUILD_TOOLS_VERSION: "34.0.0"
with:
releaseDirectory: app/build/outputs/bundle/release
signingKeyBase64: ${{ secrets.KEYSTORE }}
@@ -55,7 +51,7 @@ jobs:
mv ${{ steps.signAab.outputs.signedReleaseFile }} build/jellyfin-publish/jellyfin-androidtv-v${{ env.JELLYFIN_VERSION }}-release.aab
mv app/build/version.txt build/jellyfin-publish/
- name: Upload release archive to GitHub release
uses: alexellis/upload-assets@13926a61cdb2cb35f5fdef1c06b8b591523236d3 # 0.4.1
uses: alexellis/upload-assets@259de5111cb56966d046ced998941e93f91d2c93 # tag=0.4.0
env:
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
with:
@@ -65,24 +61,20 @@ jobs:
with:
switches: -vrptz
path: build/jellyfin-publish/
remote_path: /srv/incoming/androidtv/v${{ env.JELLYFIN_VERSION }}/
remote_host: ${{ secrets.REPO_HOST }}
remote_user: ${{ secrets.REPO_USER }}
remote_key: ${{ secrets.REPO_KEY }}
remote_path: /srv/repository/releases/client/androidtv/versions/v${{ env.JELLYFIN_VERSION }}
remote_host: ${{ secrets.DEPLOY_HOST }}
remote_user: ${{ secrets.DEPLOY_USER }}
remote_key: ${{ secrets.DEPLOY_KEY }}
- name: Update repo.jellyfin.org symlinks
uses: appleboy/ssh-action@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 # v1.0.3
uses: appleboy/ssh-action@f9010ff7f1bbd7db1a0b4bab661437550cea20c0 # tag=v0.1.5
with:
host: ${{ secrets.REPO_HOST }}
username: ${{ secrets.REPO_USER }}
key: ${{ secrets.REPO_KEY }}
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
envs: JELLYFIN_VERSION
script_stop: true
script: |
if [ -d "/srv/repository/main/client/androidtv/versions/v${{ env.JELLYFIN_VERSION }}" ] && [ -n "${{ env.JELLYFIN_VERSION }}" ]; then
sudo rm -r /srv/repository/main/client/androidtv/versions/v${{ env.JELLYFIN_VERSION }}
fi
sudo mv /srv/incoming/androidtv/v${{ env.JELLYFIN_VERSION }} /srv/repository/main/client/androidtv/versions/v${{ env.JELLYFIN_VERSION }}
cd /srv/repository/main/client/androidtv;
sudo rm -rf *.apk version.txt;
sudo ln -s versions/v${JELLYFIN_VERSION}/jellyfin-androidtv-v${JELLYFIN_VERSION}-*.apk .;
sudo ln -s versions/v${JELLYFIN_VERSION}/version.txt .;
cd /srv/repository/releases/client/androidtv;
rm -rf *.apk version.txt;
ln -s versions/v${JELLYFIN_VERSION}/jellyfin-androidtv-v${JELLYFIN_VERSION}-*.apk .;
ln -s versions/v${JELLYFIN_VERSION}/version.txt .;

View File

@@ -16,13 +16,13 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
- name: Setup Java
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@de1bb2b0c5634f0fc4438d7aa9944e68f9bf86cc # tag=v3
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef # tag=v2
- name: Run test task
run: ./gradlew test

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

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

View File

@@ -17,6 +17,6 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
uses: gradle/wrapper-validation-action@55e685c48d84285a5b0418cd094606e199cca3b6 # tag=v1

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-22.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
steps:
- uses: eps1lon/actions-label-merge-conflict@1b1b1fcde06a9b3d089f3464c96417961dde1168 # v3.0.2
- uses: eps1lon/actions-label-merge-conflict@fd1f295ee7443d13745804bc49fe158e240f6c6e # tag=v2.1.0
with:
dirtyLabel: merge conflict
repoToken: ${{ secrets.JF_BOT_TOKEN }}

View File

@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-22.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
steps:
- uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
- uses: actions/stale@5ebf00ea0e4c1561e9b43a292ed34424fb1d4578 # tag=v6
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
days-before-stale: 120

1
.gitignore vendored
View File

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

View File

@@ -11,9 +11,6 @@
- [Niels van Velzen](https://github.com/nielsvanvelzen)
- [GodTamIt](https://github.com/GodTamIt)
- [sparky3387](https://github.com/sparky3387)
- [mohd-akram](https://github.com/mohd-akram)
- [3l0w](https://github.com/3l0w)
- [MajMongoose](https://github.com/majmongoose)
# Emby Contributors

View File

@@ -13,9 +13,6 @@
<issue id="MangledCRLF" severity="warning" />
<issue id="NegativeMargin" severity="warning" />
<issue id="NoHardKeywords" severity="warning" />
<issue id="NotificationPermission">
<ignore regexp="com.bumptech.glide.request.target.NotificationTarget" />
</issue>
<issue id="Registered" severity="warning" />
<issue id="RequiredSize" severity="error" />
<issue id="Typos" severity="ignore" />

View File

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

View File

@@ -1,18 +1,21 @@
plugins {
id("com.android.application")
kotlin("android")
alias(libs.plugins.kotlin.ksp)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.aboutlibraries)
}
// Apply workaround
apply("baselineWorkaround.gradle")
android {
namespace = "org.jellyfin.androidtv"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compileSdk = 33
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
minSdk = 21
targetSdk = 33
// Release version
applicationId = namespace
@@ -22,7 +25,6 @@ android {
}
buildFeatures {
buildConfig = true
viewBinding = true
compose = true
}
@@ -31,6 +33,10 @@ android {
isCoreLibraryDesugaringEnabled = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get()
}
buildTypes {
val release by getting {
isMinifyEnabled = false
@@ -80,7 +86,7 @@ aboutLibraries {
}
val versionTxt by tasks.registering {
val path = layout.buildDirectory.asFile.get().resolve("version.txt")
val path = buildDir.resolve("version.txt")
doLast {
val versionString = "v${android.defaultConfig.versionName}=${android.defaultConfig.versionCode}"
@@ -92,9 +98,9 @@ val versionTxt by tasks.registering {
dependencies {
// Jellyfin
implementation(projects.playback.core)
implementation(projects.playback.exoplayer)
implementation(projects.playback.jellyfin)
implementation(projects.playback.media3.exoplayer)
implementation(projects.playback.media3.session)
implementation(projects.playback.ui)
implementation(projects.preference)
implementation(libs.jellyfin.apiclient)
implementation(libs.jellyfin.sdk) {
@@ -115,7 +121,6 @@ dependencies {
implementation(libs.androidx.core)
implementation(libs.androidx.activity)
implementation(libs.androidx.fragment)
implementation(libs.androidx.fragment.compose)
implementation(libs.androidx.leanback.core)
implementation(libs.androidx.leanback.preference)
implementation(libs.androidx.preference)
@@ -134,16 +139,18 @@ dependencies {
implementation(libs.bundles.koin)
// Media players
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.ui)
implementation(libs.jellyfin.androidx.media3.ffmpeg.decoder)
implementation(libs.exoplayer)
implementation(libs.jellyfin.exoplayer.ffmpegextension)
implementation(libs.libvlc)
// Markdown
implementation(libs.bundles.markwon)
// Image utility
implementation(libs.bundles.coil)
implementation(libs.blurhash)
implementation(libs.glide.core)
ksp(libs.glide.ksp)
implementation(libs.kenburnsview)
// Crash Reporting
implementation(libs.bundles.acra)
@@ -155,6 +162,10 @@ dependencies {
implementation(libs.timber)
implementation(libs.slf4j.timber)
// Debugging
if (getProperty("leakcanary.enable")?.toBoolean() == true)
debugImplementation(libs.leakcanary)
// Compatibility (desugaring)
coreLibraryDesugaring(libs.android.desugar)

View File

@@ -6,13 +6,23 @@
<!-- Android TV Integration -->
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<!-- AccountManager permissions -->
<uses-permission
android:name="android.permission.AUTHENTICATE_ACCOUNTS"
android:maxSdkVersion="22" />
<uses-permission
android:name="android.permission.GET_ACCOUNTS"
android:maxSdkVersion="22" />
<uses-permission
android:name="android.permission.MANAGE_ACCOUNTS"
android:maxSdkVersion="22" />
<!-- Generic permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission
android:name="android.permission.ACCESS_WIFI_STATE"
tools:ignore="LeanbackUsesWifi" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Search permissions (voice to text) -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
@@ -28,30 +38,20 @@
android:name="android.hardware.microphone"
android:required="false" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data
android:host="youtube.com"
android:scheme="https" />
</intent>
</queries>
<application
android:name=".JellyfinApplication"
android:allowBackup="true"
android:appCategory="video"
android:banner="@mipmap/app_banner"
android:banner="@drawable/app_banner"
android:dataExtractionRules="@xml/backup_rules"
android:fullBackupContent="@xml/backup_content"
android:icon="@mipmap/app_icon"
android:icon="@drawable/app_icon"
android:label="@string/app_name"
android:largeHeap="true"
android:roundIcon="@drawable/app_icon_round"
android:supportsRtl="true"
android:theme="@style/Theme.Jellyfin"
android:usesCleartextTraffic="true">
<!-- Legacy service required for AccountManagerMigration -->
<service
android:name=".auth.service.AuthenticatorService"
android:exported="false">
@@ -90,6 +90,9 @@
<meta-data
android:name="org.jellyfin.androidtv.SessionInitializer"
android:value="androidx.startup" />
<meta-data
android:name="org.jellyfin.androidtv.ProcessLifecycleInitializer"
android:value="androidx.startup" />
</provider>
<provider
@@ -109,13 +112,11 @@
android:name=".ui.startup.StartupActivity"
android:exported="true"
android:noHistory="true"
android:launchMode="singleTask"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustNothing">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.SEARCH" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
@@ -140,6 +141,19 @@
android:screenOrientation="behind"
android:theme="@style/Theme.Jellyfin.Preferences" />
<!-- Playback related activities -->
<activity
android:name=".ui.playback.PlaybackOverlayActivity"
android:screenOrientation="landscape" />
<activity android:name=".ui.playback.ExternalPlayerActivity" />
<activity
android:name=".ui.playback.nextup.NextUpActivity"
android:noHistory="true"
android:screenOrientation="landscape" />
<!-- Playback Rewrite - Temporary -->
<activity android:name=".ui.playback.rewrite.PlaybackForwardingActivity" />
</application>
</manifest>

View File

@@ -2,11 +2,15 @@ package org.jellyfin.androidtv
import android.app.Application
import android.content.Context
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.work.BackoffPolicy
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.await
import com.bumptech.glide.Glide
import com.vanniktech.blurhash.BlurHash
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -15,6 +19,7 @@ import org.jellyfin.androidtv.data.eventhandling.SocketHandler
import org.jellyfin.androidtv.data.repository.NotificationsRepository
import org.jellyfin.androidtv.integration.LeanbackChannelWorker
import org.jellyfin.androidtv.telemetry.TelemetryService
import org.jellyfin.androidtv.util.AutoBitrate
import org.koin.android.ext.android.inject
import java.util.concurrent.TimeUnit
@@ -35,6 +40,7 @@ class JellyfinApplication : Application() {
*/
suspend fun onSessionStart() = withContext(Dispatchers.IO) {
val workManager by inject<WorkManager>()
val autoBitrate by inject<AutoBitrate>()
val socketListener by inject<SocketHandler>()
// Update background worker
@@ -45,7 +51,7 @@ class JellyfinApplication : Application() {
// Recreate periodic workers
workManager.enqueueUniquePeriodicWork(
LeanbackChannelWorker.PERIODIC_UPDATE_REQUEST_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
ExistingPeriodicWorkPolicy.REPLACE,
PeriodicWorkRequestBuilder<LeanbackChannelWorker>(1, TimeUnit.HOURS)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES)
.build()
@@ -54,6 +60,23 @@ class JellyfinApplication : Application() {
// Update WebSockets
launch { socketListener.updateSession() }
// Detect auto bitrate
// running in a different scope to prevent slow startups
ProcessLifecycleOwner.get().lifecycleScope.launch { autoBitrate.detect() }
}
override fun onLowMemory() {
super.onLowMemory()
BlurHash.clearCache()
Glide.with(this).onLowMemory()
}
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
Glide.with(this).onTrimMemory(level)
}
override fun attachBaseContext(base: Context?) {

View File

@@ -0,0 +1,22 @@
package org.jellyfin.androidtv
import android.content.Context
import android.util.Log
import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.module.AppGlideModule
import com.bumptech.glide.request.RequestOptions
@GlideModule
class JellyfinGlideModule : AppGlideModule() {
override fun applyOptions(context: Context, builder: GlideBuilder): Unit = with(builder) {
setDefaultRequestOptions(
// Set default disk cache strategy
RequestOptions().diskCacheStrategy(DiskCacheStrategy.RESOURCE)
)
// Silence image load errors
setLogLevel(Log.ERROR)
}
}

View File

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

View File

@@ -18,7 +18,7 @@ class SessionInitializer : Initializer<Unit> {
// Restore system session
ProcessLifecycleOwner.get().lifecycleScope.launch {
koin.get<SessionRepository>().restoreSession(destroyOnly = false)
koin.get<SessionRepository>().restoreSession()
}
}

View File

@@ -1,48 +0,0 @@
package org.jellyfin.androidtv.auth
import android.accounts.AccountManager
import android.content.Context
import androidx.core.content.getSystemService
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
class AccountManagerMigration(
context: Context,
) {
private val accountManager = requireNotNull(context.getSystemService<AccountManager>())
fun migrate(
servers: Map<UUID, AuthenticationStoreServer>,
) = servers.mapValues { (serverId, server) ->
Timber.i("Migrating server $serverId (${server.name})")
server.copy(
users = server.users.mapValues { (userId, user) ->
val accessToken = getAccessToken(serverId, userId)
Timber.i("Migrating user $userId (${user.name}): ${if (accessToken != null) "success" else "no token"}")
user.copy(accessToken = accessToken)
}
)
}
@Suppress("MissingPermission")
private fun getAccessToken(serverId: UUID, userId: UUID): String? = runCatching {
accountManager.getAccountsByType(ACCOUNT_TYPE)
.firstOrNull {
val validServerId = accountManager.getUserData(it, ACCOUNT_DATA_SERVER)?.toUUIDOrNull() == serverId
val validUserId = accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == userId
validServerId && validUserId
}
?.let { account -> accountManager.peekAuthToken(account, ACCOUNT_ACCESS_TOKEN_TYPE) }
}.getOrNull()
companion object {
const val ACCOUNT_TYPE = BuildConfig.APPLICATION_ID
const val ACCOUNT_DATA_ID = "$ACCOUNT_TYPE.id"
const val ACCOUNT_DATA_SERVER = "$ACCOUNT_TYPE.server"
const val ACCOUNT_ACCESS_TOKEN_TYPE = "$ACCOUNT_TYPE.access_token"
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,12 +5,12 @@ sealed class QuickConnectState
/**
* State unknown untill first poll completed.
*/
data object UnknownQuickConnectState : QuickConnectState()
object UnknownQuickConnectState : QuickConnectState()
/**
* Server does not have QuickConnect enabled.
*/
data object UnavailableQuickConnectState : QuickConnectState()
object UnavailableQuickConnectState : QuickConnectState()
/**
* Connection is pending.
@@ -20,4 +20,4 @@ data class PendingQuickConnectState(val code: String) : QuickConnectState()
/**
* User connected.
*/
data object ConnectedQuickConnectState : QuickConnectState()
object ConnectedQuickConnectState : QuickConnectState()

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ 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
@@ -19,9 +20,10 @@ import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.auth.model.ServerUnavailableState
import org.jellyfin.androidtv.auth.model.ServerVersionNotSupported
import org.jellyfin.androidtv.auth.model.User
import org.jellyfin.androidtv.auth.store.AccountManagerStore
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.jellyfin.androidtv.util.ImageHelper
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
@@ -33,9 +35,10 @@ import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.api.AuthenticationResult
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.UserDto
import timber.log.Timber
import java.time.Instant
import java.util.Date
/**
* Repository to manage authentication of the user in the app.
@@ -49,6 +52,7 @@ interface AuthenticationRepository {
class AuthenticationRepositoryImpl(
private val jellyfin: Jellyfin,
private val sessionRepository: SessionRepository,
private val accountManagerStore: AccountManagerStore,
private val authenticationStore: AuthenticationStore,
private val userApiClient: ApiClient,
private val authenticationPreferences: AuthenticationPreferences,
@@ -66,14 +70,16 @@ class AuthenticationRepositoryImpl(
}
private fun authenticateAutomatic(server: Server, user: User): Flow<LoginState> {
Timber.i("Authenticating user ${user.id}")
Timber.d("Authenticating user %s", user)
// Automatic logic is disabled when the always authenticate preference is enabled
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return flowOf(RequireSignInState)
val authStoreUser = authenticationStore.getUser(server.id, user.id)
val account = accountManagerStore.getAccount(server.id, user.id)
// Try login with access token
return if (authStoreUser?.accessToken != null) authenticateToken(server, user.withToken(authStoreUser.accessToken))
return if (account?.accessToken != null) authenticateToken(server, user.withToken(account.accessToken))
// Try login without password
else if (!user.requirePassword) authenticateCredential(server, user.name, "")
// Require login
else flowOf(RequireSignInState)
}
@@ -115,25 +121,22 @@ class AuthenticationRepositoryImpl(
}
private fun authenticateAuthenticationResult(server: Server, result: AuthenticationResult) = flow {
val accessToken = result.accessToken ?: return@flow emit(RequireSignInState)
val accessToken = result.accessToken ?:return@flow emit(RequireSignInState)
val userInfo = result.user ?: return@flow emit(RequireSignInState)
val user = PrivateUser(
id = userInfo.id,
serverId = server.id,
name = userInfo.name!!,
accessToken = result.accessToken,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag,
lastUsed = Instant.now().toEpochMilli(),
lastUsed = Date().time,
)
authenticateFinish(server, userInfo, accessToken)
val success = setActiveSession(user, server)
if (success) {
emit(AuthenticatedState)
} else {
Timber.w("Failed to set active session after authenticating")
emit(RequireSignInState)
}
if (success) emit(AuthenticatedState)
else emit(RequireSignInState)
}
private fun authenticateToken(server: Server, user: User) = flow {
@@ -162,15 +165,24 @@ class AuthenticationRepositoryImpl(
val updatedUser = currentUser?.copy(
name = userInfo.name!!,
lastUsed = Instant.now().toEpochMilli(),
imageTag = userInfo.primaryImageTag,
accessToken = accessToken,
lastUsed = Date().time,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag
) ?: AuthenticationStoreUser(
name = userInfo.name!!,
imageTag = userInfo.primaryImageTag,
accessToken = accessToken,
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 {
@@ -179,11 +191,11 @@ class AuthenticationRepositoryImpl(
if (authenticated) {
// Update last use in store
authenticationStore.getServer(server.id)?.let { storedServer ->
authenticationStore.putServer(server.id, storedServer.copy(lastUsed = Instant.now().toEpochMilli()))
authenticationStore.putServer(server.id, storedServer.copy(lastUsed = Date().time))
}
authenticationStore.getUser(server.id, user.id)?.let { storedUser ->
authenticationStore.putUser(server.id, user.id, storedUser.copy(lastUsed = Instant.now().toEpochMilli()))
authenticationStore.putUser(server.id, user.id, storedUser.copy(lastUsed = Date().time))
}
}
@@ -191,19 +203,16 @@ class AuthenticationRepositoryImpl(
}
override fun logout(user: User): Boolean {
val authStoreUser = authenticationStore
.getUser(user.serverId, user.id)
?.copy(accessToken = null)
return if (authStoreUser != null) authenticationStore.putUser(user.serverId, user.id, authStoreUser)
else false
val authInfo = accountManagerStore.getAccount(user.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,
maxHeight = ImageHelper.MAX_PRIMARY_IMAGE_HEIGHT
imageType = ImageType.PRIMARY,
maxHeight = ImageUtils.MAX_PRIMARY_IMAGE_HEIGHT
)
}
}

View File

@@ -4,6 +4,7 @@ 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
@@ -22,12 +23,11 @@ import org.jellyfin.sdk.api.client.extensions.brandingApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.api.BrandingOptions
import org.jellyfin.sdk.model.api.ServerDiscoveryInfo
import org.jellyfin.sdk.model.serializer.toUUID
import timber.log.Timber
import java.time.Instant
import java.util.Date
import java.util.UUID
/**
@@ -47,9 +47,6 @@ interface ServerRepository {
companion object {
val minimumServerVersion = Jellyfin.minimumVersion.copy(build = null)
val recommendedServerVersion = Jellyfin.apiVersion.copy(build = null)
val upcomingMinimumServerVersion = ServerVersion(10, 9, 0)
}
}
@@ -86,12 +83,12 @@ class ServerRepositoryImpl(
// Mutating data
override fun addServer(address: String): Flow<ServerAdditionState> = flow {
Timber.i("Adding server %s", address)
Timber.d("Adding server %s", address)
emit(ConnectingState(address))
val addressCandidates = jellyfin.discovery.getAddressCandidates(address)
Timber.i("Found ${addressCandidates.size} candidates")
Timber.d("Found ${addressCandidates.size} candidates")
val goodRecommendations = mutableListOf<RecommendedServerInfo>()
val badRecommendations = mutableListOf<RecommendedServerInfo>()
@@ -138,7 +135,7 @@ class ServerRepositoryImpl(
loginDisclaimer = branding.loginDisclaimer,
splashscreenEnabled = branding.splashscreenEnabled,
setupCompleted = systemInfo.startupWizardCompleted ?: true,
lastUsed = Instant.now().toEpochMilli()
lastUsed = Date().time
) ?: AuthenticationStoreServer(
name = systemInfo.serverName ?: "Jellyfin Server",
address = chosenRecommendation.address,
@@ -188,7 +185,7 @@ class ServerRepositoryImpl(
}
private suspend fun updateServerInternal(id: UUID, server: AuthenticationStoreServer): AuthenticationStoreServer? {
val now = Instant.now().toEpochMilli()
val now = Date().time
// Only update every 10 minutes
if (now - server.lastRefreshed < 600000 && server.version != null) return null
@@ -226,7 +223,7 @@ class ServerRepositoryImpl(
loginDisclaimer = loginDisclaimer,
splashscreenEnabled = splashscreenEnabled,
setupCompleted = setupCompleted,
dateLastAccessed = Instant.ofEpochMilli(lastUsed),
dateLastAccessed = Date(lastUsed),
)
/**

View File

@@ -3,6 +3,7 @@ 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
@@ -26,15 +27,17 @@ interface ServerUserRepository {
class ServerUserRepositoryImpl(
private val jellyfin: Jellyfin,
private val authenticationStore: AuthenticationStore,
private val accountManagerStore: AccountManagerStore,
) : ServerUserRepository {
override fun getStoredServerUsers(server: Server) = authenticationStore.getUsers(server.id)
?.mapNotNull { (userId, userInfo) ->
val authInfo = authenticationStore.getUser(server.id, userId)
val authInfo = accountManagerStore.getAccount(server.id, userId)
PrivateUser(
id = userId,
serverId = server.id,
id = authInfo?.id ?: userId,
serverId = authInfo?.server ?: server.id,
name = userInfo.name,
accessToken = authInfo?.accessToken,
requirePassword = userInfo.requirePassword,
imageTag = userInfo.imageTag,
lastUsed = userInfo.lastUsed,
)
@@ -59,5 +62,10 @@ class ServerUserRepositoryImpl(
override fun deleteStoredUser(user: PrivateUser) {
// Remove user info from store
authenticationStore.removeUser(user.serverId, user.id)
// Remove authentication info from system account manager
accountManagerStore.getAccount(user.serverId, user.id)?.let { accountManagerAccount ->
accountManagerStore.removeAccount(accountManagerAccount)
}
}
}

View File

@@ -1,13 +1,12 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.auth.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
@@ -41,13 +40,14 @@ interface SessionRepository {
val currentSession: StateFlow<Session?>
val state: StateFlow<SessionRepositoryState>
suspend fun restoreSession(destroyOnly: Boolean)
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,
@@ -63,44 +63,35 @@ class SessionRepositoryImpl(
private val _state = MutableStateFlow(SessionRepositoryState.READY)
override val state = _state.asStateFlow()
override suspend fun restoreSession(destroyOnly: Boolean): Unit = withContext(NonCancellable) {
currentSessionMutex.withLock {
Timber.i("Restoring session")
override suspend fun restoreSession(): Unit = currentSessionMutex.withLock {
Timber.d("Restoring session")
_state.value = SessionRepositoryState.RESTORING_SESSION
_state.value = SessionRepositoryState.RESTORING_SESSION
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return destroyCurrentSession()
val alwaysAuthenticate = authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]
val autoLoginBehavior = authenticationPreferences[AuthenticationPreferences.autoLoginUserBehavior]
when {
alwaysAuthenticate -> destroyCurrentSession()
autoLoginBehavior == DISABLED -> destroyCurrentSession()
autoLoginBehavior == LAST_USER && !destroyOnly -> setCurrentSession(createLastUserSession())
autoLoginBehavior == SPECIFIC_USER && !destroyOnly -> {
val serverId = authenticationPreferences[AuthenticationPreferences.autoLoginServerId].toUUIDOrNull()
val userId = authenticationPreferences[AuthenticationPreferences.autoLoginUserId].toUUIDOrNull()
if (serverId != null && userId != null) setCurrentSession(createUserSession(serverId, userId))
}
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
}
_state.value = SessionRepositoryState.READY
}
override suspend fun switchCurrentSession(serverId: UUID, userId: UUID): Boolean {
// No change in user - don't switch
if (currentSession.value?.userId == userId) {
Timber.d("Current session user is the same as the requested user")
return false
}
if (currentSession.value?.userId == userId) return false
_state.value = SessionRepositoryState.SWITCHING_SESSION
Timber.i("Switching current session to user $userId")
Timber.d("Switching current session to user $userId")
val session = createUserSession(serverId, userId)
if (session == null) {
Timber.w("Could not switch to non-existing session for user $userId")
_state.value = SessionRepositoryState.READY
Timber.d("Could not switch to non-existing session for user $userId")
return false
}
@@ -110,10 +101,11 @@ class SessionRepositoryImpl(
}
override fun destroyCurrentSession() {
Timber.i("Destroying current session")
Timber.d("Destroying current session")
userRepository.updateCurrentUser(null)
_currentSession.value = null
userApiClient.applySession(null)
apiBinder.updateSession(null, userApiClient.deviceInfo)
_state.value = SessionRepositoryState.READY
}
@@ -135,12 +127,17 @@ class SessionRepositoryImpl(
// Update session after binding the apiclient settings
val deviceInfo = session?.let { defaultDeviceInfo.forUser(it.userId) } ?: defaultDeviceInfo
val success = apiBinder.updateSession(session, deviceInfo)
Timber.i("Updating current session. userId=${session?.userId} apiBindingSuccess=${success}")
Timber.d("Updating current session. userId=${session?.userId} apiBindingSuccess=${success}")
if (success) {
val applied = userApiClient.applySession(session, deviceInfo)
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
if (applied && session != null) {
try {
val user by userApiClient.userApi.getCurrentUser()
userRepository.updateCurrentUser(user)
@@ -149,11 +146,6 @@ class SessionRepositoryImpl(
destroyCurrentSession()
return false
}
// Update crash reporting URL
val crashReportUrl = userApiClient.clientLogApi.logFileUrl()
telemetryPreferences[TelemetryPreferences.crashReportUrl] = crashReportUrl
telemetryPreferences[TelemetryPreferences.crashReportToken] = session.accessToken
} else {
userRepository.updateCurrentUser(null)
}
@@ -173,34 +165,29 @@ class SessionRepositoryImpl(
}
private fun createUserSession(serverId: UUID, userId: UUID): Session? {
val account = authenticationStore.getUser(serverId, userId)
val account = accountManagerStore.getAccount(serverId, userId)
if (account?.accessToken == null) return null
return Session(
userId = userId,
serverId = serverId,
userId = account.id,
serverId = account.server,
accessToken = account.accessToken
)
}
private fun ApiClient.applySession(session: Session?, newDeviceInfo: DeviceInfo = defaultDeviceInfo): Boolean {
private fun ApiClient.applySession(session: Session?, newDeviceInfo: DeviceInfo = defaultDeviceInfo) {
deviceInfo = newDeviceInfo
if (session == null) {
update(
baseUrl = null,
accessToken = null,
deviceInfo = newDeviceInfo,
)
baseUrl = null
accessToken = null
userId = null
} else {
val server = authenticationStore.getServer(session.serverId)
?: return false
update(
baseUrl = server.address,
accessToken = session.accessToken,
deviceInfo = newDeviceInfo,
)
?: return applySession(null)
baseUrl = server.address
accessToken = session.accessToken
userId = session.userId
}
return true
}
}

View File

@@ -17,6 +17,6 @@ class UserRepositoryImpl : UserRepository {
override val currentUser = MutableStateFlow<UserDto?>(null)
override fun updateCurrentUser(user: UserDto?) {
currentUser.value = user
if (currentUser.value !== user) currentUser.value = user
}
}

View File

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

View File

@@ -13,7 +13,6 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import org.jellyfin.androidtv.auth.AccountManagerMigration
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
import org.jellyfin.androidtv.auth.model.AuthenticationStoreUser
import org.jellyfin.sdk.model.serializer.UUIDSerializer
@@ -21,14 +20,14 @@ import timber.log.Timber
import java.util.UUID
/**
* Storage for authentication related entities. Stores servers with users inside, including
* access tokens.
* Storage for authentication related entities. Stores servers with users inside. Should be used in
* combination with the [AccountManagerStore] to store access tokens and integrate with the
* operating system.
*
* The data is stored in a JSON file located in the applications data directory.
*/
class AuthenticationStore(
private val context: Context,
private val accountManagerMigration: AccountManagerMigration,
private val context: Context
) {
private val storePath
get() = context.filesDir.resolve("authentication_store.json")
@@ -60,17 +59,10 @@ class AuthenticationStore(
// Check for version
return when (root["version"]?.jsonPrimitive?.intOrNull) {
1 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
// Add access tokens from account manager to stored users and save the migrated data
.let { servers -> accountManagerMigration.migrate(servers) }
.also { servers -> write(servers) }
2 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
null -> {
Timber.e("Authentication Store is corrupt!")
emptyMap()
}
else -> {
Timber.e("Authentication Store is using an unknown version!")
emptyMap()
@@ -78,10 +70,10 @@ class AuthenticationStore(
}
}
private fun write(servers: Map<UUID, AuthenticationStoreServer>): Boolean {
private fun save(): Boolean {
val root = JsonObject(mapOf(
"version" to JsonPrimitive(2),
"servers" to json.encodeToJsonElement(servers)
"version" to JsonPrimitive(1),
"servers" to json.encodeToJsonElement(store)
))
storePath.writeText(json.encodeToString(root))
@@ -89,10 +81,6 @@ class AuthenticationStore(
return true
}
private fun save(): Boolean {
return write(store)
}
fun getServers(): Map<UUID, AuthenticationStoreServer> = store
fun getUsers(server: UUID): Map<UUID, AuthenticationStoreUser>? = getServer(server)?.users

View File

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

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

View File

@@ -7,7 +7,6 @@ object Codec {
const val ASF = "asf"
const val AVI = "avi"
const val DVR_MS = "dvr-ms"
const val HLS = "hls"
const val M2V = "m2v"
const val M4V = "m4v"
const val MKV = "mkv"
@@ -48,10 +47,8 @@ object Codec {
const val PCM_ALAW = "pcm_alaw"
const val PCM_MULAW = "pcm_mulaw"
const val PCM_S16LE = "pcm_s16le"
const val PCM_S20LE = "pcm_s20le"
const val PCM_S24LE = "pcm_s24le"
const val TRUEHD = "truehd"
const val VORBIS = "vorbis"
const val WAV = "wav"
const val WEBMA = "webma"
const val WMA = "wma"
@@ -65,7 +62,6 @@ object Codec {
const val MPEG2VIDEO = "mpeg2video"
const val VP8 = "vp8"
const val VP9 = "vp9"
const val AV1 = "av1"
}
object Subtitle {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,8 +18,8 @@ enum class QueryType {
LiveTvProgram,
LiveTvRecording,
StaticItems,
Persons,
StaticAudioQueueItems,
Artists,
AlbumArtists,
AudioPlaylists,
LatestItems,

View File

@@ -3,12 +3,11 @@ 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
import java.util.UUID
open class AudioOptions {
var enableDirectPlay = true
var enableDirectStream = true
var itemId: UUID? = null
var itemId: String? = null
var mediaSources: List<MediaSourceInfo>? = null
var profile: DeviceProfile? = null

View File

@@ -2,10 +2,12 @@ package org.jellyfin.androidtv.data.compat;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.apiclient.model.dlna.DeviceProfile;
import org.jellyfin.apiclient.model.dlna.DlnaProfileType;
import org.jellyfin.apiclient.model.dlna.EncodingContext;
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod;
import org.jellyfin.apiclient.model.dlna.SubtitleProfile;
import org.jellyfin.apiclient.model.dlna.TranscodeSeekInfo;
import org.jellyfin.apiclient.model.dto.NameValuePair;
import org.jellyfin.apiclient.model.session.PlayMethod;
import org.jellyfin.sdk.model.api.MediaProtocol;
import org.jellyfin.sdk.model.api.MediaSourceInfo;
@@ -13,16 +15,23 @@ import org.jellyfin.sdk.model.api.MediaStream;
import org.jellyfin.sdk.model.api.MediaStreamType;
import java.util.ArrayList;
import java.util.UUID;
public class StreamInfo {
private UUID ItemId;
private static final String START_TIME_TICKS = "StartTimeTicks";
private static final String SUBTITLE_STREAM_INDEX = "SubtitleStreamIndex";
private static final String STATIC = "Static";
public final UUID getItemId() {
public StreamInfo() {
setAudioCodecs(new String[]{});
}
private String ItemId;
public final String getItemId() {
return ItemId;
}
public final void setItemId(UUID value) {
public final void setItemId(String value) {
ItemId = value;
}
@@ -56,6 +65,16 @@ public class StreamInfo {
Context = value;
}
private DlnaProfileType MediaType = DlnaProfileType.values()[0];
public final DlnaProfileType getMediaType() {
return MediaType;
}
public final void setMediaType(DlnaProfileType value) {
MediaType = value;
}
private String Container;
public final String getContainer() {
@@ -76,6 +95,16 @@ public class StreamInfo {
StartPositionTicks = value;
}
private String[] AudioCodecs;
public final String[] getAudioCodecs() {
return AudioCodecs;
}
public final void setAudioCodecs(String[] value) {
AudioCodecs = value;
}
private DeviceProfile DeviceProfile;
public final DeviceProfile getDeviceProfile() {
@@ -86,6 +115,16 @@ public class StreamInfo {
DeviceProfile = value;
}
private String DeviceId;
public final String getDeviceId() {
return DeviceId;
}
public final void setDeviceId(String value) {
DeviceId = value;
}
private Long RunTimeTicks = null;
public final Long getRunTimeTicks() {
@@ -132,6 +171,122 @@ public class StreamInfo {
return getMediaSource() == null ? null : getMediaSource().getId();
}
public final boolean getIsDirectStream() {
return getPlayMethod() == PlayMethod.DirectStream || getPlayMethod() == PlayMethod.DirectPlay;
}
public final String toUrl(String baseUrl, String accessToken) {
if (!Utils.isEmpty(getMediaUrl())) {
return getMediaUrl();
}
if (getPlayMethod() == PlayMethod.DirectPlay) {
return getMediaSource().getPath();
}
if (Utils.isEmpty(baseUrl)) {
throw new IllegalArgumentException(baseUrl);
}
ArrayList<String> list = new ArrayList<String>();
for (NameValuePair pair : buildParams(this, accessToken, false)) {
if (Utils.isEmpty(pair.getValue())) {
continue;
}
// Try to keep the url clean by omitting defaults
if (START_TIME_TICKS.equalsIgnoreCase(pair.getName()) && "0".equalsIgnoreCase(pair.getValue())) {
continue;
}
if (SUBTITLE_STREAM_INDEX.equalsIgnoreCase(pair.getName()) && "-1".equalsIgnoreCase(pair.getValue())) {
continue;
}
if (STATIC.equalsIgnoreCase(pair.getName()) && "false".equalsIgnoreCase(pair.getValue())) {
continue;
}
list.add(String.format("%1$s=%2$s", pair.getName(), pair.getValue()));
}
String queryString = Utils.join("&", list);
return getUrl(baseUrl, queryString);
}
private String getUrl(String baseUrl, String queryString) {
if (Utils.isEmpty(baseUrl)) {
throw new IllegalArgumentException(baseUrl);
}
String extension = Utils.isEmpty(getContainer()) ? "" : "." + getContainer();
// remove trailing slashes
baseUrl = baseUrl.replaceAll("[/]+$", "");
if (getMediaType() == DlnaProfileType.Audio) {
return String.format("%1$s/audio/%2$s/stream%3$s?%4$s", baseUrl, getItemId(), extension, queryString);
}
return String.format("%1$s/videos/%2$s/stream%3$s?%4$s", baseUrl, getItemId(), extension, queryString);
}
private static ArrayList<NameValuePair> buildParams(StreamInfo item, String accessToken, boolean isDlna) {
ArrayList<NameValuePair> list = new ArrayList<NameValuePair>();
String audioCodecs = item.getAudioCodecs().length == 0 ? "" : Utils.join(",", item.getAudioCodecs());
list.add(new NameValuePair("DeviceProfileId", ""));
String tempVar2 = item.getDeviceId();
list.add(new NameValuePair("DeviceId", (tempVar2 != null) ? tempVar2 : ""));
String tempVar3 = item.getMediaSourceId();
list.add(new NameValuePair("MediaSourceId", (tempVar3 != null) ? tempVar3 : ""));
list.add(new NameValuePair(STATIC, Boolean.valueOf(item.getIsDirectStream()).toString().toLowerCase()));
list.add(new NameValuePair("VideoCodec", ""));
list.add(new NameValuePair("AudioCodec", audioCodecs));
list.add(new NameValuePair("AudioStreamIndex", ""));
list.add(new NameValuePair("SubtitleStreamIndex", ""));
list.add(new NameValuePair("VideoBitrate", ""));
list.add(new NameValuePair("AudioBitrate", ""));
list.add(new NameValuePair("MaxAudioChannels", ""));
list.add(new NameValuePair("MaxFramerate", ""));
list.add(new NameValuePair("MaxWidth", ""));
list.add(new NameValuePair("MaxHeight", ""));
list.add(new NameValuePair(START_TIME_TICKS, String.valueOf(item.getStartPositionTicks())));
list.add(new NameValuePair("Level", ""));
list.add(new NameValuePair("MaxRefFrames", ""));
list.add(new NameValuePair("MaxVideoBitDepth", ""));
list.add(new NameValuePair("Profile", ""));
// no longer used
list.add(new NameValuePair("Cabac", ""));
String tempVar6 = item.getPlaySessionId();
list.add(new NameValuePair("PlaySessionId", (tempVar6 != null) ? tempVar6 : ""));
list.add(new NameValuePair("api_key", (accessToken != null) ? accessToken : ""));
String liveStreamId = item.getMediaSource() == null ? null : item.getMediaSource().getLiveStreamId();
list.add(new NameValuePair("LiveStreamId", (liveStreamId != null) ? liveStreamId : ""));
if (isDlna) {
list.add(new NameValuePair("ItemId", item.getItemId()));
}
list.add(new NameValuePair("CopyTimestamps", "false"));
list.add(new NameValuePair("ForceLiveStream", "false"));
list.add(new NameValuePair("SubtitleMethod", ""));
list.add(new NameValuePair("TranscodingMaxAudioChannels", ""));
list.add(new NameValuePair("EnableSubtitlesInManifest", "false"));
String tempVar7 = item.getMediaSource().getETag();
list.add(new NameValuePair("Tag", (tempVar7 != null) ? tempVar7 : ""));
return list;
}
public final ArrayList<SubtitleStreamInfo> getSubtitleProfiles(boolean includeSelectedTrackOnly, String baseUrl, String accessToken) {
return getSubtitleProfiles(includeSelectedTrackOnly, false, baseUrl, accessToken);
}

View File

@@ -1,150 +1,98 @@
package org.jellyfin.androidtv.data.eventhandling
import android.content.Context
import android.media.AudioManager
import android.os.Build
import android.widget.Toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.data.model.DataRefreshService
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
import org.jellyfin.androidtv.ui.navigation.Destinations
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
import org.jellyfin.androidtv.util.PlaybackHelper
import org.jellyfin.androidtv.util.apiclient.PlaybackHelper
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.sessionApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.sockets.subscribe
import org.jellyfin.sdk.api.sockets.subscribeGeneralCommand
import org.jellyfin.sdk.api.sockets.subscribeGeneralCommands
import org.jellyfin.sdk.api.sockets.SocketInstance
import org.jellyfin.sdk.api.sockets.SocketInstanceState
import org.jellyfin.sdk.api.sockets.addGeneralCommandsListener
import org.jellyfin.sdk.api.sockets.addListener
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.GeneralCommandType
import org.jellyfin.sdk.model.api.LibraryChangedMessage
import org.jellyfin.sdk.model.api.LibraryUpdateInfo
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PlayMessage
import org.jellyfin.sdk.model.api.PlaystateCommand
import org.jellyfin.sdk.model.api.PlaystateMessage
import org.jellyfin.sdk.model.extensions.get
import org.jellyfin.sdk.model.constant.MediaType
import org.jellyfin.sdk.model.extensions.getValue
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.jellyfin.sdk.model.socket.LibraryChangedMessage
import org.jellyfin.sdk.model.socket.PlayMessage
import org.jellyfin.sdk.model.socket.PlayStateMessage
import timber.log.Timber
import java.time.Instant
import java.util.UUID
class SocketHandler(
private val context: Context,
private val api: ApiClient,
private val socketInstance: SocketInstance,
private val dataRefreshService: DataRefreshService,
private val mediaManager: MediaManager,
private val playbackControllerContainer: PlaybackControllerContainer,
private val navigationRepository: NavigationRepository,
private val audioManager: AudioManager,
private val itemLauncher: ItemLauncher,
private val playbackHelper: PlaybackHelper,
) {
private val coroutineScope = CoroutineScope(Dispatchers.IO)
val state = socketInstance.state
suspend fun updateSession() {
try {
api.sessionApi.postCapabilities(
playableMediaTypes = listOf(MediaType.VIDEO, MediaType.AUDIO),
playableMediaTypes = listOf(MediaType.Video, MediaType.Audio),
supportsMediaControl = true,
supportedCommands = buildList {
add(GeneralCommandType.DISPLAY_CONTENT)
add(GeneralCommandType.SET_SUBTITLE_STREAM_INDEX)
add(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
add(GeneralCommandType.DISPLAY_MESSAGE)
add(GeneralCommandType.SEND_STRING)
// Note: These are used in the PlaySessionSocketService
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !audioManager.isVolumeFixed) {
add(GeneralCommandType.VOLUME_UP)
add(GeneralCommandType.VOLUME_DOWN)
add(GeneralCommandType.SET_VOLUME)
add(GeneralCommandType.MUTE)
add(GeneralCommandType.UNMUTE)
add(GeneralCommandType.TOGGLE_MUTE)
}
},
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 {
api.webSocket.apply {
socketInstance.apply {
// Library
subscribe<LibraryChangedMessage>()
.onEach { message -> message.data?.let(::onLibraryChanged) }
.launchIn(coroutineScope)
addListener<LibraryChangedMessage> { message -> onLibraryChanged(message.info) }
// Media playback
subscribe<PlayMessage>()
.onEach { message -> onPlayMessage(message) }
.launchIn(coroutineScope)
subscribe<PlaystateMessage>()
.onEach { message -> onPlayStateMessage(message) }
.launchIn(coroutineScope)
subscribeGeneralCommand(GeneralCommandType.SET_SUBTITLE_STREAM_INDEX)
.onEach { message ->
val index = message["index"]?.toIntOrNull() ?: return@onEach
withContext(Dispatchers.Main) {
playbackControllerContainer.playbackController?.switchSubtitleStream(index)
}
}
.launchIn(coroutineScope)
subscribeGeneralCommand(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
.onEach { message ->
val index = message["index"]?.toIntOrNull() ?: return@onEach
withContext(Dispatchers.Main) {
playbackControllerContainer.playbackController?.switchAudioStream(index)
}
}
.launchIn(coroutineScope)
addListener<PlayMessage> { message -> onPlayMessage(message) }
addListener<PlayStateMessage> { message -> onPlayStateMessage(message) }
// General commands
subscribeGeneralCommand(GeneralCommandType.DISPLAY_CONTENT)
.onEach { message ->
val itemId by message
val itemType by message
addGeneralCommandsListener(setOf(GeneralCommandType.DISPLAY_CONTENT)) { message ->
val itemId by message
val itemType by message
val itemUuid = itemId?.toUUIDOrNull()
val itemKind = itemType?.let { type ->
BaseItemKind.entries.find { value ->
value.serialName.equals(type, true)
}
}
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)
}
.launchIn(coroutineScope)
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
subscribeGeneralCommands(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING))
.onEach { message ->
val header by message
val text by message
val string by message
onDisplayMessage(header, text ?: string)
}
.launchIn(coroutineScope)
onDisplayMessage(header, text ?: string)
}
}
}
@@ -157,71 +105,64 @@ class SocketHandler(
})
if (info.itemsAdded.any() || info.itemsRemoved.any())
dataRefreshService.lastLibraryChange = Instant.now()
dataRefreshService.lastLibraryChange = System.currentTimeMillis()
}
private fun onPlayMessage(message: PlayMessage) {
val itemId = message.data?.itemIds?.firstOrNull() ?: return
val itemId = message.request.itemIds?.firstOrNull() ?: return
runCatching {
playbackHelper.retrieveAndPlay(
itemId,
false,
message.data?.startPositionTicks,
context
)
}.onFailure { Timber.w(it, "Failed to start playback") }
PlaybackHelper.retrieveAndPlay(
itemId.toString(),
false,
message.request.startPositionTicks,
context
)
}
@Suppress("ComplexMethod")
private suspend fun onPlayStateMessage(message: PlaystateMessage) = withContext(Dispatchers.Main) {
Timber.i("Received PlayStateMessage with command ${message.data?.command}")
// Audio playback uses (Rewrite)MediaManager, (legacy) video playback uses playbackController
when {
mediaManager.hasAudioQueueItems() -> {
Timber.i("Ignoring PlayStateMessage: should be handled by PlaySessionSocketService")
return@withContext
}
// PlaybackController
else -> {
val playbackController = playbackControllerContainer.playbackController
when (message.data?.command) {
PlaystateCommand.STOP -> playbackController?.endPlayback(true)
PlaystateCommand.PAUSE, PlaystateCommand.UNPAUSE, PlaystateCommand.PLAY_PAUSE -> playbackController?.playPause()
PlaystateCommand.NEXT_TRACK -> playbackController?.next()
PlaystateCommand.PREVIOUS_TRACK -> playbackController?.prev()
PlaystateCommand.SEEK -> playbackController?.seek(
(message.data?.seekPositionTicks ?: 0) / TICKS_TO_MS
)
PlaystateCommand.REWIND -> playbackController?.rewind()
PlaystateCommand.FAST_FORWARD -> playbackController?.fastForward()
null -> Unit
}
}
private fun onPlayStateMessage(message: PlayStateMessage) = coroutineScope.launch(Dispatchers.Main) {
Timber.i("Received PlayStateMessage with command ${message.request.command}")
val playbackController = playbackControllerContainer.playbackController
// Audio playback uses the mediaManager, video playback and live tv use the playbackController
if (mediaManager.isAudioPlayerInitialized) when (message.request.command) {
PlaystateCommand.STOP -> mediaManager.stopAudio(true)
PlaystateCommand.PAUSE, PlaystateCommand.UNPAUSE, PlaystateCommand.PLAY_PAUSE -> mediaManager.playPauseAudio()
PlaystateCommand.NEXT_TRACK -> mediaManager.nextAudioItem()
PlaystateCommand.PREVIOUS_TRACK -> mediaManager.prevAudioItem()
// Not implemented
PlaystateCommand.SEEK,
PlaystateCommand.REWIND,
PlaystateCommand.FAST_FORWARD -> Unit
} else when (message.request.command) {
PlaystateCommand.STOP -> playbackController?.endPlayback(true)
PlaystateCommand.PAUSE, PlaystateCommand.UNPAUSE, PlaystateCommand.PLAY_PAUSE -> playbackController?.playPause()
PlaystateCommand.NEXT_TRACK -> playbackController?.next()
PlaystateCommand.PREVIOUS_TRACK -> playbackController?.prev()
PlaystateCommand.SEEK -> playbackController?.seek(
(message.request.seekPositionTicks ?: 0) / TICKS_TO_MS
)
// FIXME get rewind/forward amount from displayprefs
PlaystateCommand.REWIND -> playbackController?.skip(REWIND_MS)
PlaystateCommand.FAST_FORWARD -> playbackController?.skip(FORWARD_MS)
}
}
private suspend fun onDisplayContent(itemId: UUID, itemKind: BaseItemKind) = withContext(Dispatchers.Main) {
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@withContext
return
}
Timber.i("Launching $itemId")
when (itemKind) {
BaseItemKind.USER_VIEW,
BaseItemKind.COLLECTION_FOLDER -> {
BaseItemKind.COLLECTION_FOLDER -> coroutineScope.launch {
val item by api.userLibraryApi.getItem(itemId = itemId)
itemLauncher.launchUserView(item)
ItemLauncher.launchUserView(item)
}
else -> navigationRepository.navigate(Destinations.itemDetails(itemId))
}
}
@@ -239,5 +180,7 @@ class SocketHandler(
companion object {
const val TICKS_TO_MS = 10000L
const val REWIND_MS = -11000
const val FORWARD_MS = 30000
}
}

View File

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

View File

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

View File

@@ -1,15 +1,18 @@
package org.jellyfin.androidtv.data.model
import org.jellyfin.sdk.model.api.ItemFilter
import org.jellyfin.apiclient.model.querying.ItemFilter
class FilterOptions {
var isFavoriteOnly = false
var isUnwatchedOnly = false
val filters: Set<ItemFilter>
get() = buildSet {
if (isFavoriteOnly) add(ItemFilter.IS_FAVORITE)
if (isUnwatchedOnly) add(ItemFilter.IS_UNPLAYED)
val filters: Array<ItemFilter>?
get() {
if (!isUnwatchedOnly && !isFavoriteOnly) return null
return buildList {
if (isFavoriteOnly) add(ItemFilter.IsFavorite)
if (isUnwatchedOnly) add(ItemFilter.IsUnplayed)
}.toTypedArray()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
package org.jellyfin.androidtv.data.querying
import org.jellyfin.androidtv.auth.repository.UserRepository
import org.jellyfin.apiclient.model.querying.ItemFields
import org.jellyfin.apiclient.model.querying.ItemQuery
import org.koin.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,5 @@
package org.jellyfin.androidtv.data.querying
data class TrailersQuery(
val itemId: String,
)

View File

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

View File

@@ -6,7 +6,6 @@ import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.UserItemDataDto
import java.time.Instant
interface ItemMutationRepository {
suspend fun setFavorite(item: UUID, favorite: Boolean): UserItemDataDto
@@ -23,7 +22,7 @@ class ItemMutationRepositoryImpl(
else -> api.userLibraryApi.unmarkFavoriteItem(itemId = item)
}
dataRefreshService.lastFavoriteUpdate = Instant.now()
dataRefreshService.lastFavoriteUpdate = System.currentTimeMillis()
return response
}

View File

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

View File

@@ -5,14 +5,14 @@ import kotlinx.coroutines.flow.flow
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.constant.CollectionType
interface UserViewsRepository {
val views: Flow<Collection<BaseItemDto>>
fun isSupported(collectionType: CollectionType?): Boolean
fun allowViewSelection(collectionType: CollectionType?): Boolean
fun allowGridView(collectionType: CollectionType?): Boolean
fun isSupported(collectionType: String?): Boolean
fun allowViewSelection(collectionType: String?): Boolean
fun allowGridView(collectionType: String?): Boolean
}
class UserViewsRepositoryImpl(
@@ -26,25 +26,25 @@ class UserViewsRepositoryImpl(
emit(filteredViews)
}
override fun isSupported(collectionType: CollectionType?) = collectionType !in unsupportedCollectionTypes
override fun allowViewSelection(collectionType: CollectionType?) = collectionType !in disallowViewSelectionCollectionTypes
override fun allowGridView(collectionType: CollectionType?) = collectionType !in disallowGridViewCollectionTypes
override fun isSupported(collectionType: String?) = collectionType !in unsupportedCollectionTypes
override fun allowViewSelection(collectionType: String?) = collectionType !in disallowViewSelectionCollectionTypes
override fun allowGridView(collectionType: String?) = collectionType !in disallowGridViewCollectionTypes
private companion object {
private val unsupportedCollectionTypes = arrayOf(
CollectionType.BOOKS,
CollectionType.FOLDERS
CollectionType.Books,
CollectionType.Folders
)
private val disallowViewSelectionCollectionTypes = arrayOf(
CollectionType.LIVETV,
CollectionType.MUSIC,
CollectionType.PHOTOS,
CollectionType.LiveTv,
CollectionType.Music,
CollectionType.Photos,
)
private val disallowGridViewCollectionTypes = arrayOf(
CollectionType.LIVETV,
CollectionType.MUSIC
CollectionType.LiveTv,
CollectionType.Music
)
}
}

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,5 @@
package org.jellyfin.androidtv.di
import android.content.Context
import android.os.Build
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.auth.repository.ServerRepository
import org.jellyfin.androidtv.auth.repository.UserRepository
@@ -21,32 +15,23 @@ import org.jellyfin.androidtv.data.repository.NotificationsRepositoryImpl
import org.jellyfin.androidtv.data.repository.UserViewsRepository
import org.jellyfin.androidtv.data.repository.UserViewsRepositoryImpl
import org.jellyfin.androidtv.data.service.BackgroundService
import org.jellyfin.androidtv.integration.dream.DreamViewModel
import org.jellyfin.androidtv.ui.ScreensaverViewModel
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
import org.jellyfin.androidtv.ui.navigation.Destinations
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
import org.jellyfin.androidtv.ui.navigation.NavigationRepositoryImpl
import org.jellyfin.androidtv.ui.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.search.SearchFragmentDelegate
import org.jellyfin.androidtv.ui.search.SearchRepository
import org.jellyfin.androidtv.ui.search.SearchRepositoryImpl
import org.jellyfin.androidtv.ui.search.SearchViewModel
import org.jellyfin.androidtv.ui.startup.ServerAddViewModel
import org.jellyfin.androidtv.ui.startup.StartupViewModel
import org.jellyfin.androidtv.ui.startup.UserLoginViewModel
import org.jellyfin.androidtv.util.KeyProcessor
import org.jellyfin.androidtv.util.MarkdownRenderer
import org.jellyfin.androidtv.util.PlaybackHelper
import org.jellyfin.androidtv.util.apiclient.ReportingHelper
import org.jellyfin.androidtv.util.sdk.SdkPlaybackHelper
import org.jellyfin.androidtv.util.sdk.legacy
import org.jellyfin.apiclient.AppInfo
import org.jellyfin.apiclient.android
import org.jellyfin.apiclient.logging.AndroidLogger
import org.jellyfin.sdk.android.androidDevice
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
@@ -81,7 +66,9 @@ val appModule = module {
get<JellyfinSdk>().createApi()
}
single { SocketHandler(get(), get(), get(), get(), get(), get(), get(), get(), get()) }
single { get<ApiClient>().ws() }
single { SocketHandler(get(), get(), get(), get(), get(), get(), get()) }
// Old apiclient
single {
@@ -98,45 +85,26 @@ val appModule = module {
)
}
// Coil (images)
single {
ImageLoader.Builder(androidContext()).apply {
components {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(ImageDecoderDecoder.Factory())
else add(GifDecoder.Factory())
add(SvgDecoder.Factory())
}
}.build()
}
// Non API related
single { MediaManager(get()) }
single { DataRefreshService() }
single { PlaybackControllerContainer() }
single<UserRepository> { UserRepositoryImpl() }
single<UserViewsRepository> { UserViewsRepositoryImpl(get()) }
single<NotificationsRepository> { NotificationsRepositoryImpl(get(), get()) }
single<NotificationsRepository> { NotificationsRepositoryImpl(get(), get(), get()) }
single<ItemMutationRepository> { ItemMutationRepositoryImpl(get(), get()) }
single<CustomMessageRepository> { CustomMessageRepositoryImpl() }
single<NavigationRepository> { NavigationRepositoryImpl(Destinations.home) }
single<SearchRepository> { SearchRepositoryImpl(get()) }
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()) }
viewModel { ScreensaverViewModel(get()) }
viewModel { SearchViewModel(get()) }
viewModel { DreamViewModel(get(), get(), get(), get(), get()) }
single { BackgroundService(get(), get(), get(), get(), get()) }
single { BackgroundService(get(), get(), get(), get()) }
single { MarkdownRenderer(get()) }
single { ItemLauncher() }
single { KeyProcessor() }
single { ReportingHelper() }
single<PlaybackHelper> { SdkPlaybackHelper(get(), get(), get(), get(), get(), get(), get()) }
factory { (context: Context) -> SearchFragmentDelegate(context, get(), get()) }
}

View File

@@ -1,6 +1,5 @@
package org.jellyfin.androidtv.di
import org.jellyfin.androidtv.auth.AccountManagerMigration
import org.jellyfin.androidtv.auth.apiclient.ApiBinder
import org.jellyfin.androidtv.auth.repository.AuthenticationRepository
import org.jellyfin.androidtv.auth.repository.AuthenticationRepositoryImpl
@@ -10,22 +9,23 @@ import org.jellyfin.androidtv.auth.repository.ServerUserRepository
import org.jellyfin.androidtv.auth.repository.ServerUserRepositoryImpl
import org.jellyfin.androidtv.auth.repository.SessionRepository
import org.jellyfin.androidtv.auth.repository.SessionRepositoryImpl
import org.jellyfin.androidtv.auth.store.AccountManagerStore
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.koin.dsl.module
val authModule = module {
single { AccountManagerMigration(get()) }
single { AuthenticationStore(get(), get()) }
single { AuthenticationStore(get()) }
single { AccountManagerStore(get()) }
single { AuthenticationPreferences(get()) }
single<AuthenticationRepository> {
AuthenticationRepositoryImpl(get(), get(), get(), get(), get(), get(defaultDeviceInfo))
AuthenticationRepositoryImpl(get(), get(), get(), get(), get(), get(), get(defaultDeviceInfo))
}
single<ServerRepository> { ServerRepositoryImpl(get(), get()) }
single<ServerUserRepository> { ServerUserRepositoryImpl(get(), get()) }
single<ServerUserRepository> { ServerUserRepositoryImpl(get(), get(), get()) }
single<SessionRepository> {
SessionRepositoryImpl(get(), get(), get(), get(), get(), get(defaultDeviceInfo), get(), get(), get())
SessionRepositoryImpl(get(), get(), get(), get(), get(), get(), get(defaultDeviceInfo), get(), get(), get())
}
single { ApiBinder(get(), get()) }

View File

@@ -1,83 +1,22 @@
package org.jellyfin.androidtv.di
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationManagerCompat
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.UserSettingPreferences
import org.jellyfin.androidtv.ui.browsing.MainActivity
import org.jellyfin.androidtv.ui.playback.GarbagePlaybackLauncher
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.ui.playback.PlaybackManager
import org.jellyfin.androidtv.ui.playback.RewritePlaybackLauncher
import org.jellyfin.androidtv.ui.playback.VideoQueueManager
import org.jellyfin.androidtv.ui.playback.rewrite.RewriteMediaManager
import org.jellyfin.playback.core.playbackManager
import org.jellyfin.playback.jellyfin.jellyfinPlugin
import org.jellyfin.playback.media3.exoplayer.ExoPlayerOptions
import org.jellyfin.playback.media3.exoplayer.exoPlayerPlugin
import org.jellyfin.playback.media3.session.MediaSessionOptions
import org.jellyfin.playback.media3.session.media3SessionPlugin
import org.jellyfin.sdk.api.client.ApiClient
import org.koin.android.ext.koin.androidContext
import org.koin.core.scope.Scope
import org.koin.dsl.module
import kotlin.time.Duration.Companion.milliseconds
import org.jellyfin.androidtv.ui.playback.PlaybackManager as LegacyPlaybackManager
val playbackModule = module {
single { LegacyPlaybackManager(get()) }
single { VideoQueueManager() }
single<MediaManager> { RewriteMediaManager(get(), get(), get(), get()) }
single {
PlaybackManager(get())
}
factory {
val preferences = get<UserPreferences>()
val useRewrite = preferences[UserPreferences.playbackRewriteVideoEnabled] && BuildConfig.DEVELOPMENT
val useRewrite = preferences[UserPreferences.playbackRewriteEnabled] && BuildConfig.DEVELOPMENT
if (useRewrite) RewritePlaybackLauncher()
else GarbagePlaybackLauncher(get())
}
single { createPlaybackManager() }
}
fun Scope.createPlaybackManager() = playbackManager(androidContext()) {
val activityIntent = Intent(get(), MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(get(), 0, activityIntent, PendingIntent.FLAG_IMMUTABLE)
val notificationChannelId = "session"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
notificationChannelId,
notificationChannelId,
NotificationManager.IMPORTANCE_LOW
)
NotificationManagerCompat.from(get()).createNotificationChannel(channel)
}
val api = get<ApiClient>()
val exoPlayerOptions = ExoPlayerOptions(
httpConnectTimeout = api.httpClientOptions.connectTimeout,
httpReadTimeout = api.httpClientOptions.requestTimeout
)
install(exoPlayerPlugin(get(), exoPlayerOptions))
val mediaSessionOptions = MediaSessionOptions(
channelId = notificationChannelId,
notificationId = 1,
iconSmall = R.drawable.app_icon_foreground,
openIntent = pendingIntent,
)
install(media3SessionPlugin(get(), mediaSessionOptions))
install(jellyfinPlugin(get()))
// Options
val userSettingPreferences = get<UserSettingPreferences>()
defaultRewindAmount = { userSettingPreferences[UserSettingPreferences.skipBackLength].milliseconds }
defaultFastForwardAmount = { userSettingPreferences[UserSettingPreferences.skipForwardLength].milliseconds }
}

View File

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

View File

@@ -1,14 +1,13 @@
package org.jellyfin.androidtv.integration
import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import androidx.tvprovider.media.tv.Channel
import androidx.tvprovider.media.tv.ChannelLogoUtils
import androidx.tvprovider.media.tv.PreviewProgram
@@ -25,33 +24,26 @@ import org.jellyfin.androidtv.data.repository.UserViewsRepository
import org.jellyfin.androidtv.integration.provider.ImageProvider
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.ui.startup.StartupActivity
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.androidtv.util.ImageUtils
import org.jellyfin.androidtv.util.dp
import org.jellyfin.androidtv.util.sdk.isUsable
import org.jellyfin.androidtv.util.stripHtml
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.exception.TimeoutException
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageFormat
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.constant.MediaType
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import timber.log.Timber
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import kotlin.time.Duration
/**
* Manages channels on the android tv home screen.
@@ -63,13 +55,17 @@ class LeanbackChannelWorker(
workerParams: WorkerParameters,
) : CoroutineWorker(context, workerParams), KoinComponent {
companion object {
/**
* Amount of ticks found in a millisecond, used for calculation.
*/
private const val TICKS_IN_MILLISECOND = 10000
const val PERIODIC_UPDATE_REQUEST_NAME = "LeanbackChannelPeriodicUpdateRequest"
}
private val api by inject<ApiClient>()
private val userPreferences by inject<UserPreferences>()
private val userViewsRepository by inject<UserViewsRepository>()
private val imageHelper by inject<ImageHelper>()
/**
* Check if the app can use Leanback features and is API level 26 or higher.
@@ -91,73 +87,13 @@ class LeanbackChannelWorker(
else -> try {
// Get next up episodes
val (resumeItems, nextUpItems) = getNextUpItems()
// Get latest media
val (latestEpisodes, latestMovies, latestMedia) = getLatestMedia()
val myMedia = getMyMedia()
// Delete current items from the channels
// Delete current items from the My Media and Next Up channels
context.contentResolver.delete(TvContractCompat.PreviewPrograms.CONTENT_URI, null, null)
// Get channel URIs
val latestMediaChannel = getChannelUri(
"latest_media", Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.home_section_latest_media))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build(),
default = true
)
val myMediaChannel = getChannelUri(
"my_media", Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_my_media))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build()
)
val nextUpChannel = getChannelUri(
"next_up", Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_next_up))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build()
)
val latestMoviesChannel = getChannelUri(
"latest_movies", Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_movies))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build()
)
val latestEpisodesChannel = getChannelUri(
"latest_episodes", Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_new_episodes))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build()
)
val preferParentThumb = userPreferences[UserPreferences.seriesThumbnailsEnabled]
// Add new items
arrayOf(
nextUpItems to nextUpChannel,
latestMedia to latestMediaChannel,
latestMovies to latestMoviesChannel,
latestEpisodes to latestEpisodesChannel,
myMedia to myMediaChannel,
).forEach { (items, channel) ->
Timber.d("Updating channel %s", channel)
items.map { item ->
createPreviewProgram(
channel,
item,
preferParentThumb
)
}.let {
context.contentResolver.bulkInsert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
it.toTypedArray()
)
}
}
// Update various channels
updateMyMedia()
updateNextUp(nextUpItems)
updateWatchNext(resumeItems + nextUpItems)
// Success!
@@ -188,10 +124,7 @@ class LeanbackChannelWorker(
}
} else {
// Create new channel and save uri
context.contentResolver.insert(
TvContractCompat.Channels.CONTENT_URI,
settings.toContentValues()
)!!.also { uri ->
context.contentResolver.insert(TvContractCompat.Channels.CONTENT_URI, settings.toContentValues())!!.also { uri ->
store.edit().putString(name, uri.toString()).apply()
if (default) {
// Set as default row to display (we can request one row to automatically be added to the home screen)
@@ -201,12 +134,8 @@ class LeanbackChannelWorker(
}
// Update logo
ResourcesCompat.getDrawable(context.resources, R.mipmap.app_icon, context.theme)?.let {
ChannelLogoUtils.storeChannelLogo(
context,
ContentUris.parseId(uri),
it.toBitmap(80.dp(context), 80.dp(context))
)
ResourcesCompat.getDrawable(context.resources, R.drawable.app_icon, context.theme)?.let {
ChannelLogoUtils.storeChannelLogo(context, ContentUris.parseId(uri), it.toBitmap(80.dp(context), 80.dp(context)))
}
return uri
@@ -216,29 +145,53 @@ class LeanbackChannelWorker(
* Updates the "my media" row with current media libraries.
*/
@Suppress("RestrictedApi")
private suspend fun getMyMedia(): List<BaseItemDto> {
private suspend fun updateMyMedia() {
// Get channel
val channelUri = getChannelUri("my_media", Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_my_media))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build(), default = true)
val response by api.userViewsApi.getUserViews(includeHidden = false)
// Add new items
return response.items
val items = response.items
.orEmpty()
.filter { userViewsRepository.isSupported(it.collectionType) }
.map { item ->
val imageUri = if (item.imageTags?.contains(ImageType.PRIMARY) == true)
ImageProvider.getImageUri(api.imageApi.getItemImageUrl(item.id, ImageType.PRIMARY))
else
ImageUtils.getResourceUrl(context, R.drawable.tile_land_tv).toUri()
PreviewProgram.Builder()
.setChannelId(ContentUris.parseId(channelUri))
.setType(TvContractCompat.PreviewPrograms.TYPE_CHANNEL)
.setTitle(item.name)
.setPosterArtUri(imageUri)
.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
.setIntent(Intent(context, StartupActivity::class.java).apply {
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
putExtra(StartupActivity.EXTRA_ITEM_IS_USER_VIEW, true)
})
.build()
.toContentValues()
}.toTypedArray()
context.contentResolver.bulkInsert(TvContractCompat.PreviewPrograms.CONTENT_URI, items)
}
/**
* Gets the poster art for an item. Uses the [preferParentThumb] parameter to fetch the series
* image when preferred.
*/
private fun BaseItemDto.getPosterArtImageUrl(
preferParentThumb: Boolean
): Uri = when {
type == BaseItemKind.MOVIE || type == BaseItemKind.SERIES -> api.imageApi.getItemImageUrl(
private fun BaseItemDto.getPosterArtImageUrl(preferParentThumb: Boolean): Uri = when {
type == BaseItemKind.MOVIE -> api.imageApi.getItemImageUrl(
itemId = id,
imageType = ImageType.PRIMARY,
format = ImageFormat.WEBP,
width = 106.dp(context),
height = 153.dp(context),
tag = imageTags?.get(ImageType.PRIMARY),
)
(preferParentThumb || imageTags?.contains(ImageType.PRIMARY) != true) && parentThumbItemId != null -> api.imageApi.getItemImageUrl(
@@ -247,19 +200,15 @@ class LeanbackChannelWorker(
format = ImageFormat.WEBP,
width = 272.dp(context),
height = 153.dp(context),
tag = imageTags?.get(ImageType.THUMB),
)
imageTags?.containsKey(ImageType.PRIMARY) == true -> api.imageApi.getItemImageUrl(
else -> api.imageApi.getItemImageUrl(
itemId = id,
imageType = ImageType.PRIMARY,
format = ImageFormat.WEBP,
width = 272.dp(context),
height = 153.dp(context),
tag = imageTags?.get(ImageType.PRIMARY),
)
else -> imageHelper.getResourceUrl(context, R.drawable.tile_land_tv)
}.let(ImageProvider::getImageUri)
/**
@@ -267,133 +216,73 @@ class LeanbackChannelWorker(
* 1. resume items
* 2. next up items
*/
private suspend fun getNextUpItems(): Pair<List<BaseItemDto>, List<BaseItemDto>> =
withContext(Dispatchers.IO) {
val resume = async {
api.itemsApi.getResumeItems(
fields = listOf(ItemFields.DATE_CREATED),
imageTypeLimit = 1,
limit = 10,
mediaTypes = listOf(MediaType.VIDEO),
includeItemTypes = listOf(BaseItemKind.EPISODE, BaseItemKind.MOVIE),
excludeActiveSessions = true,
).content.items.orEmpty()
}
val nextUp = async {
api.tvShowsApi.getNextUp(
imageTypeLimit = 1,
limit = 10,
enableResumable = false,
fields = listOf(ItemFields.DATE_CREATED),
).content.items.orEmpty()
}
// Concat
Pair(resume.await(), nextUp.await())
private suspend fun getNextUpItems(): Pair<List<BaseItemDto>, List<BaseItemDto>> = withContext(Dispatchers.IO) {
val resume = async {
api.itemsApi.getResumeItems(
fields = listOf(ItemFields.DATE_CREATED),
imageTypeLimit = 1,
limit = 10,
mediaTypes = listOf(MediaType.Video),
includeItemTypes = listOf(BaseItemKind.EPISODE, BaseItemKind.MOVIE),
excludeActiveSessions = true,
).content.items.orEmpty()
}
private suspend fun getLatestMedia(): Triple<List<BaseItemDto>, List<BaseItemDto>, List<BaseItemDto>> =
withContext(Dispatchers.IO) {
val latestEpisodes = async {
api.userLibraryApi.getLatestMedia(
fields = listOf(
ItemFields.OVERVIEW,
),
limit = 50,
includeItemTypes = listOf(BaseItemKind.EPISODE),
isPlayed = false
).content
}
val latestMovies = async {
api.userLibraryApi.getLatestMedia(
fields = listOf(
ItemFields.OVERVIEW,
),
limit = 50,
includeItemTypes = listOf(BaseItemKind.MOVIE),
isPlayed = false
).content
}
val latestMedia = async {
api.userLibraryApi.getLatestMedia(
fields = listOf(
ItemFields.OVERVIEW,
),
limit = 50,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
isPlayed = false
).content
}
// Concat
Triple(latestEpisodes.await(), latestMovies.await(), latestMedia.await())
val nextup = async {
api.tvShowsApi.getNextUp(
userId = api.userId,
imageTypeLimit = 1,
limit = 10,
fields = listOf(ItemFields.DATE_CREATED),
).content.items.orEmpty()
}
@SuppressLint("RestrictedApi")
private fun createPreviewProgram(
channelUri: Uri,
item: BaseItemDto,
preferParentThumb: Boolean
): ContentValues {
val imageUri = item.getPosterArtImageUrl(preferParentThumb)
val seasonString = item.parentIndexNumber?.toString().orEmpty()
// Concat
Pair(resume.await(), nextup.await())
}
val episodeString = when {
item.indexNumberEnd != null && item.indexNumber != null ->
"${item.indexNumber}-${item.indexNumberEnd}"
/**
* Updates the "next up" row with current episodes. Uses the [nextUpItems] parameter to store
* items returned by a NextUpQuery().
*/
@Suppress("RestrictedApi")
private fun updateNextUp(nextUpItems: List<BaseItemDto>) {
val preferParentThumb = userPreferences[UserPreferences.seriesThumbnailsEnabled]
else -> item.indexNumber?.toString().orEmpty()
}
// Get channel
val channelUri = getChannelUri("next_up", Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_next_up))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build())
return PreviewProgram.Builder()
.setChannelId(ContentUris.parseId(channelUri))
.setType(
when (item.type) {
BaseItemKind.SERIES -> WatchNextPrograms.TYPE_TV_SERIES
BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE
BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE
BaseItemKind.AUDIO -> WatchNextPrograms.TYPE_TRACK
BaseItemKind.PLAYLIST -> WatchNextPrograms.TYPE_PLAYLIST
else -> WatchNextPrograms.TYPE_CHANNEL
}
)
.setTitle(item.seriesName ?: item.name)
.setEpisodeTitle(if (item.type == BaseItemKind.EPISODE) item.name else null)
.setSeasonNumber(seasonString, item.parentIndexNumber ?: 0)
.setEpisodeNumber(episodeString, item.indexNumber ?: 0)
.setDescription(item.overview?.stripHtml())
.setReleaseDate(
if (item.premiereDate != null) DateTimeFormatter.ISO_DATE.format(item.premiereDate)
else null
)
.setDurationMillis(
if (item.runTimeTicks?.ticks != null) {
// If we are resuming, we need to show remaining time, cause GoogleTV
// ignores setLastPlaybackPositionMillis
val duration = item.runTimeTicks?.ticks ?: Duration.ZERO
val playbackPosition = item.userData?.playbackPositionTicks?.ticks
?: Duration.ZERO
(duration - playbackPosition).inWholeMilliseconds.toInt()
} else 0
)
.setPosterArtUri(imageUri)
.setPosterArtAspectRatio(
when (item.type) {
BaseItemKind.COLLECTION_FOLDER,
BaseItemKind.EPISODE -> TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9
// Add new items
nextUpItems.map { item ->
val imageUri = item.getPosterArtImageUrl(preferParentThumb)
else -> TvContractCompat.PreviewPrograms.ASPECT_RATIO_MOVIE_POSTER
}
)
.setIntent(Intent(context, StartupActivity::class.java).apply {
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
putExtra(StartupActivity.EXTRA_ITEM_IS_USER_VIEW, item.type == BaseItemKind.COLLECTION_FOLDER)
})
.build()
.toContentValues()
val seasonString = item.parentIndexNumber?.toString().orEmpty()
val episodeString = when {
item.indexNumberEnd != null && item.indexNumber != null ->
"${item.indexNumber}-${item.indexNumberEnd}"
else -> item.indexNumber?.toString().orEmpty()
}
PreviewProgram.Builder()
.setChannelId(ContentUris.parseId(channelUri))
.setType(WatchNextPrograms.TYPE_TV_EPISODE)
.setTitle(item.seriesName)
.setEpisodeTitle(item.name)
.setSeasonNumber(seasonString, item.parentIndexNumber ?: 0)
.setEpisodeNumber(episodeString, item.indexNumber ?: 0)
.setPosterArtUri(imageUri)
.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
.setIntent(Intent(context, StartupActivity::class.java).apply {
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
})
.build()
.toContentValues()
}.let { context.contentResolver.bulkInsert(TvContractCompat.PreviewPrograms.CONTENT_URI, it.toTypedArray()) }
}
/**
@@ -408,8 +297,7 @@ class LeanbackChannelWorker(
// Add new items
context.contentResolver.bulkInsert(
WatchNextPrograms.CONTENT_URI,
nextUpItems.map { item -> getBaseItemAsWatchNextProgram(item).toContentValues() }
.toTypedArray()
nextUpItems.map { item -> getBaseItemAsWatchNextProgram(item).toContentValues() }.toTypedArray()
)
}
@@ -417,58 +305,55 @@ class LeanbackChannelWorker(
* Convert [BaseItemDto] to [WatchNextProgram]. Assumes the item type is "episode".
*/
@Suppress("RestrictedApi")
private fun getBaseItemAsWatchNextProgram(item: BaseItemDto) =
WatchNextProgram.Builder().apply {
val preferParentThumb = userPreferences[UserPreferences.seriesThumbnailsEnabled]
private fun getBaseItemAsWatchNextProgram(item: BaseItemDto) = WatchNextProgram.Builder().apply {
val preferParentThumb = userPreferences[UserPreferences.seriesThumbnailsEnabled]
setInternalProviderId(item.id.toString())
setInternalProviderId(item.id.toString())
// Poster size & type
if (item.type == BaseItemKind.EPISODE) {
setType(WatchNextPrograms.TYPE_TV_EPISODE)
setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_16_9)
} else if (item.type == BaseItemKind.MOVIE) {
setType(WatchNextPrograms.TYPE_MOVIE)
setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_MOVIE_POSTER)
// Poster size & type
if (item.type == BaseItemKind.EPISODE) {
setType(WatchNextPrograms.TYPE_TV_EPISODE)
setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_16_9)
} else if (item.type == BaseItemKind.MOVIE) {
setType(WatchNextPrograms.TYPE_MOVIE)
setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_MOVIE_POSTER)
}
// Name
if (item.seriesName != null) setTitle("${item.seriesName} - ${item.name}")
else setTitle(item.name)
// Poster
setPosterArtUri(item.getPosterArtImageUrl(preferParentThumb))
// Use date created or fallback to current time if unavailable
var engagement = item.dateCreated
when {
// User has started playing the episode
item.userData?.playbackPositionTicks ?: 0 > 0 -> {
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE)
setLastPlaybackPositionMillis((item.userData!!.playbackPositionTicks / TICKS_IN_MILLISECOND).toInt())
// Use last played date to prioritze
engagement = item.userData?.lastPlayedDate
}
// First episode of the season
item.indexNumber == 1 -> setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEW)
// Default
else -> setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
}
// Name
if (item.seriesName != null) setTitle("${item.seriesName} - ${item.name}")
else setTitle(item.name)
setLastEngagementTimeUtcMillis(engagement?.toInstant(ZoneOffset.UTC)?.toEpochMilli()
?: System.currentTimeMillis())
// Poster
setPosterArtUri(item.getPosterArtImageUrl(preferParentThumb))
// Episode runtime has been determined
item.runTimeTicks?.let { runTimeTicks ->
setDurationMillis((runTimeTicks / TICKS_IN_MILLISECOND).toInt())
}
// Use date created or fallback to current time if unavailable
var engagement = item.dateCreated
when {
// User has started playing the episode
(item.userData?.playbackPositionTicks ?: 0) > 0 -> {
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE)
setLastPlaybackPositionMillis(item.userData!!.playbackPositionTicks.ticks.inWholeMilliseconds.toInt())
// Use last played date to prioritize
engagement = item.userData?.lastPlayedDate
}
// First episode of the season
item.indexNumber == 1 -> setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEW)
// Default
else -> setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
}
setLastEngagementTimeUtcMillis(
engagement?.toInstant(ZoneOffset.UTC)?.toEpochMilli()
?: Instant.now().toEpochMilli()
)
// Episode runtime has been determined
item.runTimeTicks?.let { runTimeTicks ->
setDurationMillis(runTimeTicks.ticks.inWholeMilliseconds.toInt())
}
// Set intent to open the episode
setIntent(Intent(context, StartupActivity::class.java).apply {
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
})
}.build()
// Set intent to open the episode
setIntent(Intent(context, StartupActivity::class.java).apply {
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
})
}.build()
}

View File

@@ -13,7 +13,7 @@ import kotlinx.coroutines.runBlocking
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.integration.provider.ImageProvider
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.androidtv.util.ImageUtils
import org.jellyfin.androidtv.util.sdk.isUsable
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
@@ -41,7 +41,6 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
}
private val api by inject<ApiClient>()
private val imageHelper by inject<ImageHelper>()
override fun onCreate(): Boolean = api.isUsable
@@ -71,7 +70,7 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
* Gets the resumable items or returns null
*/
private suspend fun searchItems(query: String, limit: Int): BaseItemDtoQueryResult? = try {
val items by api.itemsApi.getItems(
val items by api.itemsApi.getItemsByUserId(
searchTerm = query,
recursive = true,
limit = limit,
@@ -107,7 +106,7 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
val imageUri = if (item.imageTags?.contains(ImageType.PRIMARY) == true)
ImageProvider.getImageUri(api.imageApi.getItemImageUrl(item.id, ImageType.PRIMARY))
else
imageHelper.getResourceUrl(context!!, R.drawable.tile_land_tv)
ImageUtils.getResourceUrl(context, R.drawable.tile_land_tv)
cursor.newRow().apply {
add(BaseColumns._ID, item.id)

View File

@@ -1,68 +0,0 @@
package org.jellyfin.androidtv.integration.dream
import android.service.dreams.DreamService
import androidx.annotation.CallSuper
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
abstract class DreamServiceCompat : DreamService(), SavedStateRegistryOwner, ViewModelStoreOwner {
@Suppress("LeakingThis")
private val lifecycleRegistry = LifecycleRegistry(this)
@Suppress("LeakingThis")
private val savedStateRegistryController = SavedStateRegistryController.create(this).apply {
performAttach()
}
override val lifecycle: Lifecycle get() = lifecycleRegistry
override val viewModelStore = ViewModelStore()
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
@CallSuper
override fun onCreate() {
super.onCreate()
savedStateRegistryController.performRestore(null)
lifecycleRegistry.currentState = Lifecycle.State.CREATED
}
override fun onDreamingStarted() {
super.onDreamingStarted()
lifecycleRegistry.currentState = Lifecycle.State.STARTED
}
override fun onDreamingStopped() {
super.onDreamingStopped()
lifecycleRegistry.currentState = Lifecycle.State.CREATED
}
fun setContent(content: @Composable () -> Unit) {
val view = ComposeView(this)
// Set composition strategy
view.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
// Inject dependencies normally added by appcompat activities
view.setViewTreeLifecycleOwner(this)
view.setViewTreeViewModelStoreOwner(this)
view.setViewTreeSavedStateRegistryOwner(this)
// Set content composable
view.setContent(content)
// Set content view
setContentView(view)
}
}

View File

@@ -1,168 +0,0 @@
package org.jellyfin.androidtv.integration.dream
import android.annotation.SuppressLint
import android.content.Context
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil.ImageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.integration.dream.model.DreamContent
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.ui.playback.AudioEventListener
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.ui.playback.PlaybackController
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageFormat
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
@SuppressLint("StaticFieldLeak")
class DreamViewModel(
private val api: ApiClient,
private val imageLoader: ImageLoader,
private val context: Context,
private val mediaManager: MediaManager,
private val userPreferences: UserPreferences,
) : ViewModel() {
private val _mediaContent = callbackFlow {
trySend(mediaManager.currentAudioItem)
val listener = object : AudioEventListener {
override fun onPlaybackStateChange(
newState: PlaybackController.PlaybackState,
currentItem: BaseItemDto?
) {
trySend(currentItem)
}
override fun onQueueStatusChanged(hasQueue: Boolean) {
trySend(mediaManager.currentAudioItem)
}
}
mediaManager.addAudioEventListener(listener)
awaitClose { mediaManager.removeAudioEventListener(listener) }
}
.distinctUntilChanged()
.map { it?.let(DreamContent::NowPlaying) }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(),
DreamContent.NowPlaying(mediaManager.currentAudioItem)
)
private val _libraryContent = flow {
// Load first library item after 2 seconds
// to force the logo at the start of the screensaver
emit(null)
delay(2.seconds)
while (true) {
val next = getRandomLibraryShowcase()
if (next != null) {
emit(next)
delay(30.seconds)
} else {
delay(3.seconds)
}
}
}
.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
val content = combine(_mediaContent, _libraryContent) { mediaContent, libraryContent ->
mediaContent ?: libraryContent ?: DreamContent.Logo
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = _mediaContent.value ?: _libraryContent.value ?: DreamContent.Logo,
)
private suspend fun getRandomLibraryShowcase(): DreamContent.LibraryShowcase? {
val requireParentalRating = userPreferences[UserPreferences.screensaverAgeRatingRequired]
val maxParentalRating = userPreferences[UserPreferences.screensaverAgeRatingMax]
try {
val response by api.itemsApi.getItems(
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
recursive = true,
sortBy = listOf(ItemSortBy.RANDOM),
limit = 5,
imageTypes = listOf(ImageType.BACKDROP),
maxOfficialRating = if (maxParentalRating == -1) null else maxParentalRating.toString(),
hasParentalRating = if (requireParentalRating) true else null,
)
val item = response.items?.firstOrNull { item ->
!item.backdropImageTags.isNullOrEmpty()
} ?: return null
Timber.i("Loading random library showcase item ${item.id}")
val backdropTag = item.backdropImageTags!!.randomOrNull()
?: item.imageTags?.get(ImageType.BACKDROP)
val logoTag = item.imageTags?.get(ImageType.LOGO)
val backdropUrl = api.imageApi.getItemImageUrl(
itemId = item.id,
imageType = ImageType.BACKDROP,
tag = backdropTag,
format = ImageFormat.WEBP,
)
val logoUrl = api.imageApi.getItemImageUrl(
itemId = item.id,
imageType = ImageType.LOGO,
tag = logoTag,
format = ImageFormat.WEBP,
)
val (logo, backdrop) = withContext(Dispatchers.IO) {
val logoDeferred = async {
imageLoader.execute(
request = ImageRequest.Builder(context).data(logoUrl).build()
).drawable?.toBitmap()
}
val backdropDeferred = async {
imageLoader.execute(
request = ImageRequest.Builder(context).data(backdropUrl).build()
).drawable?.toBitmap()
}
awaitAll(logoDeferred, backdropDeferred)
}
if (backdrop == null) {
return null
}
return DreamContent.LibraryShowcase(item, backdrop, logo)
} catch (err: ApiClientException) {
Timber.e(err)
return null
}
}
}

View File

@@ -0,0 +1,9 @@
package org.jellyfin.androidtv.integration.dream
import android.graphics.drawable.Drawable
import org.jellyfin.sdk.model.api.BaseItemDto
data class LibraryDreamItem(
val baseItem: BaseItemDto,
val background: Drawable,
)

View File

@@ -0,0 +1,34 @@
package org.jellyfin.androidtv.integration.dream
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.FrameLayout
import org.jellyfin.androidtv.databinding.ViewDreamLibraryItemBinding
import org.jellyfin.androidtv.util.KenBurnsTransitionGenerator
class LibraryDreamItemView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
val binding = ViewDreamLibraryItemBinding.inflate(LayoutInflater.from(context), this, true)
private val kenburnsTransitionGenerator = KenBurnsTransitionGenerator(
LibraryDreamService.UPDATE_DELAY,
AccelerateDecelerateInterpolator()
)
init {
binding.background.setTransitionGenerator(kenburnsTransitionGenerator)
}
fun setItem(item: LibraryDreamItem) {
kenburnsTransitionGenerator.reset()
binding.background.setImageDrawable(item.background)
binding.title.text = item.baseItem.name
}
}

View File

@@ -1,22 +1,143 @@
package org.jellyfin.androidtv.integration.dream
import android.graphics.drawable.Drawable
import android.service.dreams.DreamService
import org.jellyfin.androidtv.integration.dream.composable.DreamHost
import android.view.LayoutInflater
import androidx.core.view.isVisible
import com.bumptech.glide.Glide
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.databinding.DreamLibraryBinding
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.constant.ClockBehavior
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageFormat
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.constant.ItemSortBy
import org.koin.android.ext.android.inject
import timber.log.Timber
import java.util.concurrent.ExecutionException
/**
* An Android [DreamService] (screensaver) that shows TV series and movies from all libraries.
* Use `adb shell am start -n "com.android.systemui/.Somnambulator"` to start after changing the
* default screensaver in the device settings.
*/
class LibraryDreamService : DreamServiceCompat() {
class LibraryDreamService : DreamService() {
companion object {
const val INITIAL_DELAY = 2_000L // 2s
const val UPDATE_DELAY = 30_000L // 30s
const val TRANSITION_DURATION = 1_000L // 1s
}
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val api: ApiClient by inject()
private val userPreferences: UserPreferences by inject()
private lateinit var binding: DreamLibraryBinding
override fun onAttachedToWindow() {
super.onAttachedToWindow()
isInteractive = false
isFullscreen = true
setContent {
DreamHost()
binding = DreamLibraryBinding.inflate(LayoutInflater.from(window.context), null, false)
binding.clock.isVisible = when (userPreferences[UserPreferences.clockBehavior]) {
ClockBehavior.ALWAYS, ClockBehavior.IN_MENUS -> true
else -> false
}
setContentView(binding.root)
}
override fun onDreamingStarted() {
super.onDreamingStarted()
scope.launch {
delay(INITIAL_DELAY)
while (isActive) {
update()
delay(UPDATE_DELAY)
}
}
}
private suspend fun getRandomItem(): BaseItemDto? = try {
val response by api.itemsApi.getItemsByUserId(
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
recursive = true,
sortBy = listOf(ItemSortBy.Random),
limit = 1,
imageTypes = listOf(ImageType.BACKDROP)
)
response.items?.firstOrNull()
} catch (err: ApiClientException) {
Timber.e(err)
null
}
private suspend fun loadBackdrop(item: BaseItemDto): Drawable? {
val tag = item.backdropImageTags?.randomOrNull()
?: item.imageTags?.get(ImageType.BACKDROP)
val url = api.imageApi.getItemImageUrl(
itemId = item.id,
imageType = ImageType.BACKDROP,
tag = tag,
format = ImageFormat.WEBP,
)
return withContext(Dispatchers.IO) {
// Sometimes the backdrop doesn't exist
// make sure to return a null value in those cases
try {
Glide.with(binding.root)
.load(url)
.submit()
.get()
} catch (err: ExecutionException) {
Timber.e(err)
null
}
}
}
private suspend fun update() {
val item = getRandomItem()
val background = item?.let { loadBackdrop(it) }
withContext(Dispatchers.Main) {
if (item == null || background == null) {
binding.itemSwitcher.hideAllViews()
} else with(binding.itemSwitcher) {
getNextView<LibraryDreamItemView>().setItem(
item = LibraryDreamItem(
baseItem = item,
background = background,
)
)
showNextView()
}
}
}
override fun onDreamingStopped() {
super.onDreamingStopped()
scope.cancel()
}
}

View File

@@ -1,69 +0,0 @@
package org.jellyfin.androidtv.integration.dream.composable
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import org.jellyfin.androidtv.integration.dream.model.DreamContent
import org.jellyfin.androidtv.ui.composable.ZoomBox
import org.jellyfin.androidtv.ui.composable.overscan
@Composable
fun DreamContentLibraryShowcase(
content: DreamContent.LibraryShowcase,
) = Box(
modifier = Modifier.fillMaxSize(),
) {
ZoomBox(
initialValue = 1f,
targetValue = 1.1f,
delayMillis = 1_000,
durationMillis = 30_000,
) {
Image(
bitmap = content.backdrop.asImageBitmap(),
contentDescription = null,
alignment = Alignment.Center,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
// Image vignette
DreamContentVignette()
}
// Overlay
Row(
modifier = Modifier
.align(Alignment.BottomStart)
.overscan(),
) {
if (content.logo != null) {
Image(
bitmap = content.logo.asImageBitmap(),
contentDescription = content.item.name,
modifier = Modifier
.height(75.dp)
)
} else {
Text(
text = content.item.name.orEmpty(),
style = TextStyle(
color = Color.White,
fontSize = 32.sp
),
)
}
}
}

View File

@@ -1,32 +0,0 @@
package org.jellyfin.androidtv.integration.dream.composable
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.jellyfin.androidtv.R
@Composable
fun DreamContentLogo() = Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
) {
Image(
painter = painterResource(R.drawable.app_logo),
contentDescription = stringResource(R.string.app_name),
modifier = Modifier
.align(Alignment.Center)
.width(400.dp)
.fillMaxHeight()
)
}

View File

@@ -1,164 +0,0 @@
package org.jellyfin.androidtv.integration.dream.composable
import android.widget.ImageView
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import org.jellyfin.androidtv.integration.dream.model.DreamContent
import org.jellyfin.androidtv.ui.composable.AsyncImage
import org.jellyfin.androidtv.ui.composable.blurHashPainter
import org.jellyfin.androidtv.ui.composable.overscan
import org.jellyfin.androidtv.ui.playback.AudioEventListener
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.ImageFormat
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks
import org.koin.compose.koinInject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun DreamContentNowPlaying(
content: DreamContent.NowPlaying,
) = Box(
modifier = Modifier.fillMaxSize(),
) {
val api = koinInject<ApiClient>()
val mediaManager = koinInject<MediaManager>()
val item = content.item ?: return@Box
val primaryImageTag = item.imageTags?.get(ImageType.PRIMARY)
val (imageItemId, imageTag) = when {
primaryImageTag != null -> item.id to primaryImageTag
(item.albumId != null && item.albumPrimaryImageTag != null) -> item.albumId to item.albumPrimaryImageTag
else -> null to null
}
val imageBlurHash =
imageTag?.let { tag -> item.imageBlurHashes?.get(ImageType.PRIMARY)?.get(tag) }
if (imageBlurHash != null) {
Image(
painter = blurHashPainter(imageBlurHash, IntSize(32, 32)),
contentDescription = null,
alignment = Alignment.Center,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
DreamContentVignette()
}
// Overlay
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(20.dp),
modifier = Modifier
.align(Alignment.BottomStart)
.overscan(),
) {
if (imageItemId != null) {
AsyncImage(
url = api.imageApi.getItemImageUrl(
itemId = imageItemId,
imageType = ImageType.PRIMARY,
tag = imageTag,
format = ImageFormat.WEBP,
),
blurHash = imageBlurHash,
scaleType = ImageView.ScaleType.CENTER_CROP,
modifier = Modifier
.size(128.dp)
.clip(RoundedCornerShape(5.dp))
)
}
Column(
modifier = Modifier
.padding(bottom = 10.dp)
) {
Text(
text = item.name.orEmpty(),
style = TextStyle(
color = Color.White,
fontSize = 26.sp,
),
)
Text(
text = item.run {
val artistNames = artists.orEmpty()
val albumArtistNames = albumArtists?.mapNotNull { it.name }.orEmpty()
when {
artistNames.isNotEmpty() -> artistNames
albumArtistNames.isNotEmpty() -> albumArtistNames
else -> listOfNotNull(albumArtist)
}.joinToString(", ")
},
style = TextStyle(
color = Color(0.8f, 0.8f, 0.8f),
fontSize = 18.sp,
),
)
Spacer(modifier = Modifier.height(10.dp))
var progress by remember { mutableFloatStateOf(0f) }
DisposableEffect(Unit) {
val listener = object : AudioEventListener {
override fun onProgress(pos: Long) {
val duration = item.runTimeTicks?.ticks ?: Duration.ZERO
progress = (pos.milliseconds / duration).toFloat()
}
}
mediaManager.addAudioEventListener(listener)
onDispose {
mediaManager.removeAudioEventListener(listener)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.drawWithContent {
// Background
drawRect(Color.White, alpha = 0.2f)
// Foreground
drawRect(Color.White, size = size.copy(width = size.width * progress))
}
)
}
}
}

View File

@@ -1,34 +0,0 @@
package org.jellyfin.androidtv.integration.dream.composable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RadialGradientShader
import androidx.compose.ui.graphics.Shader
import androidx.compose.ui.graphics.ShaderBrush
private val vignetteBrush = object : ShaderBrush() {
override fun createShader(size: Size): Shader = RadialGradientShader(
colors = listOf(
Color.Black.copy(alpha = 0.2f),
Color.Black.copy(alpha = 0.7f),
),
center = size.center,
radius = maxOf(size.width, size.height) / 2f,
colorStops = listOf(0f, 0.95f)
)
}
@Composable
fun DreamContentVignette(
modifier: Modifier = Modifier
) = Box(
modifier = modifier
.background(vignetteBrush)
.fillMaxSize()
)

View File

@@ -1,72 +0,0 @@
package org.jellyfin.androidtv.integration.dream.composable
import android.text.format.DateUtils
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.composable.overscan
import org.jellyfin.androidtv.ui.composable.rememberCurrentTime
@Composable
fun DreamHeader(
showLogo: Boolean,
showClock: Boolean,
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.overscan(),
) {
// Logo
AnimatedVisibility(
visible = showLogo,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.height(41.dp),
) {
Image(
painter = painterResource(R.drawable.app_logo),
contentDescription = stringResource(R.string.app_name),
)
}
Spacer(
modifier = Modifier
.fillMaxWidth(0f)
)
// Clock
AnimatedVisibility(
visible = showClock,
enter = fadeIn(),
exit = fadeOut(),
) {
val time = rememberCurrentTime()
Text(
text = DateUtils.formatDateTime(LocalContext.current, time, DateUtils.FORMAT_SHOW_TIME),
style = TextStyle(
color = Color.White,
fontSize = 20.sp
),
)
}
}
}

View File

@@ -1,25 +0,0 @@
package org.jellyfin.androidtv.integration.dream.composable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import org.jellyfin.androidtv.integration.dream.DreamViewModel
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.constant.ClockBehavior
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@Composable
fun DreamHost() {
val viewModel = koinViewModel<DreamViewModel>()
val userPreferences = koinInject<UserPreferences>()
val content by viewModel.content.collectAsState()
DreamView(
content = content,
showClock = when (userPreferences[UserPreferences.clockBehavior]) {
ClockBehavior.ALWAYS, ClockBehavior.IN_MENUS -> true
else -> false
}
)
}

View File

@@ -1,42 +0,0 @@
package org.jellyfin.androidtv.integration.dream.composable
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.jellyfin.androidtv.integration.dream.model.DreamContent
@Composable
fun DreamView(
content: DreamContent,
showClock: Boolean,
) = Box(
modifier = Modifier
.fillMaxSize()
) {
AnimatedContent(
targetState = content,
transitionSpec = {
fadeIn(tween(durationMillis = 1_000)) togetherWith fadeOut(snap(delayMillis = 1_000))
},
label = "DreamContentTransition"
) { content ->
when (content) {
DreamContent.Logo -> DreamContentLogo()
is DreamContent.LibraryShowcase -> DreamContentLibraryShowcase(content)
is DreamContent.NowPlaying -> DreamContentNowPlaying(content)
}
}
// Header overlay
DreamHeader(
showLogo = content != DreamContent.Logo,
showClock = showClock,
)
}

View File

@@ -1,10 +0,0 @@
package org.jellyfin.androidtv.integration.dream.model
import android.graphics.Bitmap
import org.jellyfin.sdk.model.api.BaseItemDto
sealed interface DreamContent {
data object Logo : DreamContent
data class LibraryShowcase(val item: BaseItemDto, val backdrop: Bitmap, val logo: Bitmap?) : DreamContent
data class NowPlaying(val item: BaseItemDto?) : DreamContent
}

View File

@@ -7,18 +7,18 @@ import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import coil.ImageLoader
import coil.request.ImageRequest
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.R
import org.koin.android.ext.android.inject
import java.io.IOException
class ImageProvider : ContentProvider() {
private val imageLoader by inject<ImageLoader>()
override fun onCreate(): Boolean = true
override fun getType(uri: Uri) = null
@@ -33,40 +33,30 @@ class ImageProvider : ContentProvider() {
val (read, write) = ParcelFileDescriptor.createPipe()
val outputStream = ParcelFileDescriptor.AutoCloseOutputStream(write)
imageLoader.enqueue(ImageRequest.Builder(context!!).apply {
data(src)
error(R.drawable.placeholder_icon)
target(
onSuccess = { drawable -> writeDrawable(drawable, outputStream) },
onError = { drawable -> writeDrawable(requireNotNull(drawable), outputStream) }
)
}.build())
ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.IO) {
Glide.with(context!!)
.asBitmap()
.error(R.drawable.placeholder_icon)
.load(src)
.into(object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
@Suppress("DEPRECATION")
val format = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Bitmap.CompressFormat.WEBP_LOSSY
else -> Bitmap.CompressFormat.WEBP
}
resource.compress(format, 95, outputStream)
outputStream.close()
}
override fun onLoadCleared(placeholder: Drawable?) = outputStream.close()
})
}
return read
}
private fun writeDrawable(
drawable: Drawable,
outputStream: ParcelFileDescriptor.AutoCloseOutputStream
) {
@Suppress("DEPRECATION")
val format = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Bitmap.CompressFormat.WEBP_LOSSY
else -> Bitmap.CompressFormat.WEBP
}
try {
outputStream.use {
drawable.toBitmap().compress(format, COMPRESSION_QUALITY, outputStream)
}
} catch (_: IOException) {
// Ignore IOException as this is commonly thrown when the load request is cancelled
}
}
companion object {
private const val COMPRESSION_QUALITY = 95
/**
* Get a [Uri] that uses the [ImageProvider] to load an image. The input should be a valid
* Jellyfin image URL created using the SDK.

View File

@@ -6,9 +6,10 @@ import org.jellyfin.androidtv.constant.PosterSize
import org.jellyfin.androidtv.preference.store.DisplayPreferencesStore
import org.jellyfin.preference.booleanPreference
import org.jellyfin.preference.enumPreference
import org.jellyfin.preference.stringPreference
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.constant.ItemSortBy
class LibraryPreferences(
displayPreferencesId: String,
@@ -28,7 +29,7 @@ class LibraryPreferences(
val filterUnwatchedOnly = booleanPreference("FilterUnwatchedOnly", false)
// Item sorting
val sortBy = enumPreference("SortBy", ItemSortBy.SORT_NAME)
val sortBy = stringPreference("SortBy", ItemSortBy.SortName)
val sortOrder = enumPreference("SortOrder", SortOrder.ASCENDING)
}
}

View File

@@ -4,7 +4,7 @@ import org.jellyfin.androidtv.preference.store.DisplayPreferencesStore
import org.jellyfin.preference.booleanPreference
import org.jellyfin.preference.stringPreference
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.constant.ItemSortBy
class LiveTvPreferences(
api: ApiClient,
@@ -13,7 +13,7 @@ class LiveTvPreferences(
api = api,
) {
companion object {
val channelOrder = stringPreference("livetv-channelorder", ItemSortBy.DATE_PLAYED.name)
val channelOrder = stringPreference("livetv-channelorder", ItemSortBy.DatePlayed)
val colorCodeGuide = booleanPreference("guide-colorcodedbackgrounds", false)
val favsAtTop = booleanPreference("livetv-favoritechannelsattop", true)
val showHDIndicator = booleanPreference("guide-indicator-hd", false)

View File

@@ -1,7 +1,9 @@
package org.jellyfin.androidtv.preference
import android.content.Context
import org.jellyfin.androidtv.preference.constant.PreferredVideoPlayer
import org.jellyfin.preference.booleanPreference
import org.jellyfin.preference.enumPreference
import org.jellyfin.preference.store.SharedPreferenceStore
import org.jellyfin.preference.stringPreference
@@ -58,14 +60,14 @@ class SystemPreferences(context: Context) : SharedPreferenceStore(
val liveTvGuideFilterSports = booleanPreference("guide_filter_sports", false)
// Other persistent variables
/**
* Chosen player for play with button. Changes every time user chooses a player with "play with" button.
*/
var chosenPlayer = enumPreference("chosen_player", PreferredVideoPlayer.VLC)
/**
* The version name for the latest dismissed beta notification or empty if none.
*/
val dismissedBetaNotificationVersion = stringPreference("dismissed_beta_notification_version", "")
/**
* Whether to disable the "UI mode" warning that shows when using the app on non TV devices.
*/
val disableUiModeWarning = booleanPreference("disable_ui_mode_warning", false)
}
}

View File

@@ -7,16 +7,19 @@ import org.jellyfin.androidtv.preference.constant.AppTheme
import org.jellyfin.androidtv.preference.constant.AudioBehavior
import org.jellyfin.androidtv.preference.constant.ClockBehavior
import org.jellyfin.androidtv.preference.constant.NextUpBehavior
import org.jellyfin.androidtv.preference.constant.PreferredVideoPlayer
import org.jellyfin.androidtv.preference.constant.RatingType
import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior
import org.jellyfin.androidtv.preference.constant.WatchedIndicatorBehavior
import org.jellyfin.androidtv.preference.constant.defaultAudioBehavior
import org.jellyfin.androidtv.util.DeviceUtils
import org.jellyfin.preference.booleanPreference
import org.jellyfin.preference.enumPreference
import org.jellyfin.preference.intPreference
import org.jellyfin.preference.longPreference
import org.jellyfin.preference.migration.putEnum
import org.jellyfin.preference.store.SharedPreferenceStore
import org.jellyfin.preference.stringPreference
import kotlin.time.Duration.Companion.minutes
/**
* User preferences are configurable by the user and change behavior of the application.
@@ -28,6 +31,11 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
) {
companion object {
/**
* The value used for automatic detection in [maxBitrate].
*/
const val MAX_BITRATE_AUTO = "0"
/* Display */
/**
* Select the app theme
@@ -44,14 +52,10 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
*/
var premieresEnabled = booleanPreference("pref_enable_premieres", false)
/**
* Enable management of media like deleting items when the user has sufficient permisisons.
*/
var mediaManagementEnabled = booleanPreference("enable_media_management", false)
/* Playback - General*/
/**
* Maximum bitrate in megabit for playback.
* Maximum bitrate in megabit for playback. A value of [MAX_BITRATE_AUTO] is used when
* the bitrate should be automatically detected.
*/
var maxBitrate = stringPreference("pref_max_bitrate", "100")
@@ -83,9 +87,9 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
/* Playback - Video */
/**
* Whether to use an external playback application or not.
* Preferred video player.
*/
var useExternalPlayer = booleanPreference("external_player", false)
var videoPlayer = enumPreference("video_player", PreferredVideoPlayer.EXOPLAYER)
/**
* Change refresh rate to match media when device supports it
@@ -96,17 +100,27 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
/**
* Preferred behavior for audio streaming.
*/
var audioBehaviour = enumPreference("audio_behavior", AudioBehavior.DIRECT_STREAM)
var audioBehaviour = enumPreference("audio_behavior", defaultAudioBehavior)
/**
* Preferred behavior for audio streaming.
*/
var audioNightMode = enumPreference("audio_night_mode", false)
/**
* Enable DTS
*/
var dtsEnabled = booleanPreference("pref_bitstream_dts", false)
/**
* Enable AC3
*/
var ac3Enabled = booleanPreference("pref_bitstream_ac3", true)
var ac3Enabled = booleanPreference("pref_bitstream_ac3", !DeviceUtils.isFireTvStickGen1)
/**
* Default audio delay in milliseconds for libVLC
*/
var libVLCAudioDelay = intPreference("libvlc_audio_delay", 0)
/* Live TV */
/**
@@ -114,6 +128,11 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
*/
var liveTvDirectPlayEnabled = booleanPreference("pref_live_direct", true)
/**
* Preferred video player for live TV
*/
var liveTvVideoPlayer = enumPreference("live_tv_video_player", PreferredVideoPlayer.AUTO)
/**
* Shortcut used for changing the audio track
*/
@@ -131,9 +150,9 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
var debuggingEnabled = booleanPreference("pref_enable_debug", false)
/**
* Use playback rewrite module for video
* Use playback rewrite module
*/
var playbackRewriteVideoEnabled = booleanPreference("playback_new", false)
var playbackRewriteEnabled = booleanPreference("playback_new", false)
/**
* When to show the clock.
@@ -179,37 +198,47 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
* Subtitles foreground color
*/
var subtitlesTextColor = longPreference("subtitles_text_color", 0xFFFFFFFF)
/**
* Show screensaver in app
*/
var screensaverInAppEnabled = booleanPreference("screensaver_inapp_enabled", true)
/**
* Timeout before showing the screensaver in app, depends on [screensaverInAppEnabled].
*/
var screensaverInAppTimeout = longPreference("screensaver_inapp_timeout", 5.minutes.inWholeMilliseconds)
/**
* Age rating used to filter items in the screensaver. Use -1 to disable (omits parameter from requests).
*/
var screensaverAgeRatingMax = intPreference("screensaver_agerating_max", 13)
/**
* Whether items shown in the screensaver are required to have an age rating set.
*/
var screensaverAgeRatingRequired = booleanPreference("screensaver_agerating_required", true)
}
init {
// Note: Create a single migration per app version
// Note: Migrations are never executed for fresh installs
// Note: Old migrations are removed occasionally
runMigrations {
// v0.15.z to v0.16.0
migration(toVersion = 7) {
// Enable playback rewrite for music
putBoolean("playback_new_audio", true)
// v0.10.x to v0.11.x
migration(toVersion = 2) {
// Migrate to video player enum
// Note: This is the only time we need to check if the value is not set yet because the version numbers were reset
if (!it.contains("video_player"))
putEnum("video_player", if (it.getBoolean("pref_video_use_external", false)) PreferredVideoPlayer.EXTERNAL else PreferredVideoPlayer.AUTO)
}
// v0.11.x to v0.12.x
migration(toVersion = 5) {
// Migrate to audio behavior enum
putEnum("audio_behavior", if (it.getString("pref_audio_option", "0") == "1") AudioBehavior.DOWNMIX_TO_STEREO else AudioBehavior.DIRECT_STREAM)
// Migrate live tv player to use enum
putEnum("live_tv_video_player",
when {
it.getBoolean("pref_live_tv_use_external", false) -> PreferredVideoPlayer.EXTERNAL
it.getBoolean("pref_enable_vlc_livetv", false) -> PreferredVideoPlayer.VLC
else -> PreferredVideoPlayer.AUTO
})
// Change audio delay type from long to int
putInt("libvlc_audio_delay", it.getLong("libvlc_audio_delay", 0).toInt())
// Disable AC3 (Dolby Digital) on Fire Stick Gen 1 devices
if (DeviceUtils.isFireTvStickGen1) putBoolean("pref_bitstream_ac3", false)
}
// v0.13.3 to v0.13.4
migration(toVersion = 6) {
putEnum("refresh_rate_switching_behavior",
when {
it.getBoolean("pref_refresh_switching", false) -> RefreshRateSwitchingBehavior.SCALE_ON_TV
else -> RefreshRateSwitchingBehavior.DISABLED
})
}
}
}

View File

@@ -14,8 +14,8 @@ class UserSettingPreferences(
app = "emby",
) {
companion object {
val skipBackLength = intPreference("skipBackLength", 10_000)
val skipForwardLength = intPreference("skipForwardLength", 30_000)
val skipBackLength = intPreference("skipBackLength", 10000)
val skipForwardLength = intPreference("skipForwardLength", 30000)
val homesection0 = enumPreference("homesection0", HomeSectionType.LIBRARY_TILES_SMALL)
val homesection1 = enumPreference("homesection1", HomeSectionType.RESUME)

View File

@@ -1,23 +1,24 @@
package org.jellyfin.androidtv.preference.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
enum class AppTheme(
override val nameRes: Int,
) : PreferenceEnum {
enum class AppTheme {
/**
* The default dark theme
*/
DARK(R.string.pref_theme_dark),
@EnumDisplayOptions(R.string.pref_theme_dark)
DARK,
/**
* The "classic" emerald theme
*/
EMERALD(R.string.pref_theme_emerald),
@EnumDisplayOptions(R.string.pref_theme_emerald)
EMERALD,
/**
* A theme with a more muted accent color, inspired by CTalvio's Monochromic CSS theme for Jellyfin Web
*/
MUTED_PURPLE(R.string.pref_theme_muted_purple),
@EnumDisplayOptions(R.string.pref_theme_muted_purple)
MUTED_PURPLE,
}

View File

@@ -1,18 +1,22 @@
package org.jellyfin.androidtv.preference.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
import org.jellyfin.androidtv.util.DeviceUtils
enum class AudioBehavior(
override val nameRes: Int,
) : PreferenceEnum {
enum class AudioBehavior {
/**
* Directly stream audio without any changes
*/
DIRECT_STREAM(R.string.pref_audio_direct),
@EnumDisplayOptions(R.string.pref_audio_direct)
DIRECT_STREAM,
/**
* Downnmix audio to stereo. Disables the AC3, EAC3 and AAC_LATM audio codecs.
*/
DOWNMIX_TO_STEREO(R.string.pref_audio_compat),
@EnumDisplayOptions(R.string.pref_audio_compat)
DOWNMIX_TO_STEREO
}
val defaultAudioBehavior = if (DeviceUtils.isChromecastWithGoogleTV) AudioBehavior.DOWNMIX_TO_STEREO
else AudioBehavior.DIRECT_STREAM

View File

@@ -1,28 +1,30 @@
package org.jellyfin.androidtv.preference.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
enum class ClockBehavior(
override val nameRes: Int,
) : PreferenceEnum {
enum class ClockBehavior {
/**
* Always show clock.
*/
ALWAYS(R.string.lbl_always),
@EnumDisplayOptions(R.string.lbl_always)
ALWAYS,
/**
* Show clock in menus only.
*/
IN_MENUS(R.string.pref_clock_display_browsing),
@EnumDisplayOptions(R.string.pref_clock_display_browsing)
IN_MENUS,
/**
* Show clock in video only.
*/
IN_VIDEO(R.string.pref_clock_display_playback),
@EnumDisplayOptions(R.string.pref_clock_display_playback)
IN_VIDEO,
/**
* Show clock never.
*/
NEVER(R.string.lbl_never),
@EnumDisplayOptions(R.string.lbl_never)
NEVER
}

View File

@@ -1,18 +1,20 @@
package org.jellyfin.androidtv.preference.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
import org.jellyfin.sdk.model.constant.ItemSortBy
enum class LiveTvChannelOrder(
override val nameRes: Int,
val stringValue: String,
) : PreferenceEnum {
LAST_PLAYED(R.string.lbl_guide_option_played, ItemSortBy.DATE_PLAYED.serialName),
CHANNEL_NUMBER(R.string.lbl_guide_option_number, ItemSortBy.SORT_NAME.serialName);
val stringValue: String
) {
@EnumDisplayOptions(R.string.lbl_guide_option_played)
LAST_PLAYED(ItemSortBy.DatePlayed),
@EnumDisplayOptions(R.string.lbl_guide_option_number)
CHANNEL_NUMBER(ItemSortBy.SortName);
companion object {
fun fromString(value: String) = entries
fun fromString(value: String) = values()
.firstOrNull { it.stringValue.equals(value, ignoreCase = true) }
?: LAST_PLAYED
}

View File

@@ -1,25 +1,26 @@
package org.jellyfin.androidtv.preference.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
enum class NextUpBehavior(
override val nameRes: Int,
) : PreferenceEnum {
enum class NextUpBehavior {
/**
* Enable the Next Up screen - show the item's thumbnail
*/
EXTENDED(R.string.lbl_next_up_extended),
@EnumDisplayOptions(R.string.lbl_next_up_extended)
EXTENDED,
/**
* Enable the Next Up screen - hide the item's thumbnail
*/
MINIMAL(R.string.lbl_next_up_minimal),
@EnumDisplayOptions(R.string.lbl_next_up_minimal)
MINIMAL,
/**
* Disable the Next Up screen
*/
DISABLED(R.string.lbl_never),
@EnumDisplayOptions(R.string.lbl_never)
DISABLED
}
const val NEXTUP_TIMER_DISABLED: Int = 0

View File

@@ -0,0 +1,36 @@
package org.jellyfin.androidtv.preference.constant
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
enum class PreferredVideoPlayer {
/**
* Automatically selects between exoplayer and vlc
*/
@EnumDisplayOptions(R.string.pref_video_player_auto)
AUTO,
/**
* Force ExoPlayer
*/
@EnumDisplayOptions(R.string.pref_video_player_exoplayer)
EXOPLAYER,
/**
* Force libVLC
*/
@EnumDisplayOptions(R.string.pref_video_player_vlc)
VLC,
/**
* Use external player
*/
@EnumDisplayOptions(R.string.pref_video_player_external)
EXTERNAL,
/**
* Choose a player - play with button
*/
@EnumDisplayOptions(R.string.pref_video_player_choose)
CHOOSE
}

View File

@@ -1,23 +1,24 @@
package org.jellyfin.androidtv.preference.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
enum class RatingType(
override val nameRes: Int,
) : PreferenceEnum {
enum class RatingType {
/**
* Sets default rating type to tomatoes.
*/
RATING_TOMATOES(R.string.lbl_tomatoes),
@EnumDisplayOptions(R.string.lbl_tomatoes)
RATING_TOMATOES,
/**
* Sets the default rating type to stars.
*/
RATING_STARS(R.string.lbl_stars),
@EnumDisplayOptions(R.string.lbl_stars)
RATING_STARS,
/**
* Sets the default rating type to hidden.
*/
RATING_HIDDEN(R.string.lbl_hidden),
@EnumDisplayOptions(R.string.lbl_hidden)
RATING_HIDDEN
}

View File

@@ -1,21 +1,22 @@
package org.jellyfin.androidtv.preference.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
enum class RefreshRateSwitchingBehavior(
override val nameRes: Int,
) : PreferenceEnum {
DISABLED(R.string.state_disabled),
enum class RefreshRateSwitchingBehavior {
@EnumDisplayOptions(R.string.state_disabled)
DISABLED,
/**
* When comparing modes, use difference in resolution to rank modes.
*/
SCALE_ON_TV(R.string.pref_refresh_rate_scale_on_tv),
@EnumDisplayOptions(R.string.pref_refresh_rate_scale_on_tv)
SCALE_ON_TV,
/**
* When comparing modes, rank native resolution modes highest.
* Otherwise use difference in resolution to rank modes.
*/
SCALE_ON_DEVICE(R.string.pref_refresh_rate_scale_on_device),
@EnumDisplayOptions(R.string.pref_refresh_rate_scale_on_device)
SCALE_ON_DEVICE,
}

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