Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2b60bf78a | ||
|
|
e2ede5b7be | ||
|
|
985e4ef9a7 | ||
|
|
7d5895134b | ||
|
|
dc5bf6b47d | ||
|
|
147fc4f772 | ||
|
|
ecead3fad8 | ||
|
|
834bebb03f | ||
|
|
a001846da7 | ||
|
|
5c7bf941ea | ||
|
|
53b92184a4 | ||
|
|
1e369480fd | ||
|
|
e00f6d3f40 | ||
|
|
5e85a876bd | ||
|
|
5249209f3a | ||
|
|
276e138d7a | ||
|
|
00cc8f14ec | ||
|
|
f14a8fa6b8 | ||
|
|
6191044057 | ||
|
|
d0365ced7e | ||
|
|
9c8143368f | ||
|
|
266d5738a6 | ||
|
|
e34442a432 | ||
|
|
12e4c96304 | ||
|
|
857ad84771 | ||
|
|
7dfd3b9179 | ||
|
|
63de8d16d2 | ||
|
|
b771bb4c27 | ||
|
|
2b01ac9ffc | ||
|
|
b857fb4f1c | ||
|
|
19707eb9c8 | ||
|
|
c60d210e87 | ||
|
|
cb046c26c5 | ||
|
|
3d97b5abb0 | ||
|
|
da8c7c2837 | ||
|
|
d9c11d86ad | ||
|
|
b9172845d8 | ||
|
|
3d3fa76771 | ||
|
|
521183d427 |
@@ -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
|
||||
8
.github/workflows/app-build.yaml
vendored
8
.github/workflows/app-build.yaml
vendored
@@ -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
|
||||
|
||||
8
.github/workflows/app-lint.yaml
vendored
8
.github/workflows/app-lint.yaml
vendored
@@ -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: .
|
||||
|
||||
40
.github/workflows/app-publish.yaml
vendored
40
.github/workflows/app-publish.yaml
vendored
@@ -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 .;
|
||||
|
||||
6
.github/workflows/app-test.yaml
vendored
6
.github/workflows/app-test.yaml
vendored
@@ -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
18
.github/workflows/gradlew-update.yaml
vendored
Normal 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 }}
|
||||
4
.github/workflows/gradlew-validate.yaml
vendored
4
.github/workflows/gradlew-validate.yaml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/repo-merge-conflict.yaml
vendored
2
.github/workflows/repo-merge-conflict.yaml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/repo-stale.yaml
vendored
2
.github/workflows/repo-stale.yaml
vendored
@@ -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
1
.gitignore
vendored
@@ -4,6 +4,5 @@ local.properties
|
||||
|
||||
/.idea
|
||||
.gradle
|
||||
.kotlin/
|
||||
build/
|
||||
captures/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
39
app/baselineWorkaround.gradle
Normal file
39
app/baselineWorkaround.gradle
Normal 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, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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?) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.jellyfin.androidtv
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bumptech.glide.GlideBuilder
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.module.AppGlideModule
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
|
||||
@GlideModule
|
||||
class JellyfinGlideModule : AppGlideModule() {
|
||||
override fun applyOptions(context: Context, builder: GlideBuilder): Unit = with(builder) {
|
||||
setDefaultRequestOptions(
|
||||
// Set default disk cache strategy
|
||||
RequestOptions().diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||
)
|
||||
|
||||
// Silence image load errors
|
||||
setLogLevel(Log.ERROR)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.jellyfin.androidtv
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.startup.AppInitializer
|
||||
import androidx.startup.Initializer
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.auth.repository.SessionRepository
|
||||
import org.jellyfin.androidtv.di.KoinInitializer
|
||||
import timber.log.Timber
|
||||
|
||||
@Suppress("unused")
|
||||
class ProcessLifecycleInitializer : Initializer<Unit> {
|
||||
override fun create(context: Context) {
|
||||
val koin = AppInitializer.getInstance(context)
|
||||
.initializeComponent(KoinInitializer::class.java)
|
||||
.koin
|
||||
|
||||
// Register application lifecycle events
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
/**
|
||||
* Called by the Process Lifecycle when the app is created. It is called after [onCreate].
|
||||
*/
|
||||
override fun onCreate(owner: LifecycleOwner) {
|
||||
// Register activity lifecycle callbacks
|
||||
val callbacks = koin.getAll<Application.ActivityLifecycleCallbacks>()
|
||||
Timber.i("Registering ${callbacks.size} ActivityLifecycleCallbacks")
|
||||
val app = context.applicationContext as Application
|
||||
callbacks.forEach { callback -> app.registerActivityLifecycleCallbacks(callback) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the Process Lifecycle when the app is activated in the foreground (activity opened).
|
||||
*/
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
Timber.i("Process lifecycle started")
|
||||
|
||||
owner.lifecycleScope.launch {
|
||||
koin.get<SessionRepository>().restoreSession()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun dependencies() = listOf(KoinInitializer::class.java)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -1,11 +1,11 @@
|
||||
package org.jellyfin.androidtv.auth.model
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
|
||||
|
||||
enum class AuthenticationSortBy(
|
||||
override val nameRes: Int
|
||||
) : PreferenceEnum {
|
||||
LAST_USE(R.string.last_use),
|
||||
ALPHABETICAL(R.string.alphabetical);
|
||||
enum class AuthenticationSortBy {
|
||||
@EnumDisplayOptions(name = R.string.last_use)
|
||||
LAST_USE,
|
||||
@EnumDisplayOptions(name = R.string.alphabetical)
|
||||
ALPHABETICAL
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import java.time.Instant
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -20,7 +20,7 @@ data class AuthenticationStoreServer(
|
||||
@SerialName("login_disclaimer") val loginDisclaimer: String? = null,
|
||||
@SerialName("splashscreen_enabled") val splashscreenEnabled: Boolean = false,
|
||||
@SerialName("setup_completed") val setupCompleted: Boolean = true,
|
||||
@SerialName("last_used") val lastUsed: Long = Instant.now().toEpochMilli(),
|
||||
@SerialName("last_refreshed") val lastRefreshed: Long = Instant.now().toEpochMilli(),
|
||||
@SerialName("last_used") val lastUsed: Long = Date().time,
|
||||
@SerialName("last_refreshed") val lastRefreshed: Long = Date().time,
|
||||
val users: Map<UUID, AuthenticationStoreUser> = emptyMap(),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.jellyfin.androidtv.auth.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Instant
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Locally stored user information. New properties require default values or deserialization will fail.
|
||||
@@ -10,7 +10,7 @@ import java.time.Instant
|
||||
@Serializable
|
||||
data class AuthenticationStoreUser(
|
||||
val name: String,
|
||||
@SerialName("last_used") val lastUsed: Long = Instant.now().toEpochMilli(),
|
||||
@SerialName("image_tag") val imageTag: String? = null,
|
||||
@SerialName("access_token") val accessToken: String? = null,
|
||||
@SerialName("last_used") val lastUsed: Long = Date().time,
|
||||
@SerialName("require_password") val requirePassword: Boolean = true,
|
||||
@SerialName("image_tag") val imageTag: String? = null
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -4,6 +4,9 @@ enum class ChangeTriggerType {
|
||||
LibraryUpdated,
|
||||
MoviePlayback,
|
||||
TvPlayback,
|
||||
GuideNeedsLoad,
|
||||
MusicPlayback,
|
||||
Always,
|
||||
VideoQueueChange,
|
||||
FavoriteUpdate,
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ object Codec {
|
||||
const val ASF = "asf"
|
||||
const val AVI = "avi"
|
||||
const val DVR_MS = "dvr-ms"
|
||||
const val HLS = "hls"
|
||||
const val M2V = "m2v"
|
||||
const val M4V = "m4v"
|
||||
const val MKV = "mkv"
|
||||
@@ -48,10 +47,8 @@ object Codec {
|
||||
const val PCM_ALAW = "pcm_alaw"
|
||||
const val PCM_MULAW = "pcm_mulaw"
|
||||
const val PCM_S16LE = "pcm_s16le"
|
||||
const val PCM_S20LE = "pcm_s20le"
|
||||
const val PCM_S24LE = "pcm_s24le"
|
||||
const val TRUEHD = "truehd"
|
||||
const val VORBIS = "vorbis"
|
||||
const val WAV = "wav"
|
||||
const val WEBMA = "webma"
|
||||
const val WMA = "wma"
|
||||
@@ -65,7 +62,6 @@ object Codec {
|
||||
const val MPEG2VIDEO = "mpeg2video"
|
||||
const val VP8 = "vp8"
|
||||
const val VP9 = "vp9"
|
||||
const val AV1 = "av1"
|
||||
}
|
||||
|
||||
object Subtitle {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -2,6 +2,5 @@ package org.jellyfin.androidtv.constant
|
||||
|
||||
object Extras {
|
||||
const val Folder = "folder"
|
||||
const val IsLiveTvSeriesRecordings = "is_livetv_series_recordings"
|
||||
const val IncludeType = "type_include"
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
package org.jellyfin.androidtv.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
|
||||
|
||||
enum class GridDirection(
|
||||
override val nameRes: Int,
|
||||
) : PreferenceEnum {
|
||||
enum class GridDirection {
|
||||
/**
|
||||
* Horizontal.
|
||||
*/
|
||||
HORIZONTAL(R.string.grid_direction_horizontal),
|
||||
@EnumDisplayOptions(R.string.grid_direction_horizontal)
|
||||
HORIZONTAL,
|
||||
|
||||
/**
|
||||
* Vertical.
|
||||
*/
|
||||
VERTICAL(R.string.grid_direction_vertical),
|
||||
@EnumDisplayOptions(R.string.grid_direction_vertical)
|
||||
VERTICAL,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.jellyfin.androidtv.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
|
||||
/**
|
||||
@@ -9,17 +10,35 @@ import org.jellyfin.preference.PreferenceEnum
|
||||
* https://github.com/jellyfin/jellyfin-web/blob/master/src/components/homesections/homesections.js
|
||||
*/
|
||||
enum class HomeSectionType(
|
||||
override val serializedName: String,
|
||||
override val nameRes: Int,
|
||||
override val serializedName: String
|
||||
) : PreferenceEnum {
|
||||
LATEST_MEDIA("latestmedia", R.string.home_section_latest_media),
|
||||
LIBRARY_TILES_SMALL("smalllibrarytiles", R.string.home_section_library),
|
||||
LIBRARY_BUTTONS("librarybuttons", R.string.home_section_library_small),
|
||||
RESUME("resume", R.string.home_section_resume),
|
||||
RESUME_AUDIO("resumeaudio", R.string.home_section_resume_audio),
|
||||
RESUME_BOOK("resumebook", R.string.home_section_resume_book),
|
||||
ACTIVE_RECORDINGS("activerecordings", R.string.home_section_active_recordings),
|
||||
NEXT_UP("nextup", R.string.home_section_next_up),
|
||||
LIVE_TV("livetv", R.string.home_section_livetv),
|
||||
NONE("none", R.string.home_section_none),
|
||||
@EnumDisplayOptions(R.string.home_section_latest_media)
|
||||
LATEST_MEDIA("latestmedia"),
|
||||
|
||||
@EnumDisplayOptions(R.string.home_section_library)
|
||||
LIBRARY_TILES_SMALL("smalllibrarytiles"),
|
||||
|
||||
@EnumDisplayOptions(R.string.home_section_library_small)
|
||||
LIBRARY_BUTTONS("librarybuttons"),
|
||||
|
||||
@EnumDisplayOptions(R.string.home_section_resume)
|
||||
RESUME("resume"),
|
||||
|
||||
@EnumDisplayOptions(R.string.home_section_resume_audio)
|
||||
RESUME_AUDIO("resumeaudio"),
|
||||
|
||||
@EnumDisplayOptions(R.string.home_section_resume_book)
|
||||
RESUME_BOOK("resumebook"),
|
||||
|
||||
@EnumDisplayOptions(R.string.home_section_active_recordings)
|
||||
ACTIVE_RECORDINGS("activerecordings"),
|
||||
|
||||
@EnumDisplayOptions(R.string.home_section_next_up)
|
||||
NEXT_UP("nextup"),
|
||||
|
||||
@EnumDisplayOptions(R.string.home_section_livetv)
|
||||
LIVE_TV("livetv"),
|
||||
|
||||
@EnumDisplayOptions(R.string.home_section_none)
|
||||
NONE("none"),
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
package org.jellyfin.androidtv.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.preference.PreferenceEnum
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
|
||||
|
||||
enum class ImageType(
|
||||
override val nameRes: Int,
|
||||
) : PreferenceEnum {
|
||||
enum class ImageType {
|
||||
/**
|
||||
* Poster.
|
||||
*/
|
||||
POSTER(R.string.image_type_poster),
|
||||
@EnumDisplayOptions(R.string.image_type_poster)
|
||||
POSTER,
|
||||
|
||||
/**
|
||||
* Thumbnail.
|
||||
*/
|
||||
THUMB(R.string.image_type_thumbnail),
|
||||
@EnumDisplayOptions(R.string.image_type_thumbnail)
|
||||
THUMB,
|
||||
|
||||
/**
|
||||
* Banner.
|
||||
*/
|
||||
BANNER(R.string.image_type_banner),
|
||||
@EnumDisplayOptions(R.string.image_type_banner)
|
||||
BANNER
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ enum class QueryType {
|
||||
LiveTvProgram,
|
||||
LiveTvRecording,
|
||||
StaticItems,
|
||||
Persons,
|
||||
StaticAudioQueueItems,
|
||||
Artists,
|
||||
AlbumArtists,
|
||||
AudioPlaylists,
|
||||
LatestItems,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,4 @@ package org.jellyfin.androidtv.data.model
|
||||
data class AppNotification(
|
||||
val message: String,
|
||||
val dismiss: () -> Unit,
|
||||
val public: Boolean,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package org.jellyfin.androidtv.data.querying;
|
||||
|
||||
data class AdditionalPartsQuery(val itemId: String)
|
||||
@@ -1,7 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.querying;
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class GetAdditionalPartsRequest(
|
||||
val itemId: UUID,
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.querying
|
||||
|
||||
data object GetSeriesTimersRequest
|
||||
@@ -1,7 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.querying
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class GetSpecialsRequest(
|
||||
val itemId: UUID,
|
||||
)
|
||||
@@ -1,7 +0,0 @@
|
||||
package org.jellyfin.androidtv.data.querying
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class GetTrailersRequest(
|
||||
val itemId: UUID,
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.jellyfin.androidtv.data.querying
|
||||
|
||||
data class SpecialsQuery(
|
||||
val itemId: String,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.jellyfin.androidtv.data.querying
|
||||
|
||||
data class TrailersQuery(
|
||||
val itemId: String,
|
||||
)
|
||||
@@ -1,3 +1,3 @@
|
||||
package org.jellyfin.androidtv.data.querying
|
||||
|
||||
data object GetUserViewsRequest
|
||||
object ViewQuery
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.jellyfin.androidtv.di
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.app.UiModeManager
|
||||
import android.media.AudioManager
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.work.WorkManager
|
||||
import org.koin.android.ext.koin.androidApplication
|
||||
@@ -11,7 +11,7 @@ import org.koin.dsl.module
|
||||
* Provides DI for Android system components
|
||||
*/
|
||||
val androidModule = module {
|
||||
factory { androidApplication().getSystemService<AccountManager>()!! }
|
||||
factory { androidApplication().getSystemService<UiModeManager>()!! }
|
||||
factory { androidApplication().getSystemService<AudioManager>()!! }
|
||||
factory { WorkManager.getInstance(get()) }
|
||||
}
|
||||
|
||||
@@ -1,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()) }
|
||||
}
|
||||
|
||||
@@ -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()) }
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package org.jellyfin.androidtv.di
|
||||
|
||||
import org.jellyfin.androidtv.util.AutoBitrate
|
||||
import org.jellyfin.androidtv.util.ImageHelper
|
||||
import org.koin.dsl.module
|
||||
|
||||
val utilsModule = module {
|
||||
single { AutoBitrate(get()) }
|
||||
single { ImageHelper(get()) }
|
||||
}
|
||||
|
||||
@@ -1,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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user