Compare commits

..

20 Commits

Author SHA1 Message Date
Niels van Velzen
7d84011d81 Re-use supportsHevc in one additional place
(cherry picked from commit 6111cbec5a)
2024-08-11 09:59:40 +02:00
Joseph Price
36a307cd7f Really don't request HEVC if unsupported
(cherry picked from commit c7a4c89b74)
2024-08-11 09:59:39 +02:00
Niels van Velzen
2a122803c2 Fix InfoRow not using requested media source
(cherry picked from commit b0cd0c5b37)
2024-08-11 09:59:39 +02:00
Niels van Velzen
dbf6dc0f9a Reset display mode when exiting video player
(cherry picked from commit aa5fb63b71)
2024-08-11 09:59:38 +02:00
Niels van Velzen
fd663aff39 Improve InfoRow colors/font readability
(cherry picked from commit d661583a86)
2024-08-11 09:59:38 +02:00
Niels van Velzen
30e34568c1 Fix intros showing next up screen
(cherry picked from commit fe31c19f15)
2024-08-11 09:59:37 +02:00
Niels van Velzen
5fb77314c4 Fix crash when starting next episode
(cherry picked from commit fcd9751d82)
2024-08-11 09:59:37 +02:00
Niels van Velzen
d436c5de3d Graduate reactive homepage to stable
(cherry picked from commit f8c5345871)
2024-08-11 09:59:36 +02:00
renovate[bot]
8157e3e5c1 Update dependency androidx.tv:tv-material to v1.0.0-rc02
(cherry picked from commit 0385f63fbe)
2024-08-11 09:59:20 +02:00
renovate[bot]
3656c18e8f Update dependency androidx.work:work-runtime to v2.9.1
(cherry picked from commit 6cc1031b8a)
2024-08-11 09:59:19 +02:00
renovate[bot]
9f48b7da39 Update dependency org.jellyfin.sdk:jellyfin-core to v1.5.3
(cherry picked from commit 587f0fb35a)
2024-08-11 09:59:19 +02:00
Niels van Velzen
ef899f98bc Fix SelectQualityAction crashing when opening menu
(cherry picked from commit 044ce67110)
2024-08-06 15:20:34 +02:00
Niels van Velzen
de6c7904f0 Use "direct play" as default audio behavior
(cherry picked from commit f6c536880d)
2024-08-06 15:20:34 +02:00
Marc Vollmer
0a79d5b60f Fix NullPointerException in LeanbackOverlayFragment.onPause
(cherry picked from commit 3b852a031e)
2024-08-06 15:20:33 +02:00
Niels van Velzen
36e0bc1edc Fix NullPointerException in CustomPlaybackOverlayFragment.onResume
(cherry picked from commit 7bc99d2e66)
2024-08-06 15:20:33 +02:00
Niels van Velzen
8faeb3aac1 Remove context requirement from ExoPlayerProfile
(cherry picked from commit 9ecf952351)
2024-08-06 15:20:11 +02:00
majmongoose
160ef4914f Add Google Chromecast HD limitation (#3820)
* Add identification for Google Chromecast HD

Possibly an upstream bug is preventing this from working, as the chromecast is being idenitified correctly in debugging.

* Update DeviceUtils.kt

Added a note for the reason of the change.

* Removed unintentional imports

* Update app/src/main/java/org/jellyfin/androidtv/util/DeviceUtils.kt

Make changes suggested by maintainer.

Co-authored-by: Niels van Velzen <nielsvanvelzen@users.noreply.github.com>

* Update DeviceUtils.kt

Removed unneeded isChromecastHD

---------

Co-authored-by: Niels van Velzen <nielsvanvelzen@users.noreply.github.com>
(cherry picked from commit a8c9f054ab)
2024-08-06 15:20:10 +02:00
Niels van Velzen
474c0774f1 Fix AudioNowPlayingFragment showing wrong item in extremely rare cases
(cherry picked from commit 61b4178dec)
2024-08-06 15:20:10 +02:00
Niels van Velzen
0c9ba1ccec Fix race condition in PictureViewerViewModel
Using the showNext/showPrevious functions while the album is still loading can cause an IndexOutOfBoundsException. Check if the album is empty (default value) first before trying to read from it to avoid these crashes.

(cherry picked from commit 9aa1759ae7)
2024-08-06 15:20:10 +02:00
Niels van Velzen
0c667ab615 Strip HTML from descriptions in leanback integration
(cherry picked from commit 2e55987939)
2024-08-06 15:20:09 +02:00
478 changed files with 9944 additions and 18190 deletions

View File

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

View File

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

View File

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

View File

@@ -12,23 +12,23 @@ permissions:
jobs:
build:
name: Build
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Setup Java
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
with:
distribution: temurin
java-version: 21
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
- 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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
with:
name: build-artifacts
retention-days: 14

View File

@@ -14,21 +14,21 @@ permissions:
jobs:
lint:
name: Lint
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Setup Java
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
with:
distribution: temurin
java-version: 21
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
- name: Run detekt and lint tasks
run: ./gradlew detekt lint
- name: Upload SARIF files
uses: github/codeql-action/upload-sarif@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
uses: github/codeql-action/upload-sarif@5cf07d8b700b67e235fbb65cbc84f69c0cf10464 # v3.25.14
if: ${{ always() }}
with:
sarif_file: .

View File

@@ -8,18 +8,18 @@ on:
jobs:
publish:
name: Publish
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Setup Java
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
with:
distribution: temurin
java-version: 21
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
- name: Set JELLYFIN_VERSION
run: echo "JELLYFIN_VERSION=$(echo ${GITHUB_REF#refs/tags/v} | tr / -)" >> $GITHUB_ENV
- name: Assemble release files
@@ -70,7 +70,7 @@ jobs:
remote_user: ${{ secrets.REPO_USER }}
remote_key: ${{ secrets.REPO_KEY }}
- name: Update repo.jellyfin.org symlinks
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
uses: appleboy/ssh-action@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 # v1.0.3
with:
host: ${{ secrets.REPO_HOST }}
username: ${{ secrets.REPO_USER }}

View File

@@ -13,16 +13,16 @@ permissions:
jobs:
test:
name: Test
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Setup Java
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
with:
distribution: temurin
java-version: 21
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
- name: Run test task
run: ./gradlew test

View File

@@ -14,9 +14,9 @@ permissions:
jobs:
validate:
name: Validate
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
uses: gradle/actions/wrapper-validation@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0

View File

@@ -9,10 +9,10 @@ on:
jobs:
triage:
name: Triage
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
steps:
- uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
- uses: eps1lon/actions-label-merge-conflict@1b1b1fcde06a9b3d089f3464c96417961dde1168 # v3.0.2
with:
dirtyLabel: merge conflict
repoToken: ${{ secrets.JF_BOT_TOKEN }}

View File

@@ -12,10 +12,10 @@ permissions:
jobs:
triage:
name: Triage
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
steps:
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
- uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
days-before-stale: 120

View File

@@ -14,8 +14,6 @@
- [mohd-akram](https://github.com/mohd-akram)
- [3l0w](https://github.com/3l0w)
- [MajMongoose](https://github.com/majmongoose)
- [Olaren15](https://github.com/Olaren15)
- [dtrexler](https://github.com/dtrexler)
# Emby Contributors

View File

@@ -13,13 +13,12 @@
<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" />
<issue id="UnknownNullness" severity="informational" />
<issue id="UnusedIds" severity="ignore" />
<issue id="UnusedAttribute">
<ignore regexp="usesCleartextTraffic" />
<ignore regexp="networkSecurityConfig" />
</issue>
</lint>

View File

@@ -18,6 +18,7 @@ android {
applicationId = namespace
versionName = project.getVersionName()
versionCode = getVersionCode(versionName!!)
setProperty("archivesBaseName", "jellyfin-androidtv-v$versionName")
}
buildFeatures {
@@ -31,7 +32,7 @@ android {
}
buildTypes {
release {
val release by getting {
isMinifyEnabled = false
// Set package names used in various XML files
@@ -45,7 +46,7 @@ android {
buildConfigField("boolean", "DEVELOPMENT", "false")
}
debug {
val debug by getting {
// Use different application id to run release and debug at the same time
applicationIdSuffix = ".debug"
@@ -73,9 +74,12 @@ android {
}
}
base.archivesName.set("jellyfin-androidtv-v${project.getVersionName()}")
aboutLibraries {
// Remove the "generated" timestamp to allow for reproducible builds
excludeFields = arrayOf("generated")
}
tasks.register("versionTxt") {
val versionTxt by tasks.registering {
val path = layout.buildDirectory.asFile.get().resolve("version.txt")
doLast {
@@ -92,6 +96,7 @@ dependencies {
implementation(projects.playback.media3.exoplayer)
implementation(projects.playback.media3.session)
implementation(projects.preference)
implementation(libs.jellyfin.apiclient)
implementation(libs.jellyfin.sdk) {
// Change version if desired
val sdkVersion = findProperty("sdk.version")?.toString()
@@ -109,7 +114,6 @@ dependencies {
// Android(x)
implementation(libs.androidx.core)
implementation(libs.androidx.activity)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.fragment)
implementation(libs.androidx.fragment.compose)
implementation(libs.androidx.leanback.core)
@@ -125,14 +129,12 @@ dependencies {
implementation(libs.androidx.cardview)
implementation(libs.androidx.startup)
implementation(libs.bundles.androidx.compose)
implementation(libs.accompanist.permissions)
// Dependency Injection
implementation(libs.bundles.koin)
// Media players
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.datasource.okhttp)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.ui)
implementation(libs.jellyfin.androidx.media3.ffmpeg.decoder)

View File

@@ -49,8 +49,7 @@
android:largeHeap="true"
android:supportsRtl="true"
android:theme="@style/Theme.Jellyfin"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
android:usesCleartextTraffic="true">
<!-- Legacy service required for AccountManagerMigration -->
<service
@@ -109,8 +108,9 @@
<activity
android:name=".ui.startup.StartupActivity"
android:exported="true"
android:launchMode="singleTask"
android:noHistory="true"
android:launchMode="singleTask"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustNothing">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -132,11 +132,12 @@
<!-- Main application -->
<activity
android:name=".ui.browsing.MainActivity"
android:launchMode="singleTask"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustNothing" />
<activity
android:name=".ui.preference.PreferencesActivity"
android:screenOrientation="behind"
android:theme="@style/Theme.Jellyfin.Preferences" />
<activity android:name=".ui.playback.ExternalPlayerActivity" />

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ package org.jellyfin.androidtv.auth.model
sealed class QuickConnectState
/**
* State unknown until first poll completed.
* State unknown untill first poll completed.
*/
data object UnknownQuickConnectState : QuickConnectState()

View File

@@ -18,7 +18,7 @@ data class Server(
val setupCompleted: Boolean = true,
var dateLastAccessed: Instant = Instant.MIN,
) {
val serverVersion = version?.let(ServerVersion::fromString)
private val serverVersion = version?.let(ServerVersion::fromString)
val versionSupported = serverVersion != null && serverVersion >= ServerRepository.minimumServerVersion
operator fun compareTo(other: ServerVersion): Int = serverVersion?.compareTo(other) ?: -1

View File

@@ -1,11 +1,9 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import org.jellyfin.androidtv.auth.model.ApiClientErrorLoginState
import org.jellyfin.androidtv.auth.model.AuthenticateMethod
import org.jellyfin.androidtv.auth.model.AuthenticatedState
@@ -23,10 +21,7 @@ import org.jellyfin.androidtv.auth.model.ServerVersionNotSupported
import org.jellyfin.androidtv.auth.model.User
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.jellyfin.androidtv.util.apiclient.JellyfinImage
import org.jellyfin.androidtv.util.apiclient.JellyfinImageSource
import org.jellyfin.androidtv.util.apiclient.getUrl
import org.jellyfin.androidtv.util.apiclient.primaryImage
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.androidtv.util.sdk.forUser
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
@@ -34,10 +29,10 @@ import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.exception.TimeoutException
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.authenticateWithQuickConnect
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.api.AuthenticationResult
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.UserDto
import timber.log.Timber
import java.time.Instant
@@ -60,6 +55,9 @@ class AuthenticationRepositoryImpl(
private val defaultDeviceInfo: DeviceInfo,
) : AuthenticationRepository {
override fun authenticate(server: Server, method: AuthenticateMethod): Flow<LoginState> {
// Check server version first
if (!server.versionSupported) return flowOf(ServerVersionNotSupported(server))
return when (method) {
is AutomaticAuthenticateMethod -> authenticateAutomatic(server, method.user)
is CredentialAuthenticateMethod -> authenticateCredential(server, method.username, method.password)
@@ -96,7 +94,7 @@ class AuthenticationRepositoryImpl(
}
emitAll(authenticateAuthenticationResult(server, result))
}.flowOn(Dispatchers.IO)
}
private fun authenticateQuickConnect(server: Server, secret: String) = flow {
val api = jellyfin.createApi(server.address, deviceInfo = defaultDeviceInfo)
@@ -114,7 +112,7 @@ class AuthenticationRepositoryImpl(
}
emitAll(authenticateAuthenticationResult(server, result))
}.flowOn(Dispatchers.IO)
}
private fun authenticateAuthenticationResult(server: Server, result: AuthenticationResult) = flow {
val accessToken = result.accessToken ?: return@flow emit(RequireSignInState)
@@ -124,7 +122,7 @@ class AuthenticationRepositoryImpl(
serverId = server.id,
name = userInfo.name!!,
accessToken = result.accessToken,
imageTag = userInfo.primaryImage?.tag,
imageTag = userInfo.primaryImageTag,
lastUsed = Instant.now().toEpochMilli(),
)
@@ -134,18 +132,16 @@ class AuthenticationRepositoryImpl(
emit(AuthenticatedState)
} else {
Timber.w("Failed to set active session after authenticating")
if (!server.versionSupported) emit(ServerVersionNotSupported(server))
else emit(RequireSignInState)
emit(RequireSignInState)
}
}.flowOn(Dispatchers.IO)
}
private fun authenticateToken(server: Server, user: User) = flow {
emit(AuthenticatingState)
val success = setActiveSession(user, server)
if (!success) {
if (!server.versionSupported) emit(ServerVersionNotSupported(server))
else emit(RequireSignInState)
emit(RequireSignInState)
} else try {
// Update user info
val userInfo by userApiClient.userApi.getCurrentUser()
@@ -159,7 +155,7 @@ class AuthenticationRepositoryImpl(
Timber.e(err, "Unable to get current user data")
emit(ApiClientErrorLoginState(err))
}
}.flowOn(Dispatchers.IO)
}
private suspend fun authenticateFinish(server: Server, userInfo: UserDto, accessToken: String) {
val currentUser = authenticationStore.getUser(server.id, userInfo.id)
@@ -167,11 +163,11 @@ class AuthenticationRepositoryImpl(
val updatedUser = currentUser?.copy(
name = userInfo.name!!,
lastUsed = Instant.now().toEpochMilli(),
imageTag = userInfo.primaryImage?.tag,
imageTag = userInfo.primaryImageTag,
accessToken = accessToken,
) ?: AuthenticationStoreUser(
name = userInfo.name!!,
imageTag = userInfo.primaryImage?.tag,
imageTag = userInfo.primaryImageTag,
accessToken = accessToken,
)
authenticationStore.putUser(server.id, userInfo.id, updatedUser)
@@ -203,15 +199,11 @@ class AuthenticationRepositoryImpl(
else false
}
override fun getUserImageUrl(server: Server, user: User): String? = user.imageTag?.let { primaryImageTag ->
JellyfinImage(
item = user.id,
source = JellyfinImageSource.USER,
type = ImageType.PRIMARY,
tag = primaryImageTag,
blurHash = null,
aspectRatio = null,
index = null
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
)
}?.getUrl(jellyfin.createApi(server.address))
}
}

View File

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

View File

@@ -1,7 +1,5 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.auth.model.PrivateUser
import org.jellyfin.androidtv.auth.model.PublicUser
import org.jellyfin.androidtv.auth.model.Server
@@ -49,9 +47,7 @@ class ServerUserRepositoryImpl(
val api = jellyfin.createApi(server.address)
return try {
val users = withContext(Dispatchers.IO) {
api.userApi.getPublicUsers().content
}
val users by api.userApi.getPublicUsers()
users.mapNotNull(UserDto::toPublicUser)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to retrieve public users")

View File

@@ -1,6 +1,5 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -8,7 +7,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.auth.apiclient.ApiBinder
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.jellyfin.androidtv.preference.PreferencesRepository
@@ -49,6 +48,7 @@ interface SessionRepository {
class SessionRepositoryImpl(
private val authenticationPreferences: AuthenticationPreferences,
private val apiBinder: ApiBinder,
private val authenticationStore: AuthenticationStore,
private val userApiClient: ApiClient,
private val preferencesRepository: PreferencesRepository,
@@ -112,15 +112,13 @@ class SessionRepositoryImpl(
override fun destroyCurrentSession() {
Timber.i("Destroying current session")
userRepository.setCurrentUser(null)
serverRepository.setCurrentServer(null)
userRepository.updateCurrentUser(null)
_currentSession.value = null
apiBinder.updateSession(null, userApiClient.deviceInfo)
_state.value = SessionRepositoryState.READY
}
private suspend fun setCurrentSession(session: Session?): Boolean {
var server: Server? = null
if (session != null) {
// No change in session - don't switch
if (currentSession.value?.userId == session.userId) return true
@@ -130,40 +128,40 @@ class SessionRepositoryImpl(
authenticationPreferences[AuthenticationPreferences.lastUserId] = session.userId.toString()
// Check if server version is supported
server = serverRepository.getServer(session.serverId, true)
val server = serverRepository.getServer(session.serverId)
if (server == null || !server.versionSupported) return false
}
// Update session after binding the apiclient settings
val deviceInfo = session?.let { defaultDeviceInfo.forUser(it.userId) } ?: defaultDeviceInfo
Timber.i("Updating current session. userId=${session?.userId} server=${server?.serverVersion}")
val success = apiBinder.updateSession(session, deviceInfo)
Timber.i("Updating current session. userId=${session?.userId} apiBindingSuccess=${success}")
val applied = userApiClient.applySession(session, deviceInfo)
if (applied && session != null) {
try {
val user = withContext(Dispatchers.IO) {
userApiClient.userApi.getCurrentUser().content
if (success) {
val applied = userApiClient.applySession(session, deviceInfo)
if (applied && session != null) {
try {
val user by userApiClient.userApi.getCurrentUser()
userRepository.updateCurrentUser(user)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to authenticate: bad response when getting user info")
destroyCurrentSession()
return false
}
userRepository.setCurrentUser(user)
serverRepository.setCurrentServer(server)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to authenticate: bad response when getting user info")
destroyCurrentSession()
return false
// Update crash reporting URL
val crashReportUrl = userApiClient.clientLogApi.logFileUrl()
telemetryPreferences[TelemetryPreferences.crashReportUrl] = crashReportUrl
telemetryPreferences[TelemetryPreferences.crashReportToken] = session.accessToken
} else {
userRepository.updateCurrentUser(null)
}
preferencesRepository.onSessionChanged()
_currentSession.value = session
} else destroyCurrentSession()
// Update crash reporting URL
val crashReportUrl = userApiClient.clientLogApi.logFileUrl()
telemetryPreferences[TelemetryPreferences.crashReportUrl] = crashReportUrl
telemetryPreferences[TelemetryPreferences.crashReportToken] = session.accessToken
} else {
userRepository.setCurrentUser(null)
serverRepository.setCurrentServer(null)
}
preferencesRepository.onSessionChanged()
_currentSession.value = session
return true
return success
}
private fun createLastUserSession(): Session? {

View File

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

View File

@@ -18,6 +18,7 @@ enum class QueryType {
LiveTvProgram,
LiveTvRecording,
StaticItems,
StaticAudioQueueItems,
Artists,
AlbumArtists,
AudioPlaylists,

View File

@@ -1,6 +1,7 @@
package org.jellyfin.androidtv.data.compat
import org.jellyfin.sdk.model.api.DeviceProfile
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
@@ -21,4 +22,11 @@ open class AudioOptions {
* Example: DeviceProfile supports five channel, but user only has stereo speakers
*/
var maxAudioChannels: Int? = null
/**
* The application's configured quality setting
*/
var maxBitrate: Int? = null
var context = EncodingContext.Streaming
}

View File

@@ -1,7 +1,7 @@
package org.jellyfin.androidtv.data.compat
import org.jellyfin.sdk.model.api.PlaybackErrorCode
import org.jellyfin.apiclient.model.dlna.PlaybackErrorCode
class PlaybackException : RuntimeException() {
var errorCode = PlaybackErrorCode.NOT_ALLOWED
var errorCode = PlaybackErrorCode.NotAllowed
}

View File

@@ -0,0 +1,97 @@
package org.jellyfin.androidtv.data.compat;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod;
import org.jellyfin.apiclient.model.dlna.SubtitleProfile;
import org.jellyfin.apiclient.model.entities.MediaStream;
import org.jellyfin.apiclient.model.session.PlayMethod;
public class StreamBuilder
{
public static SubtitleProfile getSubtitleProfile(org.jellyfin.sdk.model.api.MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod)
{
if (playMethod != PlayMethod.Transcode && !subtitleStream.isExternal())
{
// Look for supported embedded subs
for (SubtitleProfile profile : subtitleProfiles)
{
if (!profile.SupportsLanguage(subtitleStream.getLanguage()))
{
continue;
}
if (profile.getMethod() != SubtitleDeliveryMethod.Embed)
{
continue;
}
if (subtitleStream.isTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat()) && Utils.equalsIgnoreCase(profile.getFormat(), subtitleStream.getCodec()))
{
return profile;
}
}
}
// Look for an external or hls profile that matches the stream type (text/graphical) and doesn't require conversion
SubtitleProfile tempVar = new SubtitleProfile();
tempVar.setMethod(SubtitleDeliveryMethod.Encode);
tempVar.setFormat(subtitleStream.getCodec());
SubtitleProfile tempVar2 = getExternalSubtitleProfile(subtitleStream, subtitleProfiles, playMethod, false);
SubtitleProfile tempVar3 = getExternalSubtitleProfile(subtitleStream, subtitleProfiles, playMethod, true);
return (tempVar2 != null) ? tempVar2 : (tempVar3 != null) ? tempVar3 : tempVar;
}
private static SubtitleProfile getExternalSubtitleProfile(org.jellyfin.sdk.model.api.MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod, boolean allowConversion)
{
for (SubtitleProfile profile : subtitleProfiles)
{
if (profile.getMethod() != SubtitleDeliveryMethod.External && profile.getMethod() != SubtitleDeliveryMethod.Hls)
{
continue;
}
if (profile.getMethod() == SubtitleDeliveryMethod.Hls && playMethod != PlayMethod.Transcode)
{
continue;
}
if (!profile.SupportsLanguage(subtitleStream.getLanguage()))
{
continue;
}
if ((profile.getMethod() == SubtitleDeliveryMethod.External && subtitleStream.isTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat())) || (profile.getMethod() == SubtitleDeliveryMethod.Hls && subtitleStream.isTextSubtitleStream()))
{
boolean requiresConversion = !Utils.equalsIgnoreCase(subtitleStream.getCodec(), profile.getFormat());
if (!requiresConversion)
{
return profile;
}
if (!allowConversion)
{
continue;
}
if (subtitleStream.isTextSubtitleStream() && subtitleStream.getSupportsExternalStream() && supportsSubtitleConversionTo(subtitleStream, profile.getFormat()))
{
return profile;
}
}
}
return null;
}
public static boolean supportsSubtitleConversionTo(org.jellyfin.sdk.model.api.MediaStream mediaStream, String codec)
{
if (!mediaStream.isTextSubtitleStream())
{
return false;
}
// Can't convert from this
return !("ass".equalsIgnoreCase(mediaStream.getCodec()) || "ssa".equalsIgnoreCase(mediaStream.getCodec()) || "ass".equalsIgnoreCase(codec) || "ssa".equalsIgnoreCase(codec));
}
}

View File

@@ -1,10 +1,16 @@
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.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.session.PlayMethod;
import org.jellyfin.sdk.model.api.MediaProtocol;
import org.jellyfin.sdk.model.api.MediaSourceInfo;
import org.jellyfin.sdk.model.api.MediaStream;
import org.jellyfin.sdk.model.api.MediaStreamType;
import org.jellyfin.sdk.model.api.PlayMethod;
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod;
import java.util.ArrayList;
import java.util.UUID;
@@ -30,14 +36,24 @@ public class StreamInfo {
MediaUrl = value;
}
private PlayMethod playMethod = PlayMethod.DIRECT_PLAY;
private PlayMethod PlayMethod = getPlayMethod().values()[0];
public final PlayMethod getPlayMethod() {
return playMethod;
return PlayMethod;
}
public final void setPlayMethod(PlayMethod value) {
playMethod = value;
PlayMethod = value;
}
private EncodingContext Context = EncodingContext.values()[0];
public final EncodingContext getContext() {
return Context;
}
public final void setContext(EncodingContext value) {
Context = value;
}
private String Container;
@@ -50,6 +66,26 @@ public class StreamInfo {
Container = value;
}
private long StartPositionTicks;
public final long getStartPositionTicks() {
return StartPositionTicks;
}
public final void setStartPositionTicks(long value) {
StartPositionTicks = value;
}
private DeviceProfile DeviceProfile;
public final DeviceProfile getDeviceProfile() {
return DeviceProfile;
}
public final void setDeviceProfile(DeviceProfile value) {
DeviceProfile = value;
}
private Long RunTimeTicks = null;
public final Long getRunTimeTicks() {
@@ -60,6 +96,12 @@ public class StreamInfo {
RunTimeTicks = value;
}
private TranscodeSeekInfo TranscodeSeekInfo = getTranscodeSeekInfo().values()[0];
public final TranscodeSeekInfo getTranscodeSeekInfo() {
return TranscodeSeekInfo;
}
private MediaSourceInfo MediaSource;
public final MediaSourceInfo getMediaSource() {
@@ -70,10 +112,10 @@ public class StreamInfo {
MediaSource = value;
}
public final org.jellyfin.sdk.model.api.SubtitleDeliveryMethod getSubtitleDeliveryMethod() {
Integer subtitleStreamIndex = MediaSource.getDefaultSubtitleStreamIndex();
if (subtitleStreamIndex == null || subtitleStreamIndex == -1) return SubtitleDeliveryMethod.DROP;
return MediaSource.getMediaStreams().get(subtitleStreamIndex).getDeliveryMethod();
private SubtitleDeliveryMethod SubtitleDeliveryMethod = getSubtitleDeliveryMethod().values()[0];
public final SubtitleDeliveryMethod getSubtitleDeliveryMethod() {
return SubtitleDeliveryMethod;
}
private String PlaySessionId;
@@ -90,6 +132,69 @@ public class StreamInfo {
return getMediaSource() == null ? null : getMediaSource().getId();
}
public final ArrayList<SubtitleStreamInfo> getSubtitleProfiles(boolean includeSelectedTrackOnly, String baseUrl, String accessToken) {
return getSubtitleProfiles(includeSelectedTrackOnly, false, baseUrl, accessToken);
}
public final ArrayList<SubtitleStreamInfo> getSubtitleProfiles(boolean includeSelectedTrackOnly, boolean enableAllProfiles, String baseUrl, String accessToken) {
ArrayList<SubtitleStreamInfo> list = new ArrayList<SubtitleStreamInfo>();
// HLS will preserve timestamps so we can just grab the full subtitle stream
long startPositionTicks = getPlayMethod() == PlayMethod.Transcode ? getStartPositionTicks() : 0;
if (!includeSelectedTrackOnly) {
if (getMediaSource() == null) return list;
for (org.jellyfin.sdk.model.api.MediaStream stream : getMediaSource().getMediaStreams()) {
if (stream.getType() == org.jellyfin.sdk.model.api.MediaStreamType.SUBTITLE) {
addSubtitleProfiles(list, stream, enableAllProfiles, baseUrl, accessToken, startPositionTicks);
}
}
}
return list;
}
private void addSubtitleProfiles(ArrayList<SubtitleStreamInfo> list, org.jellyfin.sdk.model.api.MediaStream stream, boolean enableAllProfiles, String baseUrl, String accessToken, long startPositionTicks) {
if (enableAllProfiles) {
for (SubtitleProfile profile : getDeviceProfile().getSubtitleProfiles()) {
SubtitleStreamInfo info = getSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, new SubtitleProfile[]{profile});
list.add(info);
}
} else {
SubtitleStreamInfo info = getSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, getDeviceProfile().getSubtitleProfiles());
list.add(info);
}
}
private SubtitleStreamInfo getSubtitleStreamInfo(org.jellyfin.sdk.model.api.MediaStream stream, String baseUrl, String accessToken, long startPositionTicks, SubtitleProfile[] subtitleProfiles) {
SubtitleProfile subtitleProfile = StreamBuilder.getSubtitleProfile(stream, subtitleProfiles, getPlayMethod());
SubtitleStreamInfo tempVar = new SubtitleStreamInfo();
String tempVar2 = stream.getLanguage();
tempVar.setName((tempVar2 != null) ? tempVar2 : "Unknown");
tempVar.setFormat(subtitleProfile.getFormat());
tempVar.setIndex(stream.getIndex());
tempVar.setDeliveryMethod(subtitleProfile.getMethod());
tempVar.setDisplayTitle(stream.getDisplayTitle());
SubtitleStreamInfo info = tempVar;
if (info.getDeliveryMethod() == SubtitleDeliveryMethod.External) {
if (getMediaSource().getProtocol() == MediaProtocol.FILE || !stream.getCodec().equalsIgnoreCase(subtitleProfile.getFormat())) {
info.setUrl(String.format("%1$s/Videos/%2$s/%3$s/Subtitles/%4$s/%5$s/Stream.%6$s", baseUrl, getItemId(), getMediaSourceId(), String.valueOf(stream.getIndex()), String.valueOf(startPositionTicks), subtitleProfile.getFormat()));
if (!Utils.isEmpty(accessToken)) {
info.setUrl(info.getUrl() + "?api_key=" + accessToken);
}
} else {
info.setUrl(stream.getPath());
}
}
return info;
}
public final ArrayList<MediaStream> getSelectableAudioStreams() {
return getSelectableStreams(MediaStreamType.AUDIO);
}

View File

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

View File

@@ -4,14 +4,10 @@ import android.content.Context
import android.media.AudioManager
import android.os.Build
import android.widget.Toast
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.data.model.DataRefreshService
@@ -20,7 +16,6 @@ import org.jellyfin.androidtv.ui.navigation.Destinations
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
import org.jellyfin.androidtv.ui.playback.setSubtitleIndex
import org.jellyfin.androidtv.util.PlaybackHelper
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
@@ -54,109 +49,103 @@ class SocketHandler(
private val audioManager: AudioManager,
private val itemLauncher: ItemLauncher,
private val playbackHelper: PlaybackHelper,
private val lifecycle: Lifecycle,
) {
init {
lifecycle.coroutineScope.launch(Dispatchers.IO) {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
subscribe(this)
}
}
}
private val coroutineScope = CoroutineScope(Dispatchers.IO)
suspend fun updateSession() {
try {
withContext(Dispatchers.IO) {
api.sessionApi.postCapabilities(
playableMediaTypes = listOf(MediaType.VIDEO, MediaType.AUDIO),
supportsMediaControl = true,
supportedCommands = buildList {
add(GeneralCommandType.DISPLAY_CONTENT)
add(GeneralCommandType.SET_SUBTITLE_STREAM_INDEX)
add(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
api.sessionApi.postCapabilities(
playableMediaTypes = listOf(MediaType.VIDEO, MediaType.AUDIO),
supportsMediaControl = true,
supportedCommands = buildList {
add(GeneralCommandType.DISPLAY_CONTENT)
add(GeneralCommandType.SET_SUBTITLE_STREAM_INDEX)
add(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
add(GeneralCommandType.DISPLAY_MESSAGE)
add(GeneralCommandType.SEND_STRING)
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)
// 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)
}
},
)
}
add(GeneralCommandType.MUTE)
add(GeneralCommandType.UNMUTE)
add(GeneralCommandType.TOGGLE_MUTE)
}
},
)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to update capabilities")
return
}
}
private fun subscribe(coroutineScope: CoroutineScope) = api.webSocket.apply {
// Library
subscribe<LibraryChangedMessage>()
.onEach { message -> message.data?.let(::onLibraryChanged) }
.launchIn(coroutineScope)
init {
api.webSocket.apply {
// Library
subscribe<LibraryChangedMessage>()
.onEach { message -> message.data?.let(::onLibraryChanged) }
.launchIn(coroutineScope)
// Media playback
subscribe<PlayMessage>()
.onEach { message -> onPlayMessage(message) }
.launchIn(coroutineScope)
// Media playback
subscribe<PlayMessage>()
.onEach { message -> onPlayMessage(message) }
.launchIn(coroutineScope)
subscribe<PlaystateMessage>()
.onEach { message -> onPlayStateMessage(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
subscribeGeneralCommand(GeneralCommandType.SET_SUBTITLE_STREAM_INDEX)
.onEach { message ->
val index = message["index"]?.toIntOrNull() ?: return@onEach
withContext(Dispatchers.Main) {
playbackControllerContainer.playbackController?.setSubtitleIndex(index)
}
}
.launchIn(coroutineScope)
subscribeGeneralCommand(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
.onEach { message ->
val index = message["index"]?.toIntOrNull() ?: return@onEach
withContext(Dispatchers.Main) {
playbackControllerContainer.playbackController?.switchAudioStream(index)
}
}
.launchIn(coroutineScope)
// General commands
subscribeGeneralCommand(GeneralCommandType.DISPLAY_CONTENT)
.onEach { message ->
val itemId by message
val itemType by message
val itemUuid = itemId?.toUUIDOrNull()
val itemKind = itemType?.let { type ->
BaseItemKind.entries.find { value ->
value.serialName.equals(type, true)
withContext(Dispatchers.Main) {
playbackControllerContainer.playbackController?.switchSubtitleStream(index)
}
}
.launchIn(coroutineScope)
if (itemUuid != null && itemKind != null) onDisplayContent(itemUuid, itemKind)
}
.launchIn(coroutineScope)
subscribeGeneralCommand(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
.onEach { message ->
val index = message["index"]?.toIntOrNull() ?: return@onEach
subscribeGeneralCommands(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING))
.onEach { message ->
val header by message
val text by message
val string by message
withContext(Dispatchers.Main) {
playbackControllerContainer.playbackController?.switchAudioStream(index)
}
}
.launchIn(coroutineScope)
onDisplayMessage(header, text ?: string)
}
.launchIn(coroutineScope)
// General commands
subscribeGeneralCommand(GeneralCommandType.DISPLAY_CONTENT)
.onEach { message ->
val itemId by message
val itemType by message
val itemUuid = itemId?.toUUIDOrNull()
val itemKind = itemType?.let { type ->
BaseItemKind.entries.find { value ->
value.serialName.equals(type, true)
}
}
if (itemUuid != null && itemKind != null) onDisplayContent(itemUuid, itemKind)
}
.launchIn(coroutineScope)
subscribeGeneralCommands(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING))
.onEach { message ->
val header by message
val text by message
val string by message
onDisplayMessage(header, text ?: string)
}
.launchIn(coroutineScope)
}
}
private fun onLibraryChanged(info: LibraryUpdateInfo) {

View File

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

View File

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

View File

@@ -1,9 +1,7 @@
package org.jellyfin.androidtv.data.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemDto
@@ -23,9 +21,10 @@ class UserViewsRepositoryImpl(
override val views = flow {
val views by api.userViewsApi.getUserViews()
val filteredViews = views.items
.orEmpty()
.filter { isSupported(it.collectionType) }
emit(filteredViews)
}.flowOn(Dispatchers.IO)
}
override fun isSupported(collectionType: CollectionType?) = collectionType !in unsupportedCollectionTypes
override fun allowViewSelection(collectionType: CollectionType?) = collectionType !in disallowViewSelectionCollectionTypes

View File

@@ -3,9 +3,9 @@ package org.jellyfin.androidtv.data.service
import android.content.Context
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import coil3.ImageLoader
import coil3.request.ImageRequest
import coil3.toBitmap
import androidx.core.graphics.drawable.toBitmap
import coil.ImageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
@@ -15,14 +15,13 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.util.apiclient.getUrl
import org.jellyfin.androidtv.util.apiclient.itemBackdropImages
import org.jellyfin.androidtv.util.apiclient.parentBackdropImages
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import java.time.Instant
import java.util.UUID
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@@ -55,6 +54,23 @@ class BackgroundService(
val blurBackground get() = _blurBackground.asStateFlow()
val enabled get() = _enabled.asStateFlow()
// Helper function for [setBackground]
private fun List<String>?.getUrls(itemId: UUID?): List<String> {
// Check for nullability
if (itemId == null || isNullOrEmpty()) return emptyList()
return mapIndexed { index, tag ->
api.imageApi.getItemImageUrl(
itemId = itemId,
imageType = ImageType.BACKDROP,
tag = tag,
imageIndex = index,
fillWidth = context.resources.displayMetrics.widthPixels,
fillHeight = context.resources.displayMetrics.heightPixels,
)
}
}
/**
* Use all available backdrops from [baseItem] as background.
*/
@@ -67,9 +83,9 @@ class BackgroundService(
_blurBackground.value = true
// Get all backdrop urls
val backdropUrls = (baseItem.itemBackdropImages + baseItem.parentBackdropImages)
.map { it.getUrl(api) }
.toSet()
val itemBackdropUrls = baseItem.backdropImageTags.getUrls(baseItem.id)
val parentBackdropUrls = baseItem.parentBackdropImageTags.getUrls(baseItem.parentBackdropItemId)
val backdropUrls = itemBackdropUrls.union(parentBackdropUrls)
loadBackgrounds(backdropUrls)
}
@@ -108,7 +124,7 @@ class BackgroundService(
_backgrounds = backdropUrls.mapNotNull { url ->
imageLoader.execute(
request = ImageRequest.Builder(context).data(url).build()
).image?.toBitmap()?.asImageBitmap()
).drawable?.toBitmap()?.asImageBitmap()
}
// Go to first background

View File

@@ -2,16 +2,10 @@ package org.jellyfin.androidtv.di
import android.content.Context
import android.os.Build
import androidx.lifecycle.ProcessLifecycleOwner
import coil3.ImageLoader
import coil3.annotation.ExperimentalCoilApi
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.NetworkFetcher
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.serviceLoaderEnabled
import coil3.svg.SvgDecoder
import coil3.util.Logger
import 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
@@ -28,17 +22,14 @@ import org.jellyfin.androidtv.data.repository.UserViewsRepository
import org.jellyfin.androidtv.data.repository.UserViewsRepositoryImpl
import org.jellyfin.androidtv.data.service.BackgroundService
import org.jellyfin.androidtv.integration.dream.DreamViewModel
import org.jellyfin.androidtv.ui.InteractionTrackerViewModel
import org.jellyfin.androidtv.ui.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.PlaybackControllerContainer
import org.jellyfin.androidtv.ui.playback.nextup.NextUpViewModel
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepository
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepositoryImpl
import org.jellyfin.androidtv.ui.playback.stillwatching.StillWatchingViewModel
import org.jellyfin.androidtv.ui.player.photo.PhotoPlayerViewModel
import org.jellyfin.androidtv.ui.search.SearchFragmentDelegate
import org.jellyfin.androidtv.ui.search.SearchRepository
import org.jellyfin.androidtv.ui.search.SearchRepositoryImpl
@@ -50,76 +41,68 @@ import org.jellyfin.androidtv.util.KeyProcessor
import org.jellyfin.androidtv.util.MarkdownRenderer
import org.jellyfin.androidtv.util.PlaybackHelper
import org.jellyfin.androidtv.util.apiclient.ReportingHelper
import org.jellyfin.androidtv.util.coil.CoilTimberLogger
import org.jellyfin.androidtv.util.coil.createCoilConnectivityChecker
import org.jellyfin.androidtv.util.sdk.SdkPlaybackHelper
import org.jellyfin.androidtv.util.sdk.legacy
import org.jellyfin.apiclient.AppInfo
import org.jellyfin.apiclient.android
import org.jellyfin.apiclient.logging.AndroidLogger
import org.jellyfin.sdk.android.androidDevice
import org.jellyfin.sdk.api.client.HttpClientOptions
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import org.koin.android.ext.koin.androidApplication
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.viewModel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.jellyfin.apiclient.Jellyfin as JellyfinApiClient
import org.jellyfin.sdk.Jellyfin as JellyfinSdk
val defaultDeviceInfo = named("defaultDeviceInfo")
val appModule = module {
// SDK
// New SDK
single(defaultDeviceInfo) { androidDevice(get()) }
single { OkHttpFactory() }
single { HttpClientOptions() }
single {
createJellyfin {
context = androidContext()
// Add client info
val clientName = buildString {
append("Jellyfin Android TV")
if (BuildConfig.DEBUG) append(" (debug)")
}
clientInfo = ClientInfo(clientName, BuildConfig.VERSION_NAME)
clientInfo = ClientInfo("Android TV", BuildConfig.VERSION_NAME)
deviceInfo = get(defaultDeviceInfo)
// Change server version
minimumServerVersion = ServerRepository.minimumServerVersion
// Use our own shared factory instance
apiClientFactory = get<OkHttpFactory>()
socketConnectionFactory = get<OkHttpFactory>()
}
}
single {
// Create an empty API instance, the actual values are set by the SessionRepository
get<JellyfinSdk>().createApi(httpClientOptions = get<HttpClientOptions>())
get<JellyfinSdk>().createApi()
}
single { SocketHandler(get(), get(), get(), get(), get(), get(), get(), get(), get(), ProcessLifecycleOwner.get().lifecycle) }
single { SocketHandler(get(), get(), get(), get(), get(), get(), get(), get(), get()) }
// Coil (images)
// Old apiclient
single {
val okHttpFactory = get<OkHttpFactory>()
val httpClientOptions = get<HttpClientOptions>()
JellyfinApiClient {
appInfo = AppInfo("Android TV", BuildConfig.VERSION_NAME)
logger = AndroidLogger()
android(androidApplication())
}
}
@OptIn(ExperimentalCoilApi::class)
OkHttpNetworkFetcherFactory(
callFactory = { okHttpFactory.createClient(httpClientOptions) },
connectivityChecker = ::createCoilConnectivityChecker,
single {
get<JellyfinApiClient>().createApi(
device = get<DeviceInfo>(defaultDeviceInfo).legacy()
)
}
// Coil (images)
single {
ImageLoader.Builder(androidContext()).apply {
serviceLoaderEnabled(false)
logger(CoilTimberLogger(if (BuildConfig.DEBUG) Logger.Level.Warn else Logger.Level.Error))
components {
add(get<NetworkFetcher.Factory>())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(AnimatedImageDecoder.Factory())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(ImageDecoderDecoder.Factory())
else add(GifDecoder.Factory())
add(SvgDecoder.Factory())
}
@@ -129,7 +112,6 @@ val appModule = module {
// Non API related
single { DataRefreshService() }
single { PlaybackControllerContainer() }
single { InteractionTrackerViewModel(get(), get()) }
single<UserRepository> { UserRepositoryImpl() }
single<UserViewsRepository> { UserViewsRepositoryImpl(get()) }
@@ -138,14 +120,13 @@ val appModule = module {
single<CustomMessageRepository> { CustomMessageRepositoryImpl() }
single<NavigationRepository> { NavigationRepositoryImpl(Destinations.home) }
single<SearchRepository> { SearchRepositoryImpl(get()) }
single<MediaSegmentRepository> { MediaSegmentRepositoryImpl(get(), get()) }
viewModel { StartupViewModel(get(), get(), get(), get()) }
viewModel { UserLoginViewModel(get(), get(), get(), get(defaultDeviceInfo)) }
viewModel { ServerAddViewModel(get()) }
viewModel { NextUpViewModel(get(), get(), get()) }
viewModel { StillWatchingViewModel(get(), get(), get(), get()) }
viewModel { PhotoPlayerViewModel(get()) }
viewModel { NextUpViewModel(get(), get(), get(), get()) }
viewModel { PictureViewerViewModel(get()) }
viewModel { ScreensaverViewModel(get()) }
viewModel { SearchViewModel(get()) }
viewModel { DreamViewModel(get(), get(), get(), get(), get()) }
@@ -154,8 +135,8 @@ val appModule = module {
single { MarkdownRenderer(get()) }
single { ItemLauncher() }
single { KeyProcessor() }
single { ReportingHelper(get(), get()) }
single<PlaybackHelper> { SdkPlaybackHelper(get(), get(), get(), get()) }
single { ReportingHelper() }
single<PlaybackHelper> { SdkPlaybackHelper(get(), get(), get(), get(), get(), get(), get()) }
factory { (context: Context) -> SearchFragmentDelegate(context, get(), get()) }
}

View File

@@ -1,6 +1,7 @@
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
import org.jellyfin.androidtv.auth.repository.ServerRepository
@@ -24,11 +25,8 @@ val authModule = module {
single<ServerRepository> { ServerRepositoryImpl(get(), get()) }
single<ServerUserRepository> { ServerUserRepositoryImpl(get(), get()) }
single<SessionRepository> {
SessionRepositoryImpl(get(), get(), get(), get(), get(defaultDeviceInfo), get(), get(), get())
SessionRepositoryImpl(get(), get(), get(), get(), get(), get(defaultDeviceInfo), get(), get(), get())
}
factory {
val serverRepository = get<ServerRepository>()
serverRepository.currentServer.value?.serverVersion ?: ServerRepository.minimumServerVersion
}
single { ApiBinder(get(), get()) }
}

View File

@@ -6,26 +6,23 @@ import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.media3.datasource.HttpDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import org.jellyfin.androidtv.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.PlaybackLauncher
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.androidtv.util.profile.createDeviceProfile
import org.jellyfin.playback.core.playbackManager
import org.jellyfin.playback.jellyfin.jellyfinPlugin
import org.jellyfin.playback.media3.exoplayer.ExoPlayerOptions
import org.jellyfin.playback.media3.exoplayer.exoPlayerPlugin
import org.jellyfin.playback.media3.session.MediaSessionOptions
import org.jellyfin.playback.media3.session.media3SessionPlugin
import org.jellyfin.sdk.api.client.HttpClientOptions
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.api.client.ApiClient
import org.koin.android.ext.koin.androidContext
import org.koin.core.scope.Scope
import org.koin.dsl.module
@@ -35,16 +32,14 @@ import org.jellyfin.androidtv.ui.playback.PlaybackManager as LegacyPlaybackManag
val playbackModule = module {
single { LegacyPlaybackManager(get()) }
single { VideoQueueManager() }
single<MediaManager> { RewriteMediaManager(get(), get()) }
single<MediaManager> { RewriteMediaManager(get(), get(), get(), get()) }
single { PlaybackLauncher(get(), get(), get(), get()) }
factory {
val preferences = get<UserPreferences>()
val useRewrite = preferences[UserPreferences.playbackRewriteVideoEnabled] && BuildConfig.DEVELOPMENT
// OkHttp data source using OkHttpFactory from SDK
single<HttpDataSource.Factory> {
val okHttpFactory = get<OkHttpFactory>()
val httpClientOptions = get<HttpClientOptions>()
OkHttpDataSource.Factory(okHttpFactory.createClient(httpClientOptions))
if (useRewrite) RewritePlaybackLauncher()
else GarbagePlaybackLauncher(get())
}
single { createPlaybackManager() }
@@ -61,15 +56,13 @@ fun Scope.createPlaybackManager() = playbackManager(androidContext()) {
notificationChannelId,
NotificationManager.IMPORTANCE_LOW
)
channel.setShowBadge(false)
NotificationManagerCompat.from(get()).createNotificationChannel(channel)
}
val userPreferences = get<UserPreferences>()
val api = get<ApiClient>()
val exoPlayerOptions = ExoPlayerOptions(
preferFfmpeg = userPreferences[UserPreferences.preferExoPlayerFfmpeg],
enableDebugLogging = userPreferences[UserPreferences.debuggingEnabled],
baseDataSourceFactory = get<HttpDataSource.Factory>(),
httpConnectTimeout = api.httpClientOptions.connectTimeout,
httpReadTimeout = api.httpClientOptions.requestTimeout
)
install(exoPlayerPlugin(get(), exoPlayerOptions))
@@ -81,8 +74,7 @@ fun Scope.createPlaybackManager() = playbackManager(androidContext()) {
)
install(media3SessionPlugin(get(), mediaSessionOptions))
val deviceProfileBuilder = { createDeviceProfile(androidContext(), userPreferences, get()) }
install(jellyfinPlugin(get(), deviceProfileBuilder, ProcessLifecycleOwner.get().lifecycle))
install(jellyfinPlugin(get()))
// Options
val userSettingPreferences = get<UserSettingPreferences>()

View File

@@ -7,10 +7,8 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.core.content.edit
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import androidx.tvprovider.media.tv.Channel
import androidx.tvprovider.media.tv.ChannelLogoUtils
import androidx.tvprovider.media.tv.PreviewProgram
@@ -23,38 +21,38 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.data.repository.ItemRepository
import org.jellyfin.androidtv.data.repository.UserViewsRepository
import org.jellyfin.androidtv.integration.provider.ImageProvider
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.ui.startup.StartupActivity
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.androidtv.util.apiclient.getUrl
import org.jellyfin.androidtv.util.apiclient.itemImages
import org.jellyfin.androidtv.util.apiclient.parentImages
import org.jellyfin.androidtv.util.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.koin.core.component.KoinComponent
import org.koin.core.component.inject
import timber.log.Timber
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import kotlin.time.Duration
/**
* Manages channels on the android tv home screen.
*
@@ -102,39 +100,39 @@ class LeanbackChannelWorker(
// 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(),
.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()
.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()
.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()
.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()
.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]
@@ -146,21 +144,18 @@ class LeanbackChannelWorker(
latestEpisodes to latestEpisodesChannel,
myMedia to myMediaChannel,
).forEach { (items, channel) ->
if (channel == null) {
Timber.e("Skipping channel because it was not available")
} else {
items.map { item ->
createPreviewProgram(
channel,
item,
preferParentThumb
)
}.let {
context.contentResolver.bulkInsert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
it.toTypedArray()
)
}
Timber.d("Updating channel %s", channel)
items.map { item ->
createPreviewProgram(
channel,
item,
preferParentThumb
)
}.let {
context.contentResolver.bulkInsert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
it.toTypedArray()
)
}
}
updateWatchNext(resumeItems + nextUpItems)
@@ -183,46 +178,35 @@ class LeanbackChannelWorker(
* update or create the channel. The [name] parameter is used to store the id and should be
* unique.
*/
private fun getChannelUri(name: String, settings: Channel, default: Boolean = false): Uri? {
private fun getChannelUri(name: String, settings: Channel, default: Boolean = false): Uri {
val store = context.getSharedPreferences("leanback_channels", Context.MODE_PRIVATE)
var uri: Uri? = null
// Try and re-use our existing channel definition
if (store.contains(name)) {
uri = store.getString(name, null)?.toUri()
if (uri != null) {
val result = context.contentResolver.update(uri, settings.toContentValues(), null, null)
// If we did not affect exactly 1 row there might be something wrong, so recreate it
if (result != 1) uri = null
val uri = if (store.contains(name)) {
// Retrieve uri and update content resolver
Uri.parse(store.getString(name, null)).also { uri ->
context.contentResolver.update(uri, settings.toContentValues(), null, null)
}
}
if (uri == null) {
// Create new channel
uri = context.contentResolver.insert(
} else {
// Create new channel and save uri
context.contentResolver.insert(
TvContractCompat.Channels.CONTENT_URI,
settings.toContentValues()
)
// Set as default row to display (we can request one row to automatically be added to the home screen)
if (uri != null && default) {
TvContractCompat.requestChannelBrowsable(context, ContentUris.parseId(uri))
)!!.also { uri ->
store.edit().putString(name, uri.toString()).apply()
if (default) {
// Set as default row to display (we can request one row to automatically be added to the home screen)
TvContractCompat.requestChannelBrowsable(context, ContentUris.parseId(uri))
}
}
// Save uri to shared preferences
store.edit { putString(name, uri?.toString()) }
}
// Update logo
if (uri != null) {
ResourcesCompat.getDrawable(context.resources, R.mipmap.app_icon, context.theme)?.let {
ChannelLogoUtils.storeChannelLogo(
context,
ContentUris.parseId(uri),
it.toBitmap(80.dp(context), 80.dp(context))
)
}
ResourcesCompat.getDrawable(context.resources, R.mipmap.app_icon, context.theme)?.let {
ChannelLogoUtils.storeChannelLogo(
context,
ContentUris.parseId(uri),
it.toBitmap(80.dp(context), 80.dp(context))
)
}
return uri
@@ -237,6 +221,7 @@ class LeanbackChannelWorker(
// Add new items
return response.items
.orEmpty()
.filter { userViewsRepository.isSupported(it.collectionType) }
}
@@ -247,12 +232,35 @@ class LeanbackChannelWorker(
private fun BaseItemDto.getPosterArtImageUrl(
preferParentThumb: Boolean
): Uri = when {
type == BaseItemKind.MOVIE || type == BaseItemKind.SERIES -> itemImages[ImageType.PRIMARY]
(preferParentThumb || !itemImages.contains(ImageType.PRIMARY)) && parentImages.contains(ImageType.THUMB) -> parentImages[ImageType.THUMB]
else -> itemImages[ImageType.PRIMARY]
}.let { image ->
ImageProvider.getImageUri(image?.getUrl(api) ?: imageHelper.getResourceUrl(context, R.drawable.tile_land_tv))
}
type == BaseItemKind.MOVIE || type == BaseItemKind.SERIES -> 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(
itemId = parentThumbItemId!!,
imageType = ImageType.THUMB,
format = ImageFormat.WEBP,
width = 272.dp(context),
height = 153.dp(context),
tag = imageTags?.get(ImageType.THUMB),
)
imageTags?.containsKey(ImageType.PRIMARY) == true -> 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)
/**
* Gets the resume and next up episodes. The returned pair contains two lists:
@@ -263,13 +271,13 @@ class LeanbackChannelWorker(
withContext(Dispatchers.IO) {
val resume = async {
api.itemsApi.getResumeItems(
fields = ItemRepository.itemFields,
fields = listOf(ItemFields.DATE_CREATED),
imageTypeLimit = 1,
limit = 10,
mediaTypes = listOf(MediaType.VIDEO),
includeItemTypes = listOf(BaseItemKind.EPISODE, BaseItemKind.MOVIE),
excludeActiveSessions = true,
).content.items
).content.items.orEmpty()
}
val nextUp = async {
@@ -277,8 +285,8 @@ class LeanbackChannelWorker(
imageTypeLimit = 1,
limit = 10,
enableResumable = false,
fields = ItemRepository.itemFields,
).content.items
fields = listOf(ItemFields.DATE_CREATED),
).content.items.orEmpty()
}
// Concat
@@ -289,7 +297,9 @@ class LeanbackChannelWorker(
withContext(Dispatchers.IO) {
val latestEpisodes = async {
api.userLibraryApi.getLatestMedia(
fields = ItemRepository.itemFields,
fields = listOf(
ItemFields.OVERVIEW,
),
limit = 50,
includeItemTypes = listOf(BaseItemKind.EPISODE),
isPlayed = false
@@ -298,7 +308,9 @@ class LeanbackChannelWorker(
val latestMovies = async {
api.userLibraryApi.getLatestMedia(
fields = ItemRepository.itemFields,
fields = listOf(
ItemFields.OVERVIEW,
),
limit = 50,
includeItemTypes = listOf(BaseItemKind.MOVIE),
isPlayed = false
@@ -307,7 +319,9 @@ class LeanbackChannelWorker(
val latestMedia = async {
api.userLibraryApi.getLatestMedia(
fields = ItemRepository.itemFields,
fields = listOf(
ItemFields.OVERVIEW,
),
limit = 50,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
isPlayed = false
@@ -348,11 +362,23 @@ class LeanbackChannelWorker(
)
.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) {
@@ -366,22 +392,8 @@ class LeanbackChannelWorker(
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
putExtra(StartupActivity.EXTRA_ITEM_IS_USER_VIEW, item.type == BaseItemKind.COLLECTION_FOLDER)
})
.setDurationMillis(
if (item.runTimeTicks?.ticks != null) {
// If we are resuming, we need to show remaining time, cause GoogleTV
// ignores setLastPlaybackPositionMillis
val duration = item.runTimeTicks?.ticks ?: Duration.ZERO
val playbackPosition = item.userData?.playbackPositionTicks?.ticks
?: Duration.ZERO
(duration - playbackPosition).inWholeMilliseconds.toInt()
} else 0
)
.apply {
if ((item.parentIndexNumber ?: 0) > 0)
setSeasonNumber(seasonString, item.parentIndexNumber!!)
if ((item.indexNumber ?: 0) > 0)
setEpisodeNumber(episodeString, item.indexNumber!!)
}.build().toContentValues()
.build()
.toContentValues()
}
/**
@@ -389,57 +401,16 @@ class LeanbackChannelWorker(
* or other types of media. Uses the [nextUpItems] parameter to store items returned by a
* NextUpQuery().
*/
@SuppressLint("RestrictedApi")
private fun updateWatchNext(nextUpItems: List<BaseItemDto>) {
deletePrograms(nextUpItems)
// Delete current items
context.contentResolver.delete(WatchNextPrograms.CONTENT_URI, null, null)
// Get current watch next state
val currentWatchNextPrograms = getCurrentWatchNext()
// Create all programs in nextUpItems but not in watch next
val programsToAdd = nextUpItems
.filter { next -> currentWatchNextPrograms.none { it.internalProviderId == next.id.toString() } }
// Add new items
context.contentResolver.bulkInsert(
WatchNextPrograms.CONTENT_URI,
programsToAdd.map { item -> getBaseItemAsWatchNextProgram(item).toContentValues() }
.toTypedArray())
}
/**
* Delete stale programs from the watch next row. Items that don't need to be touched are
* kept as is, so they keep their ordering in the watch next row.
*/
@SuppressLint("RestrictedApi")
private fun deletePrograms(nextUpItems: List<BaseItemDto>) {
// Retrieve current watch next row
val currentWatchNextPrograms = getCurrentWatchNext()
// Find all stale programs to delete
val deletedByUser = currentWatchNextPrograms.filter { !it.isBrowsable }
val noLongerInWatchNext =
currentWatchNextPrograms.filter { (nextUpItems).none { next -> it.internalProviderId == next.id.toString() } }
val continueWatching = currentWatchNextPrograms.filter { it.watchNextType == WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE }
// Delete the programs
(deletedByUser + noLongerInWatchNext + continueWatching)
.forEach { context.contentResolver.delete(TvContractCompat.buildWatchNextProgramUri(it.id), null, null) }
}
/**
* Retrieves the current watch next row state.
*/
@SuppressLint("RestrictedApi")
private fun getCurrentWatchNext(): MutableList<WatchNextProgram> {
val currentWatchNextPrograms: MutableList<WatchNextProgram> = mutableListOf()
context.contentResolver.query(WatchNextPrograms.CONTENT_URI, WatchNextProgram.PROJECTION, null, null, null)
.use { cursor ->
if (cursor != null && cursor.moveToFirst()) {
do {
currentWatchNextPrograms.add(WatchNextProgram.fromCursor(cursor))
} while (cursor.moveToNext())
}
}
return currentWatchNextPrograms
nextUpItems.map { item -> getBaseItemAsWatchNextProgram(item).toContentValues() }
.toTypedArray()
)
}
/**
@@ -461,51 +432,39 @@ class LeanbackChannelWorker(
setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_MOVIE_POSTER)
}
// Name and episode details
if (item.seriesName != null) {
setTitle(item.seriesName)
setEpisodeTitle(item.name)
item.indexNumber?.takeIf { it > 0 }?.let { setEpisodeNumber(it) }
item.parentIndexNumber?.takeIf { it > 0 }?.let { setSeasonNumber(it) }
} else {
setTitle(item.name)
}
setDescription(item.overview?.stripHtml())
// 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.inWholeMilliseconds.toInt())
// Use last played date to prioritize
setLastEngagementTimeUtcMillis(
item.userData?.lastPlayedDate?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
?: Instant.now().toEpochMilli()
)
engagement = item.userData?.lastPlayedDate
}
// First episode of the season
item.indexNumber == 1 -> {
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEW)
setLastEngagementTimeUtcMillis(
item.dateCreated?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
?: Instant.now().toEpochMilli()
)
}
item.indexNumber == 1 -> setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEW)
// Default
else -> {
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
setLastEngagementTimeUtcMillis(Instant.now().toEpochMilli())
}
else -> setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
}
// Runtime has been determined
item.runTimeTicks?.ticks?.let { setDurationMillis(it.inWholeMilliseconds.toInt()) }
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 {

View File

@@ -9,22 +9,19 @@ import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.provider.BaseColumns
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.data.repository.ItemRepository
import org.jellyfin.androidtv.integration.provider.ImageProvider
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.androidtv.util.apiclient.getUrl
import org.jellyfin.androidtv.util.apiclient.itemImages
import org.jellyfin.androidtv.util.sdk.isUsable
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.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import timber.log.Timber
@@ -64,9 +61,8 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
val limit = uri.getQueryParameter(SearchManager.SUGGEST_PARAMETER_LIMIT)?.toIntOrNull()
?: DEFAULT_LIMIT
return runBlocking { getSuggestions(query, limit) }
return getSuggestions(query, limit)
}
else -> throw IllegalArgumentException("Unknown Uri: $uri")
}
}
@@ -79,7 +75,7 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
searchTerm = query,
recursive = true,
limit = limit,
fields = ItemRepository.itemFields
fields = setOf(ItemFields.TAGLINES)
)
items
@@ -88,7 +84,7 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
null
}
private suspend fun getSuggestions(query: String, limit: Int) = withContext(Dispatchers.IO) {
private fun getSuggestions(query: String, limit: Int) = runBlocking {
val searchResult = searchItems(query, limit)
if (searchResult != null) Timber.d("Query resulted in %d items", searchResult.totalRecordCount)
@@ -108,10 +104,10 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
MatrixCursor(columns).also { cursor ->
searchResult?.items?.forEach { item ->
val imageUri = ImageProvider.getImageUri(
item.itemImages[ImageType.PRIMARY]?.getUrl(api)
?: imageHelper.getResourceUrl(context!!, R.drawable.tile_land_tv)
)
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)
cursor.newRow().apply {
add(BaseColumns._ID, item.id)

View File

@@ -2,43 +2,39 @@ 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 coil3.ImageLoader
import coil3.request.ImageRequest
import coil3.toBitmap
import coil.ImageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.cancellable
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
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.util.apiclient.getUrl
import org.jellyfin.androidtv.util.apiclient.itemBackdropImages
import org.jellyfin.androidtv.util.apiclient.itemImages
import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.core.queue.queue
import org.jellyfin.playback.jellyfin.queue.baseItem
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
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@SuppressLint("StaticFieldLeak")
@@ -46,18 +42,35 @@ class DreamViewModel(
private val api: ApiClient,
private val imageLoader: ImageLoader,
private val context: Context,
playbackManager: PlaybackManager,
private val mediaManager: MediaManager,
private val userPreferences: UserPreferences,
) : ViewModel() {
@OptIn(ExperimentalCoroutinesApi::class)
private val _mediaContent = playbackManager.queue.entry
.map { entry ->
entry
?.takeIf { it.visibleInScreensaver }
?.baseItem
?.let { baseItem -> DreamContent.NowPlaying(entry, baseItem) }
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)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
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
@@ -65,19 +78,15 @@ class DreamViewModel(
emit(null)
delay(2.seconds)
val requireParentalRating = userPreferences[UserPreferences.screensaverAgeRatingRequired]
val maxParentalRating = userPreferences[UserPreferences.screensaverAgeRatingMax]
emitAll(
getRandomLibraryShowcaseItems(
requireParentalRating = requireParentalRating,
maxParentalRating = maxParentalRating,
// A batch size of 60 should be equal to 30 minutes of items
batchSize = 60,
emitDelay = 30.seconds,
noItemsDelay = 2.minutes,
errorDelay = 3.seconds,
)
)
while (true) {
val next = getRandomLibraryShowcase()
if (next != null) {
emit(next)
delay(30.seconds)
} else {
delay(3.seconds)
}
}
}
.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
@@ -90,71 +99,70 @@ class DreamViewModel(
initialValue = _mediaContent.value ?: _libraryContent.value ?: DreamContent.Logo,
)
private fun getRandomLibraryShowcaseItems(
requireParentalRating: Boolean,
maxParentalRating: Int,
batchSize: Int,
emitDelay: Duration,
noItemsDelay: Duration,
errorDelay: Duration,
): Flow<DreamContent.LibraryShowcase?> = flow {
while (true) {
val items = try {
withContext(Dispatchers.IO) {
val response by api.itemsApi.getItems(
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
recursive = true,
sortBy = listOf(ItemSortBy.RANDOM),
limit = batchSize,
imageTypes = listOf(ImageType.BACKDROP),
maxOfficialRating = if (maxParentalRating == -1) null else maxParentalRating.toString(),
hasParentalRating = if (requireParentalRating) true else null,
)
response.items
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()
}
} catch (err: ApiClientException) {
Timber.e(err)
null
}
if (items == null) {
emit(null)
delay(errorDelay)
} else if (items.isEmpty()) {
emit(null)
delay(noItemsDelay)
} else {
for (item in items) {
if (item.itemBackdropImages.isEmpty()) continue
val showcase = item.asLibraryShowcase() ?: continue
emit(showcase)
delay(emitDelay)
val backdropDeferred = async {
imageLoader.execute(
request = ImageRequest.Builder(context).data(backdropUrl).build()
).drawable?.toBitmap()
}
awaitAll(logoDeferred, backdropDeferred)
}
}
}.cancellable()
private suspend fun BaseItemDto.asLibraryShowcase(): DreamContent.LibraryShowcase? = withContext(Dispatchers.IO) {
val logoUrl = itemImages[ImageType.LOGO]?.getUrl(api)
val backdropUrl = itemBackdropImages.randomOrNull()?.getUrl(api)
// Require a backdrop
if (backdropUrl == null) return@withContext null
// Only attempt to load logo if there is one, wrap in async {} to load it parallel with the backdrop
val logo = logoUrl?.let { url ->
async {
imageLoader.execute(
request = ImageRequest.Builder(context).data(url).build()
).image?.toBitmap()
if (backdrop == null) {
return null
}
return DreamContent.LibraryShowcase(item, backdrop, logo)
} catch (err: ApiClientException) {
Timber.e(err)
return null
}
val backdrop = imageLoader.execute(
request = ImageRequest.Builder(context).data(backdropUrl).build()
).image?.toBitmap()
if (backdrop == null) null
else DreamContent.LibraryShowcase(this@asLibraryShowcase, backdrop, logo?.await())
}
}

View File

@@ -1,12 +0,0 @@
package org.jellyfin.androidtv.integration.dream
import org.jellyfin.playback.core.element.ElementKey
import org.jellyfin.playback.core.element.requiredElement
import org.jellyfin.playback.core.queue.QueueEntry
private val visibleInScreensaverKey = ElementKey<Boolean>("visibleInScreensaver")
/**
* Get or set whether this [QueueEntry] is shown in the screensaver.
*/
var QueueEntry.visibleInScreensaver by requiredElement(visibleInScreensaverKey) { false }

View File

@@ -2,10 +2,9 @@ package org.jellyfin.androidtv.integration.dream.composable
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -15,10 +14,10 @@ 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.base.Text
import org.jellyfin.androidtv.ui.composable.ZoomBox
import org.jellyfin.androidtv.ui.composable.modifier.overscan
import org.jellyfin.androidtv.ui.composable.overscan
@Composable
fun DreamContentLibraryShowcase(
@@ -51,17 +50,12 @@ fun DreamContentLibraryShowcase(
.overscan(),
) {
if (content.logo != null) {
BoxWithConstraints {
Image(
bitmap = content.logo.asImageBitmap(),
contentDescription = content.item.name,
modifier = Modifier
.sizeIn(
maxWidth = maxWidth * 0.35f,
maxHeight = 75.dp
)
)
}
Image(
bitmap = content.logo.asImageBitmap(),
contentDescription = content.item.name,
modifier = Modifier
.height(75.dp)
)
} else {
Text(
text = content.item.name.orEmpty(),

View File

@@ -14,39 +14,36 @@ 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.collectAsState
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.base.SeekbarDefaults
import org.jellyfin.androidtv.ui.base.Text
import org.jellyfin.androidtv.ui.composable.AsyncImage
import org.jellyfin.androidtv.ui.composable.LyricsDtoBox
import org.jellyfin.androidtv.ui.composable.blurHashPainter
import org.jellyfin.androidtv.ui.composable.modifier.fadingEdges
import org.jellyfin.androidtv.ui.composable.modifier.overscan
import org.jellyfin.androidtv.ui.composable.rememberPlayerProgress
import org.jellyfin.androidtv.ui.player.base.PlayerSeekbar
import org.jellyfin.androidtv.util.apiclient.albumPrimaryImage
import org.jellyfin.androidtv.util.apiclient.getUrl
import org.jellyfin.androidtv.util.apiclient.itemImages
import org.jellyfin.androidtv.util.apiclient.parentImages
import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.jellyfin.lyrics
import org.jellyfin.playback.jellyfin.lyricsFlow
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(
@@ -55,17 +52,21 @@ fun DreamContentNowPlaying(
modifier = Modifier.fillMaxSize(),
) {
val api = koinInject<ApiClient>()
val playbackManager = koinInject<PlaybackManager>()
val lyrics = content.entry.run { lyricsFlow.collectAsState(lyrics) }.value
val mediaManager = koinInject<MediaManager>()
val item = content.item ?: return@Box
val primaryImage = content.item.itemImages[ImageType.PRIMARY]
?: content.item.albumPrimaryImage
?: content.item.parentImages[ImageType.PRIMARY]
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
}
// Background
if (primaryImage?.blurHash != null) {
val imageBlurHash =
imageTag?.let { tag -> item.imageBlurHashes?.get(ImageType.PRIMARY)?.get(tag) }
if (imageBlurHash != null) {
Image(
painter = blurHashPainter(primaryImage.blurHash, IntSize(32, 32)),
painter = blurHashPainter(imageBlurHash, IntSize(32, 32)),
contentDescription = null,
alignment = Alignment.Center,
contentScale = ContentScale.Crop,
@@ -75,29 +76,7 @@ fun DreamContentNowPlaying(
DreamContentVignette()
}
// Lyrics overlay (on top of background)
if (lyrics != null) {
val playState by remember { playbackManager.state.playState }.collectAsState()
// Using the progress animation causes the layout to recompose, which we need for synced lyrics to work
// we don't actually use the animation value here
rememberPlayerProgress(playbackManager)
LyricsDtoBox(
lyricDto = lyrics,
currentTimestamp = playbackManager.state.positionInfo.active,
duration = playbackManager.state.positionInfo.duration,
paused = playState != PlayState.PLAYING,
fontSize = 22.sp,
color = Color.White,
modifier = Modifier
.fillMaxSize()
.fadingEdges(vertical = 250.dp)
.padding(horizontal = 50.dp),
)
}
// Metadata overlay (includes title / progress)
// Overlay
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(20.dp),
@@ -105,10 +84,15 @@ fun DreamContentNowPlaying(
.align(Alignment.BottomStart)
.overscan(),
) {
if (primaryImage != null) {
if (imageItemId != null) {
AsyncImage(
url = primaryImage.getUrl(api),
blurHash = primaryImage.blurHash,
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)
@@ -121,7 +105,7 @@ fun DreamContentNowPlaying(
.padding(bottom = 10.dp)
) {
Text(
text = content.item.name.orEmpty(),
text = item.name.orEmpty(),
style = TextStyle(
color = Color.White,
fontSize = 26.sp,
@@ -129,7 +113,7 @@ fun DreamContentNowPlaying(
)
Text(
text = content.item.run {
text = item.run {
val artistNames = artists.orEmpty()
val albumArtistNames = albumArtists?.mapNotNull { it.name }.orEmpty()
@@ -147,16 +131,33 @@ fun DreamContentNowPlaying(
Spacer(modifier = Modifier.height(10.dp))
PlayerSeekbar(
playbackManager = playbackManager,
colors = SeekbarDefaults.colors(
backgroundColor = Color.White.copy(alpha = 0.2f),
progressColor = Color.White,
bufferColor = Color.Transparent,
),
var progress by remember { mutableFloatStateOf(0f) }
DisposableEffect(Unit) {
val listener = object : AudioEventListener {
override fun onProgress(pos: Long) {
val duration = item.runTimeTicks?.ticks ?: Duration.ZERO
progress = (pos.milliseconds / duration).toFloat()
}
}
mediaManager.addAudioEventListener(listener)
onDispose {
mediaManager.removeAudioEventListener(listener)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.drawWithContent {
// Background
drawRect(Color.White, alpha = 0.2f)
// Foreground
drawRect(Color.White, size = size.copy(width = size.width * progress))
}
)
}
}

View File

@@ -1,32 +1,72 @@
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.layout.Box
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.Alignment
import androidx.compose.ui.Modifier
import org.jellyfin.androidtv.ui.composable.modifier.overscan
import org.jellyfin.androidtv.ui.shared.toolbar.ToolbarClock
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,
) = Box(
modifier = Modifier
.fillMaxWidth()
.overscan(),
) {
// Clock
AnimatedVisibility(
visible = showClock,
enter = fadeIn(),
exit = fadeOut(),
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.align(Alignment.TopEnd),
.fillMaxWidth()
.overscan(),
) {
ToolbarClock()
// Logo
AnimatedVisibility(
visible = showLogo,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.height(41.dp),
) {
Image(
painter = painterResource(R.drawable.app_logo),
contentDescription = stringResource(R.string.app_name),
)
}
Spacer(
modifier = Modifier
.fillMaxWidth(0f)
)
// Clock
AnimatedVisibility(
visible = showClock,
enter = fadeIn(),
exit = fadeOut(),
) {
val time = rememberCurrentTime()
Text(
text = DateUtils.formatDateTime(LocalContext.current, time, DateUtils.FORMAT_SHOW_TIME),
style = TextStyle(
color = Color.White,
fontSize = 20.sp
),
)
}
}
}

View File

@@ -36,6 +36,7 @@ fun DreamView(
// Header overlay
DreamHeader(
showLogo = content != DreamContent.Logo,
showClock = showClock,
)
}

View File

@@ -1,11 +1,10 @@
package org.jellyfin.androidtv.integration.dream.model
import android.graphics.Bitmap
import org.jellyfin.playback.core.queue.QueueEntry
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 entry: QueueEntry, val item: BaseItemDto) : DreamContent
data class NowPlaying(val item: BaseItemDto?) : DreamContent
}

View File

@@ -9,10 +9,8 @@ import android.os.Build
import android.os.ParcelFileDescriptor
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import coil3.ImageLoader
import coil3.asDrawable
import coil3.request.ImageRequest
import coil3.request.error
import coil.ImageLoader
import coil.request.ImageRequest
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.R
import org.koin.android.ext.android.inject
@@ -39,8 +37,8 @@ class ImageProvider : ContentProvider() {
data(src)
error(R.drawable.placeholder_icon)
target(
onSuccess = { image -> writeDrawable(image.asDrawable(context!!.resources), outputStream) },
onError = { image -> writeDrawable(requireNotNull(image?.asDrawable(context!!.resources)), outputStream) }
onSuccess = { drawable -> writeDrawable(drawable, outputStream) },
onError = { drawable -> writeDrawable(requireNotNull(drawable), outputStream) }
)
}.build())

View File

@@ -3,26 +3,19 @@ package org.jellyfin.androidtv.preference
import android.content.Context
import android.view.KeyEvent
import androidx.preference.PreferenceManager
import org.jellyfin.androidtv.preference.UserPreferences.Companion.screensaverInAppEnabled
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.RatingType
import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior
import org.jellyfin.androidtv.preference.constant.StillWatchingBehavior
import org.jellyfin.androidtv.preference.constant.WatchedIndicatorBehavior
import org.jellyfin.androidtv.preference.constant.ZoomMode
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentAction
import org.jellyfin.androidtv.ui.playback.segment.toMediaSegmentActionsString
import org.jellyfin.preference.booleanPreference
import org.jellyfin.preference.enumPreference
import org.jellyfin.preference.floatPreference
import org.jellyfin.preference.intPreference
import org.jellyfin.preference.longPreference
import org.jellyfin.preference.store.SharedPreferenceStore
import org.jellyfin.preference.stringPreference
import org.jellyfin.sdk.model.api.MediaSegmentType
import kotlin.time.Duration.Companion.minutes
/**
@@ -52,7 +45,7 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
var premieresEnabled = booleanPreference("pref_enable_premieres", false)
/**
* Enable management of media like deleting items when the user has sufficient permissions.
* Enable management of media like deleting items when the user has sufficient permisisons.
*/
var mediaManagementEnabled = booleanPreference("enable_media_management", false)
@@ -88,11 +81,6 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
*/
var cinemaModeEnabled = booleanPreference("pref_enable_cinema_mode", true)
/**
* Enable still watching
*/
var stillWatchingBehavior = enumPreference("enable_still_watching", StillWatchingBehavior.DISABLED)
/* Playback - Video */
/**
* Whether to use an external playback application or not.
@@ -104,11 +92,6 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
*/
var refreshRateSwitchingBehavior = enumPreference("refresh_rate_switching_behavior", RefreshRateSwitchingBehavior.DISABLED)
/**
* Whether ExoPlayer should prefer FFmpeg renderers to core ones.
*/
var preferExoPlayerFfmpeg = booleanPreference("exoplayer_prefer_ffmpeg", defaultValue = false)
/* Playback - Audio related */
/**
* Preferred behavior for audio streaming.
@@ -173,35 +156,30 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
var seriesThumbnailsEnabled = booleanPreference("pref_enable_series_thumbnails", true)
/**
* Subtitles foreground color
* Enable subtitles background
*/
var subtitlesBackgroundColor = longPreference("subtitles_background_color", 0x00FFFFFF)
var subtitlesBackgroundEnabled = booleanPreference("subtitles_background_enabled", true)
/**
* Subtitles bold text
* Subtitles font size
*/
var subtitlesTextWeight = intPreference("subtitles_text_weight", 400)
var subtitlesSize = intPreference("subtitles_size", 28)
/**
* Subtitles stroke size
*/
var subtitleStrokeSize = intPreference("subtitles_stroke_size", 0)
/**
* Subtitles position
*/
var subtitlePosition = intPreference("subtitles_position", 40)
/**
* Subtitles foreground color
*/
var subtitlesTextColor = longPreference("subtitles_text_color", 0xFFFFFFFF)
/**
* Subtitles stroke color
*/
var subtitleTextStrokeColor = longPreference("subtitles_text_stroke_color", 0xFF000000)
/**
* Subtitles font size
*/
var subtitlesTextSize = floatPreference("subtitles_text_size", 1f)
/**
* Subtitles offset
*/
var subtitlesOffsetPosition = floatPreference("subtitles_offset_position", 0.08f)
/**
* Show screensaver in app
*/
@@ -221,37 +199,6 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
* Whether items shown in the screensaver are required to have an age rating set.
*/
var screensaverAgeRatingRequired = booleanPreference("screensaver_agerating_required", true)
/**
* Delay when starting video playback after loading the video player.
*/
var videoStartDelay = longPreference("video_start_delay", 0)
/**
* The actions to take for each media segment type. Managed by the [MediaSegmentRepository].
*/
var mediaSegmentActions = stringPreference(
key = "media_segment_actions",
defaultValue = mapOf(
MediaSegmentType.INTRO to MediaSegmentAction.ASK_TO_SKIP,
MediaSegmentType.OUTRO to MediaSegmentAction.ASK_TO_SKIP,
).toMediaSegmentActionsString()
)
/**
* Preferred behavior for player aspect ratio (zoom mode).
*/
var playerZoomMode = enumPreference("player_zoom_mode", ZoomMode.FIT)
/**
* Enable TrickPlay in legacy player user interface while seeking.
*/
var trickPlayEnabled = booleanPreference("trick_play_enabled", false)
/**
* Enable PGS subtitle direct-play.
*/
var pgsDirectPlay = booleanPreference("pgs_enabled", true)
}
init {
@@ -264,17 +211,6 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
// Enable playback rewrite for music
putBoolean("playback_new_audio", true)
}
// v0.17.z to v0.18.0
migration(toVersion = 8) {
// Set subtitle background color to black if it was enabled in a previous version
val subtitlesBackgroundEnabled = it.getBoolean("subtitles_background_enabled", true)
putLong("subtitles_background_color", if (subtitlesBackgroundEnabled) 0XFF000000L else 0X00FFFFFFL)
// Set subtitle text stroke color to black if it was enabled in a previous version
val subtitleStrokeSize = it.getInt("subtitles_stroke_size", 0)
putLong("subtitles_text_stroke_color", if (subtitleStrokeSize > 0) 0XFF000000L else 0X00FFFFFFL)
}
}
}
}

View File

@@ -24,26 +24,10 @@ class UserSettingPreferences(
val homesection4 = enumPreference("homesection4", HomeSectionType.LIVE_TV)
val homesection5 = enumPreference("homesection5", HomeSectionType.NEXT_UP)
val homesection6 = enumPreference("homesection6", HomeSectionType.LATEST_MEDIA)
val homesection7 = enumPreference("homesection7", HomeSectionType.NONE)
val homesection8 = enumPreference("homesection8", HomeSectionType.NONE)
val homesection9 = enumPreference("homesection9", HomeSectionType.NONE)
}
val homesections = listOf(
homesection0,
homesection1,
homesection2,
homesection3,
homesection4,
homesection5,
homesection6,
homesection7,
homesection8,
homesection9,
)
val activeHomesections
get() = homesections
val homesections
get() = listOf(homesection0, homesection1, homesection2, homesection3, homesection4, homesection5, homesection6)
.map(::get)
.filterNot { it == HomeSectionType.NONE }
}

View File

@@ -1,63 +0,0 @@
package org.jellyfin.androidtv.preference.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
enum class StillWatchingBehavior(
override val nameRes: Int,
val enabled: Boolean,
val episodeCount: Int,
val minDuration: Duration,
) : PreferenceEnum {
/**
* Shorter than default. Show screen at 2 episodes or 60 minutes of uninterrupted watch time.
*/
SHORT(
nameRes = R.string.lbl_still_watching_short,
enabled = true,
episodeCount = 2,
minDuration = 1.hours,
),
/**
* Default behavior for still watching. Show screen at 3 episodes or 90 minutes of uninterrupted watch time.
*/
DEFAULT(
nameRes = R.string.lbl_still_watching_default,
enabled = true,
episodeCount = 3,
minDuration = 1.5.hours,
),
/**
* Longer than default. Show screen at 5 episodes or 150 minutes of uninterrupted watch time.
*/
LONG(
nameRes = R.string.lbl_still_watching_long,
enabled = true,
episodeCount = 5,
minDuration = 2.5.hours,
),
/**
* Much longer than default. Show screen at 8 episodes or 240 minutes of uninterrupted watch time.
*/
VERY_LONG(
nameRes = R.string.lbl_still_watching_very_long,
enabled = true,
episodeCount = 8,
minDuration = 4.hours,
),
/**
* Disables still watching screen.
*/
DISABLED(
nameRes = R.string.state_disabled,
enabled = false,
episodeCount = 0,
minDuration = Duration.ZERO,
)
}

View File

@@ -1,24 +0,0 @@
package org.jellyfin.androidtv.preference.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
enum class ZoomMode(
override val nameRes: Int,
) : PreferenceEnum {
/**
* Sets the zoom mode to normal (fit).
*/
FIT(R.string.lbl_fit),
/**
* Sets the zoom mode to auto crop.
*/
AUTO_CROP(R.string.lbl_auto_crop),
/**
* Sets the zoom mode to stretch.
*/
STRETCH(R.string.lbl_stretch),
}

View File

@@ -1,7 +1,5 @@
package org.jellyfin.androidtv.preference.store
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.preference.Preference
import org.jellyfin.preference.PreferenceEnum
import org.jellyfin.preference.migration.MigrationContext
@@ -58,12 +56,10 @@ abstract class DisplayPreferencesStore(
override suspend fun update(): Boolean {
try {
val result = withContext(Dispatchers.IO) {
api.displayPreferencesApi.getDisplayPreferences(
displayPreferencesId = displayPreferencesId,
client = app
).content
}
val result by api.displayPreferencesApi.getDisplayPreferences(
displayPreferencesId = displayPreferencesId,
client = app
)
displayPreferencesDto = result
cachedPreferences = result.customPrefs.toMutableMap()
@@ -86,9 +82,6 @@ abstract class DisplayPreferencesStore(
override fun getLong(key: String, defaultValue: Long) =
cachedPreferences[key]?.toLongOrNull() ?: defaultValue
override fun getFloat(key: String, defaultValue: Float) =
cachedPreferences[key]?.toFloatOrNull() ?: defaultValue
override fun getBool(key: String, defaultValue: Boolean) =
cachedPreferences[key]?.toBooleanStrictOrNull() ?: defaultValue
@@ -103,10 +96,6 @@ abstract class DisplayPreferencesStore(
cachedPreferences[key] = value.toString()
}
override fun setFloat(key: String, value: Float) {
cachedPreferences[key] = value.toString()
}
override fun setBool(key: String, value: Boolean) {
cachedPreferences[key] = value.toString()
}

View File

@@ -18,11 +18,6 @@ import org.acra.sender.ReportSenderFactory
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.preference.TelemetryPreferences
import org.jellyfin.androidtv.util.appendCodeBlock
import org.jellyfin.androidtv.util.appendItem
import org.jellyfin.androidtv.util.appendSection
import org.jellyfin.androidtv.util.appendValue
import org.jellyfin.androidtv.util.buildMarkdown
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
import java.net.HttpURLConnection
import java.net.URL
@@ -68,12 +63,8 @@ object TelemetryService {
// Create connection
val connection = URL(url).openConnection() as HttpURLConnection
// Add authorization
val clientName = buildString {
append("Jellyfin Android TV")
if (BuildConfig.DEBUG) append(" (debug)")
}
val authorization = AuthorizationHeaderBuilder.buildHeader(
clientName = clientName,
clientName = BuildConfig.APPLICATION_ID,
clientVersion = BuildConfig.VERSION_NAME,
deviceId = "",
deviceName = "",
@@ -94,7 +85,31 @@ object TelemetryService {
throw ReportSenderException("Unable to send crash report to server", e)
}
private fun CrashReportData.toReport(): String = buildMarkdown {
private fun StringBuilder.appendSection(name: String, content: StringBuilder.() -> Unit) {
appendLine("### $name")
appendLine()
content()
appendLine()
}
private fun StringBuilder.appendItem(name: String, value: StringBuilder.() -> Unit) {
append("***$name***: ")
value()
appendLine(" ")
}
private fun StringBuilder.appendCodeBlock(language: String, code: String?) {
appendLine()
appendLine("```$language")
appendLine(code ?: "<null>")
append("```")
}
private fun StringBuilder.appendValue(value: String?) {
append("`", value ?: "<null>", "`")
}
private fun CrashReportData.toReport(): String = buildString {
// Header
appendLine("---")
appendLine("client: Jellyfin for Android TV")
@@ -103,7 +118,6 @@ object TelemetryService {
appendLine("type: crash_report")
appendLine("format: markdown")
appendLine("---")
appendLine()
// Content
appendSection("Logs") {

View File

@@ -1,25 +1,18 @@
package org.jellyfin.androidtv.ui
import android.app.ActivityManager
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.widget.ImageView
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.doOnAttach
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import coil3.ImageLoader
import coil3.asImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import coil3.request.target
import coil3.request.transformations
import coil3.transform.CircleCropTransformation
import coil.ImageLoader
import coil.request.ImageRequest
import coil.transform.CircleCropTransformation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.R
@@ -42,7 +35,6 @@ class AsyncImageView @JvmOverloads constructor(
private val lifeCycleOwner get() = findViewTreeLifecycleOwner()
private val styledAttributes = context.obtainStyledAttributes(attrs, R.styleable.AsyncImageView, defStyleAttr, 0)
private val imageLoader by inject<ImageLoader>()
private var loadJob: Job? = null
/**
* The duration of the crossfade when changing switching the images of the url, blurhash and
@@ -68,15 +60,11 @@ class AsyncImageView @JvmOverloads constructor(
aspectRatio: Double = 1.0,
blurHashResolution: Int = 32,
) = doOnAttach {
// Cancel the previous load if still running
loadJob?.cancel()
loadJob = lifeCycleOwner?.lifecycleScope?.launch(Dispatchers.IO) {
lifeCycleOwner?.lifecycleScope?.launch {
var placeholderOrBlurHash = placeholder
// Only show blurhash if an image is going to be loaded from the network
val isLowRamDevice = context.getSystemService<ActivityManager>()?.isLowRamDevice == true
if (url != null && blurHash != null && !isLowRamDevice) withContext(Dispatchers.IO) {
if (url != null && blurHash != null) withContext(Dispatchers.IO) {
val blurHashBitmap = BlurHashDecoder.decode(
blurHash,
if (aspectRatio > 1) round(blurHashResolution * aspectRatio).toInt() else blurHashResolution,
@@ -86,27 +74,25 @@ class AsyncImageView @JvmOverloads constructor(
}
// Start loading image or placeholder
val request = if (url == null) {
ImageRequest.Builder(context).apply {
if (url == null) {
imageLoader.enqueue(ImageRequest.Builder(context).apply {
target(this@AsyncImageView)
data(placeholder)
if (circleCrop) transformations(CircleCropTransformation())
}.build()
}.build())
} else {
ImageRequest.Builder(context).apply {
imageLoader.enqueue(ImageRequest.Builder(context).apply {
val crossFadeDurationMs = crossFadeDuration.inWholeMilliseconds.toInt()
if (crossFadeDurationMs > 0) crossfade(crossFadeDurationMs)
else crossfade(false)
target(this@AsyncImageView)
data(url)
placeholder(placeholderOrBlurHash?.asImage())
placeholder(placeholderOrBlurHash)
if (circleCrop) transformations(CircleCropTransformation())
error(placeholder?.asImage())
}.build()
error(placeholder)
}.build())
}
imageLoader.enqueue(request).job.await()
}
}
}

View File

@@ -0,0 +1,61 @@
package org.jellyfin.androidtv.ui
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.animation.AlphaAnimation
import android.widget.FrameLayout
import androidx.annotation.IntRange
class FadeViewSwitcherLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
companion object {
const val VIEW_NONE = -1
}
private var currentView = VIEW_NONE
private val nextViewId
get() = if (currentView >= childCount - 1) 0 else currentView + 1
private val previousViewId
get() = if (currentView <= 0) childCount - 1 else currentView - 1
override fun onViewAdded(child: View?) {
super.onViewAdded(child)
// Hide new views by default
child?.alpha = 0f
}
fun showNextView() = showView(nextViewId)
fun showPreviousView() = showView(previousViewId)
fun hideAllViews() = showView(VIEW_NONE)
fun <V : View> getNextView() = getChildAt(nextViewId) as V
fun <V : View> getCurrentView() = if (currentView == VIEW_NONE) null else currentView as V
fun <V : View> getPreviousView() = getChildAt(previousViewId) as V
fun showView(@IntRange(from = -1) view: Int) {
// TODO: right now crossfades, need to be smarter to hide old view after new view is completely visible
if (currentView != VIEW_NONE) getChildAt(currentView).fadeOut()
if (view != VIEW_NONE) getChildAt(view).fadeIn()
currentView = view
}
private fun View.fadeIn() {
alpha = 1f
startAnimation(AlphaAnimation(0f, 1f).apply {
duration = 1_000
fillAfter = true
})
}
private fun View.fadeOut() = startAnimation(AlphaAnimation(1f, 0f).apply {
duration = 1_000
fillAfter = true
})
}

View File

@@ -1,160 +0,0 @@
package org.jellyfin.androidtv.ui
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
class InteractionTrackerViewModel(
private val userPreferences: UserPreferences,
private val playbackControllerContainer: PlaybackControllerContainer
) : ViewModel() {
// Screensaver
private var timer: Job? = null
private var locks = 0
// Still Watching
private var isWatchingEpisodes = false
private var episodeCount = 0
private var watchTime = 0L
private var episodeWasInterrupted: Boolean = false
// Preferences
private val inAppEnabled get() = userPreferences[UserPreferences.screensaverInAppEnabled]
private val timeout get() = userPreferences[UserPreferences.screensaverInAppTimeout].milliseconds
private val stillWatchingBehavior get() = userPreferences[UserPreferences.stillWatchingBehavior]
// State
private val _screensaverVisible = MutableStateFlow(false)
val visible get() = _screensaverVisible.asStateFlow()
private val _keepScreenOn = MutableStateFlow(inAppEnabled)
val keepScreenOn get() = _keepScreenOn.asStateFlow()
var activityPaused: Boolean = false
set(value) {
field = value
notifyInteraction(canCancel = true, userInitiated = false)
}
init {
notifyInteraction(canCancel = true, userInitiated = false)
}
fun getShowStillWatching(): Boolean {
if (!stillWatchingBehavior.enabled) return false
if (stillWatchingBehavior.episodeCount > 0 && episodeCount >= stillWatchingBehavior.episodeCount) return true
if (stillWatchingBehavior.minDuration > Duration.ZERO && watchTime.milliseconds >= stillWatchingBehavior.minDuration) return true
return false
}
fun notifyStartSession(item: BaseItemDto, items: List<BaseItemDto>) {
// No need to track when only watching 1 episode
if (item.type == BaseItemKind.EPISODE && items.size > 1) {
resetSession()
episodeWasInterrupted = false
}
}
fun notifyStillWatching() {
resetSession()
}
fun onEpisodeWatched() {
if (!episodeWasInterrupted) episodeCount++
watchTime += playbackControllerContainer.playbackController?.duration ?: 0
episodeWasInterrupted = false
}
fun notifyStart(item: BaseItemDto) {
if (item.type == BaseItemKind.EPISODE) isWatchingEpisodes = true
}
fun notifyInteraction(canCancel: Boolean, userInitiated: Boolean) {
// Cancel pending screensaver timer (if any)
timer?.cancel()
// If watching episodes, reset episode count and watch time
if (isWatchingEpisodes && userInitiated) {
resetSession()
episodeWasInterrupted = true
}
// Hide screensaver when interacted with allowed cancellation or when disabled
if (_screensaverVisible.value && (canCancel || !inAppEnabled || activityPaused)) {
_screensaverVisible.value = false
}
// Create new timer to show screensaver when enabled
if (inAppEnabled && !activityPaused && locks == 0) {
timer = viewModelScope.launch {
delay(timeout)
_screensaverVisible.value = true
}
}
// Update KEEP_SCREEN_ON flag value
_keepScreenOn.value = inAppEnabled || locks > 0
}
/**
* Create a lock that prevents the screensaver for running until the returned function is called
* or the lifecycle is destroyed.
*
* @return Function to cancel the lock
*/
fun addLifecycleLock(lifecycle: Lifecycle): () -> Unit {
if (lifecycle.currentState == Lifecycle.State.DESTROYED) return {}
val lock = ScreensaverLock(lifecycle)
lock.activate()
return lock::cancel
}
private inner class ScreensaverLock(private val lifecycle: Lifecycle) : LifecycleEventObserver {
private var active: Boolean = false
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_DESTROY) cancel()
}
fun activate() {
if (active) return
lifecycle.addObserver(this)
locks++
notifyInteraction(canCancel = true, userInitiated = false)
active = true
}
fun cancel() {
if (!active) return
locks--
lifecycle.removeObserver(this)
notifyInteraction(canCancel = false, userInitiated = false)
active = false
}
}
private fun resetSession() {
watchTime = 0L
episodeCount = 0
}
}

View File

@@ -2,24 +2,27 @@ package org.jellyfin.androidtv.ui
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.data.repository.ItemRepository
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.ItemFields
import org.koin.java.KoinJavaComponent
fun ItemListView.refresh() {
val api by KoinJavaComponent.inject<ApiClient>(ApiClient::class.java)
findViewTreeLifecycleOwner()?.lifecycleScope?.launch {
val response = withContext(Dispatchers.IO) {
api.itemsApi.getItems(
ids = mItemIds,
fields = ItemRepository.itemFields
).content
}
val response by api.itemsApi.getItems(
ids = mItemIds,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.ITEM_COUNTS,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
ItemFields.MEDIA_SOURCES,
)
)
response.items?.forEachIndexed { index, item ->
val view = mList.getChildAt(index)

View File

@@ -24,7 +24,7 @@ import org.jellyfin.androidtv.data.model.DataRefreshService;
import org.jellyfin.androidtv.ui.livetv.LiveTvGuide;
import org.jellyfin.androidtv.ui.livetv.TvManager;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.EmptyResponse;
import org.jellyfin.androidtv.util.apiclient.EmptyLifecycleAwareResponse;
import org.jellyfin.sdk.model.api.BaseItemDto;
import org.koin.java.KoinJavaComponent;
@@ -48,13 +48,13 @@ public class LiveProgramDetailPopup {
private Button mFirstButton;
private Button mSeriesSettingsButton;
private EmptyResponse mTuneAction;
private EmptyLifecycleAwareResponse mTuneAction;
private View mAnchor;
private int mPosLeft;
private int mPosTop;
public LiveProgramDetailPopup(Context context, LifecycleOwner lifecycleOwner, LiveTvGuide tvGuide, int width, EmptyResponse tuneAction) {
public LiveProgramDetailPopup(Context context, LifecycleOwner lifecycleOwner, LiveTvGuide tvGuide, int width, EmptyLifecycleAwareResponse tuneAction) {
mContext = context;
lifecycle = lifecycleOwner.getLifecycle();
mTvGuide = tvGuide;
@@ -247,7 +247,7 @@ public class LiveProgramDetailPopup {
tune.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (mTuneAction != null) mTuneAction.onResponse();
if (mTuneAction != null && mTuneAction.getActive()) mTuneAction.onResponse();
mPopup.dismiss();
}
});

View File

@@ -1,9 +1,7 @@
package org.jellyfin.androidtv.ui
import androidx.lifecycle.coroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.data.repository.ItemMutationRepository
import org.jellyfin.androidtv.util.getActivity
import org.jellyfin.sdk.api.client.ApiClient
@@ -34,9 +32,7 @@ fun LiveProgramDetailPopup.cancelTimer(
lifecycle.coroutineScope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.liveTvApi.cancelTimer(timerId)
}
api.liveTvApi.cancelTimer(timerId)
}.onSuccess {
callback()
}
@@ -51,9 +47,7 @@ fun LiveProgramDetailPopup.cancelSeriesTimer(
lifecycle.coroutineScope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.liveTvApi.cancelSeriesTimer(seriesTimerId)
}
api.liveTvApi.cancelSeriesTimer(seriesTimerId)
}.onSuccess {
callback()
}
@@ -94,12 +88,10 @@ fun LiveProgramDetailPopup.recordProgram(
lifecycle.coroutineScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val seriesTimer by api.liveTvApi.getDefaultTimer(programId.toString())
val timer = seriesTimer.asTimerInfoDto()
api.liveTvApi.createTimer(timer)
api.liveTvApi.getProgram(programId.toString()).content
}
val seriesTimer by api.liveTvApi.getDefaultTimer(programId.toString())
val timer = seriesTimer.asTimerInfoDto()
api.liveTvApi.createTimer(timer)
api.liveTvApi.getProgram(programId.toString()).content
}.onSuccess { program ->
callback(program)
}
@@ -114,11 +106,9 @@ fun LiveProgramDetailPopup.recordSeries(
lifecycle.coroutineScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val timer by api.liveTvApi.getDefaultTimer(programId.toString())
api.liveTvApi.createSeriesTimer(timer)
api.liveTvApi.getProgram(programId.toString()).content
}
val timer by api.liveTvApi.getDefaultTimer(programId.toString())
api.liveTvApi.createSeriesTimer(timer)
api.liveTvApi.getProgram(programId.toString()).content
}.onSuccess { program ->
callback(program)
}
@@ -133,9 +123,7 @@ fun LiveProgramDetailPopup.getSeriesTimer(
lifecycle.coroutineScope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.liveTvApi.getSeriesTimer(seriesTimerId).content
}
api.liveTvApi.getSeriesTimer(seriesTimerId).content
}.onSuccess { seriesTimer ->
callback(seriesTimer)
}

View File

@@ -2,152 +2,103 @@ package org.jellyfin.androidtv.ui
import android.content.Context
import android.util.AttributeSet
import android.widget.ImageView
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.graphics.drawscope.Stroke
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import android.view.LayoutInflater
import android.widget.FrameLayout
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.core.view.setPadding
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.base.ProvideTextStyle
import org.jellyfin.androidtv.ui.base.Text
import org.jellyfin.androidtv.ui.base.button.ButtonBase
import org.jellyfin.androidtv.ui.composable.AsyncImage
import org.jellyfin.androidtv.ui.composable.rememberPlayerProgress
import org.jellyfin.androidtv.ui.composable.rememberQueueEntry
import org.jellyfin.androidtv.databinding.ViewNowPlayingBinding
import org.jellyfin.androidtv.ui.navigation.Destinations
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
import org.jellyfin.androidtv.util.apiclient.albumPrimaryImage
import org.jellyfin.androidtv.util.apiclient.getUrl
import org.jellyfin.androidtv.util.apiclient.itemImages
import org.jellyfin.androidtv.util.apiclient.parentImages
import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.jellyfin.queue.baseItem
import org.jellyfin.playback.jellyfin.queue.baseItemFlow
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.ImageType
import org.koin.compose.koinInject
@Composable
fun NowPlayingComposable(
modifier: Modifier = Modifier,
onFocusableChange: (focusable: Boolean) -> Unit,
) {
val api = koinInject<ApiClient>()
val playbackManager = koinInject<PlaybackManager>()
val navigationRepository = koinInject<NavigationRepository>()
val entry by rememberQueueEntry(playbackManager)
val item = entry?.run { baseItemFlow.collectAsState(baseItem) }?.value
val progress = rememberPlayerProgress(playbackManager)
LaunchedEffect(item == null) { onFocusableChange(item != null) }
AnimatedContent(
modifier = modifier,
targetState = item,
transitionSpec = { fadeIn() togetherWith fadeOut() },
) { item ->
if (item != null) {
ButtonBase(
onClick = { navigationRepository.navigate(Destinations.nowPlaying) },
modifier = Modifier
.widthIn(100.dp, 250.dp)
) {
ProvideTextStyle(
value = TextStyle.Default.copy(
fontSize = 12.sp,
)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(3.dp)
) {
val image = item.itemImages[ImageType.PRIMARY] ?: item.albumPrimaryImage ?: item.parentImages[ImageType.PRIMARY]
AsyncImage(
url = image?.getUrl(api),
blurHash = image?.blurHash,
placeholder = ContextCompat.getDrawable(LocalContext.current, R.drawable.ic_album),
aspectRatio = image?.aspectRatio ?: 1f,
modifier = Modifier
.size(35.dp)
.clip(CircleShape)
.drawWithContent {
drawContent()
// Background
drawCircle(
style = Stroke(width = 3.dp.toPx()),
color = Color.Black,
alpha = 0.4f,
)
// Foreground
drawArc(
style = Stroke(width = 3.dp.toPx()),
color = Color.White,
useCenter = false,
startAngle = -90f,
sweepAngle = 360f * progress,
)
},
scaleType = ImageView.ScaleType.CENTER_CROP,
)
Column(
verticalArrangement = Arrangement.SpaceAround,
modifier = Modifier
.padding(start = 2.dp, end = 8.dp)
) {
// Name
Text(text = item.name.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Bold)
val artists = item.artists ?: item.albumArtists ?: item.albumArtist?.let(::listOf)
Text(text = artists?.joinToString(", ").orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
}
}
import org.jellyfin.androidtv.ui.playback.AudioEventListener
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.ui.playback.PlaybackController
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.androidtv.util.TimeUtils
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class NowPlayingView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : AbstractComposeView(context, attrs, defStyle) {
@Composable
override fun Content() = NowPlayingComposable(
// Workaround for older Android versions unable to find focus in our toolbar view when the NowPlayingView is added but inactive
onFocusableChange = { focusable ->
isFocusable = focusable
descendantFocusability = if (focusable) FOCUS_AFTER_DESCENDANTS else FOCUS_BLOCK_DESCENDANTS
defStyleAttr: Int = 0,
defStyleRes: Int = R.style.Button_Default,
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes), KoinComponent {
val binding = ViewNowPlayingBinding.inflate(LayoutInflater.from(context), this, true)
private val mediaManager by inject<MediaManager>()
private val navigationRepository by inject<NavigationRepository>()
private val imageHelper by inject<ImageHelper>()
private var currentDuration: String = ""
init {
setPadding(0)
if (!isInEditMode) setOnClickListener {
navigationRepository.navigate(Destinations.nowPlaying)
}
)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (!isInEditMode) {
// hook our events
mediaManager.addAudioEventListener(audioEventListener)
if (mediaManager.hasAudioQueueItems()) {
isVisible = true
setInfo(mediaManager.currentAudioItem!!)
setStatus(mediaManager.currentAudioPosition)
} else isVisible = false
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
if (!isInEditMode) mediaManager.removeAudioEventListener(audioEventListener)
}
private fun setInfo(item: org.jellyfin.sdk.model.api.BaseItemDto) {
val placeholder = ContextCompat.getDrawable(context, R.drawable.ic_album)
val blurHash = item.imageBlurHashes?.get(org.jellyfin.sdk.model.api.ImageType.PRIMARY)?.get(item.imageTags?.get(org.jellyfin.sdk.model.api.ImageType.PRIMARY))
binding.npIcon.load(imageHelper.getPrimaryImageUrl(item), blurHash, placeholder, item.primaryImageAspectRatio ?: 1.0)
currentDuration = TimeUtils.formatMillis(if (item.runTimeTicks != null) item.runTimeTicks!! / 10_000 else 0)
binding.npDesc.text = if (item.albumArtist != null) item.albumArtist else item.name
}
private fun setStatus(pos: Long) {
binding.npStatus.text = resources.getString(R.string.lbl_status, TimeUtils.formatMillis(pos), currentDuration)
}
fun showDescription(show: Boolean) {
binding.npDesc.isVisible = show
}
private var audioEventListener: AudioEventListener = object : AudioEventListener {
override fun onPlaybackStateChange(newState: PlaybackController.PlaybackState, currentItem: org.jellyfin.sdk.model.api.BaseItemDto?) {
when {
currentItem == null -> Unit
newState == PlaybackController.PlaybackState.PLAYING -> setInfo(currentItem)
newState == PlaybackController.PlaybackState.IDLE && isShown -> setStatus(mediaManager.currentAudioPosition)
}
}
override fun onProgress(pos: Long) {
if (isShown) setStatus(pos)
}
override fun onQueueStatusChanged(hasQueue: Boolean) {
isVisible = hasQueue
if (hasQueue) {
// may have just added one so update display
setInfo(mediaManager.currentAudioItem!!)
setStatus(mediaManager.currentAudioPosition)
}
}
}
}

View File

@@ -1,9 +1,7 @@
package org.jellyfin.androidtv.ui
import androidx.lifecycle.coroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.util.getActivity
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
@@ -43,11 +41,9 @@ fun RecordPopup.updateSeriesTimer(
lifecycle.coroutineScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val id = seriesTimer.id
if (id == null) api.liveTvApi.createSeriesTimer(seriesTimer)
else api.liveTvApi.updateSeriesTimer(id, seriesTimer)
}
val id = seriesTimer.id
if (id == null) api.liveTvApi.createSeriesTimer(seriesTimer)
else api.liveTvApi.updateSeriesTimer(id, seriesTimer)
}.onSuccess {
callback()
}
@@ -62,11 +58,9 @@ fun RecordPopup.updateTimer(
lifecycle.coroutineScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val id = timer.id
if (id == null) api.liveTvApi.createTimer(timer)
else api.liveTvApi.updateTimer(id, timer)
}
val id = timer.id
if (id == null) api.liveTvApi.createTimer(timer)
else api.liveTvApi.updateTimer(id, timer)
}.onSuccess {
callback()
}
@@ -92,9 +86,7 @@ fun RecordPopup.getLiveTvProgram(
lifecycle.coroutineScope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.liveTvApi.getProgram(id.toString()).content
}
api.liveTvApi.getProgram(id.toString()).content
}.onSuccess { program ->
callback(program)
}

View File

@@ -1,33 +0,0 @@
package org.jellyfin.androidtv.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.LocalLifecycleOwner
import org.koin.compose.koinInject
private typealias Lock = () -> Unit
@Composable
fun ScreensaverLock(
enabled: Boolean
) {
val viewModel = koinInject<InteractionTrackerViewModel>()
val lifecycleOwner = LocalLifecycleOwner.current
var lock by remember { mutableStateOf<Lock?>(null) }
DisposableEffect(enabled, lifecycleOwner) {
if (enabled && lock == null) {
lock = viewModel.addLifecycleLock(lifecycleOwner.lifecycle)
}
onDispose {
lock?.invoke()
lock = null
}
}
}

View File

@@ -0,0 +1,103 @@
package org.jellyfin.androidtv.ui
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.preference.UserPreferences
import kotlin.time.Duration.Companion.milliseconds
class ScreensaverViewModel(
private val userPreferences: UserPreferences,
) : ViewModel() {
private var timer: Job? = null
private var locks = 0
// Preferences
val inAppEnabled get() = userPreferences[UserPreferences.screensaverInAppEnabled]
private val timeout get() = userPreferences[UserPreferences.screensaverInAppTimeout].milliseconds
// State
private val _visible = MutableStateFlow(false)
val visible get() = _visible.asStateFlow()
private val _keepScreenOn = MutableStateFlow(inAppEnabled)
val keepScreenOn get() = _keepScreenOn.asStateFlow()
var activityPaused: Boolean = false
set(value) {
field = value
notifyInteraction(true)
}
init {
notifyInteraction(true)
}
fun notifyInteraction(canCancel: Boolean) {
// Cancel pending timer (if any)
timer?.cancel()
// Hide when interacted with allowed cancelation or when disabled
if (_visible.value && (canCancel || !inAppEnabled || activityPaused)) {
_visible.value = false
}
// Create new timer to show screensaver when enabled
if (inAppEnabled && !activityPaused && locks == 0) {
timer = viewModelScope.launch {
delay(timeout)
_visible.value = true
}
}
// Update KEEP_SCREEN_ON flag value
_keepScreenOn.value = inAppEnabled || locks > 0
}
/**
* Create a lock that prevents the screensaver for running until the returned function is called
* or the lifecycle is destroyed.
*
* @return Function to cancel the lock
*/
fun addLifecycleLock(lifecycle: Lifecycle): () -> Unit {
if (lifecycle.currentState == Lifecycle.State.DESTROYED) return {}
val lock = ScreensaverLock(lifecycle)
lock.activate()
return lock::cancel
}
private inner class ScreensaverLock(private val lifecycle: Lifecycle) : LifecycleEventObserver {
private var active: Boolean = false
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_DESTROY) cancel()
}
fun activate() {
if (active) return
lifecycle.addObserver(this)
locks++
notifyInteraction(true)
active = true
}
fun cancel() {
if (!active) return
locks--
lifecycle.removeObserver(this)
notifyInteraction(false)
active = false
}
}
}

View File

@@ -1,118 +1,86 @@
package org.jellyfin.androidtv.ui
import android.content.Context
import android.graphics.Rect
import android.os.Build
import android.util.AttributeSet
import android.view.KeyEvent
import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.MutableInteractionSource
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.base.CircularProgressIndicator
import org.jellyfin.androidtv.ui.base.Icon
import org.jellyfin.androidtv.ui.base.LocalTextStyle
import org.jellyfin.androidtv.ui.base.ProvideTextStyle
import org.jellyfin.androidtv.ui.base.Text
import org.jellyfin.androidtv.ui.base.button.ButtonBase
import org.jellyfin.androidtv.ui.base.button.ButtonDefaults
import org.jellyfin.androidtv.databinding.ViewButtonServerBinding
import org.jellyfin.androidtv.util.MenuBuilder
import org.jellyfin.androidtv.util.popupMenu
import org.jellyfin.androidtv.util.showIfNotEmpty
@Composable
fun ServerButton(
icon: @Composable () -> Unit,
name: @Composable () -> Unit,
address: @Composable () -> Unit,
version: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = ButtonDefaults.Shape,
) {
ButtonBase(
onClick = onClick,
modifier = modifier,
interactionSource = interactionSource,
shape = shape,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
icon()
Column(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween,
) {
ProvideTextStyle(LocalTextStyle.current.copy(fontSize = 14.sp)) {
name()
}
ProvideTextStyle(LocalTextStyle.current.copy(fontSize = 12.sp)) {
address()
}
}
Box(modifier = Modifier.align(Alignment.Bottom)) {
ProvideTextStyle(LocalTextStyle.current.copy(fontSize = 12.sp)) {
version()
}
}
}
}
}
class ServerButtonView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : AbstractComposeView(context, attrs, defStyleAttr) {
var name by mutableStateOf("")
var address by mutableStateOf("")
var version by mutableStateOf<String?>(null)
var state by mutableStateOf(State.DEFAULT)
private var focused by mutableStateOf(false)
defStyleRes: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
val binding = ViewButtonServerBinding.inflate(LayoutInflater.from(context), this, true)
init {
isFocusable = true
descendantFocusability = FOCUS_BLOCK_DESCENDANTS
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) defaultFocusHighlightEnabled = false
isClickable = true
descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
foreground = ResourcesCompat.getDrawable(resources, R.drawable.ripple, context.theme)
}
var name: String
get() = binding.serverName.text.toString()
set(value) {
binding.serverName.text = value
}
var address: String
get() = binding.serverAddress.text.toString()
set(value) {
binding.serverAddress.text = value
}
var version: String?
get() = binding.serverVersion.text?.toString()
set(value) {
binding.serverVersion.text = value
}
private var _state: State = State.DEFAULT
var state: State
get() = _state
set(value) {
_state = value
when (_state) {
State.DEFAULT -> {
binding.iconDefault.isVisible = true
binding.iconDefault.setImageResource(R.drawable.ic_house)
binding.iconProgress.isVisible = false
}
State.EDIT -> {
binding.iconDefault.isVisible = true
binding.iconDefault.setImageResource(R.drawable.ic_house_edit)
binding.iconProgress.isVisible = false
}
State.CONNECTING -> {
binding.iconDefault.isInvisible = true
binding.iconProgress.isVisible = true
}
State.ERROR -> {
binding.iconDefault.isVisible = true
binding.iconDefault.setImageResource(R.drawable.ic_error)
binding.iconProgress.isVisible = false
}
}
}
fun setPopupMenu(init: MenuBuilder.() -> Unit) {
setOnLongClickListener {
popupMenu(context, this, init = init).showIfNotEmpty()
popupMenu(context, binding.root, init = init).showIfNotEmpty()
}
}
@@ -125,59 +93,6 @@ class ServerButtonView @JvmOverloads constructor(
return false
}
override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
focused = gainFocus
}
@Composable
override fun Content() {
val interactionSource = remember { MutableInteractionSource() }
// Forward focus events to the interaction source
val focusInteraction = remember { FocusInteraction.Focus() }
LaunchedEffect(focused) {
if (focused) interactionSource.emit(focusInteraction)
else interactionSource.emit(FocusInteraction.Unfocus(focusInteraction))
}
ServerButton(
icon = {
when (state) {
State.DEFAULT -> Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_house),
contentDescription = null,
)
State.EDIT -> Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_house_edit),
contentDescription = null,
)
State.CONNECTING -> CircularProgressIndicator(
modifier = Modifier.size(24.dp)
)
State.ERROR -> Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_error),
contentDescription = null,
)
}
},
name = { Text(name) },
address = { Text(address) },
version = { Text(version.orEmpty()) },
interactionSource = interactionSource,
// Use the old shape as the Android View implementation is only used in legacy UI
shape = RoundedCornerShape(3.dp),
// New implementation doesn't end up at 51.dp by default so force the old size
modifier = Modifier.height(51.dp),
// We use our own click handler for the view
onClick = {}
)
}
enum class State {
DEFAULT,
EDIT,

View File

@@ -1,48 +0,0 @@
package org.jellyfin.androidtv.ui.base
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
@Composable
fun CircularProgressIndicator(
color: Color = LocalTextStyle.current.color,
modifier: Modifier = Modifier,
) {
val infiniteTransition = rememberInfiniteTransition()
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1_500, // 1.5 seconds
easing = LinearEasing,
)
)
)
Canvas(
modifier = modifier
.aspectRatio(1f)
.rotate(rotation)
) {
drawArc(
color = color,
startAngle = 0f,
sweepAngle = 270f,
useCenter = false,
style = Stroke(width = 4f, cap = StrokeCap.Round)
)
}
}

View File

@@ -1,82 +0,0 @@
package org.jellyfin.androidtv.ui.base
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.paint
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toolingGraphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
@Composable
fun Icon(
imageVector: ImageVector,
contentDescription: String?,
modifier: Modifier = Modifier,
tint: Color = LocalTextStyle.current.color,
) = Icon(
painter = rememberVectorPainter(imageVector),
contentDescription = contentDescription,
modifier = modifier,
tint = tint,
)
@Composable
fun Icon(
bitmap: ImageBitmap,
contentDescription: String?,
modifier: Modifier = Modifier,
tint: Color = LocalTextStyle.current.color,
) = Icon(
painter = remember(bitmap) { BitmapPainter(bitmap) },
contentDescription = contentDescription,
modifier = modifier,
tint = tint,
)
@Composable
fun Icon(
painter: Painter,
contentDescription: String?,
modifier: Modifier = Modifier,
tint: Color = LocalTextStyle.current.color,
) {
val colorFilter = remember(tint) { tint.takeUnless { it == Color.Unspecified }?.let(ColorFilter::tint) }
val semantics = if (contentDescription != null) Modifier.semantics {
this.contentDescription = contentDescription
this.role = Role.Image
} else Modifier
Box(
modifier
.toolingGraphicsLayer()
.defaultSizeFor(painter)
.paint(painter, colorFilter = colorFilter, contentScale = ContentScale.Fit)
.then(semantics)
)
}
private fun Modifier.defaultSizeFor(painter: Painter) = then(
if (painter.intrinsicSize == Size.Unspecified || painter.intrinsicSize.isInfinite()) {
Modifier.size(20.dp)
} else {
Modifier
}
)
private fun Size.isInfinite() = width.isInfinite() && height.isInfinite()

View File

@@ -1,35 +0,0 @@
package org.jellyfin.androidtv.ui.base
import androidx.compose.foundation.LocalOverscrollFactory
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable
@Composable
fun JellyfinTheme(
colorScheme: ColorScheme = JellyfinTheme.colorScheme,
shapes: Shapes = JellyfinTheme.shapes,
typography: Typography = JellyfinTheme.typography,
content: @Composable () -> Unit
) {
CompositionLocalProvider(
LocalColorScheme provides colorScheme,
LocalShapes provides shapes,
LocalTypography provides typography,
// Disable overscroll
LocalOverscrollFactory provides null,
) {
ProvideTextStyle(value = typography.default, content = content)
}
}
object JellyfinTheme {
val colorScheme: ColorScheme
@Composable @ReadOnlyComposable get() = LocalColorScheme.current
val typography: Typography
@Composable @ReadOnlyComposable get() = LocalTypography.current
val shapes: Shapes
@Composable @ReadOnlyComposable get() = LocalShapes.current
}

View File

@@ -1,191 +0,0 @@
package org.jellyfin.androidtv.ui.base
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.times
@Immutable
data class SeekbarColors(
val backgroundColor: Color,
val bufferColor: Color,
val progressColor: Color,
val knobColor: Color,
)
object SeekbarDefaults {
@ReadOnlyComposable
@Composable
fun colors(
backgroundColor: Color = Color(0xCC4B4B4B),
bufferColor: Color = Color(0x40FFFFFF),
progressColor: Color = Color(0xFF00A4DC),
knobColor: Color = Color.White,
) = SeekbarColors(
backgroundColor = backgroundColor,
bufferColor = bufferColor,
progressColor = progressColor,
knobColor = knobColor,
)
}
@Composable
fun Seekbar(
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
progress: Duration = Duration.ZERO,
buffer: Duration = Duration.ZERO,
duration: Duration = Duration.ZERO,
seekForwardAmount: Duration = duration / 100,
seekRewindAmount: Duration = duration / 100,
onScrubbing: ((scrubbing: Boolean) -> Unit)? = null,
onSeek: ((progress: Duration) -> Unit)? = null,
enabled: Boolean = true,
colors: SeekbarColors = SeekbarDefaults.colors(),
) {
val durationMs = duration.inWholeMilliseconds.toFloat()
val progressPercentage = progress.inWholeMilliseconds.toFloat() / durationMs
val bufferPercentage = buffer.inWholeMilliseconds.toFloat() / durationMs
val seekForwardPercentage = seekForwardAmount.inWholeMilliseconds.toFloat() / durationMs
val seekRewindPercentage = seekRewindAmount.inWholeMilliseconds.toFloat() / durationMs
Seekbar(
modifier = modifier,
interactionSource = interactionSource,
progress = progressPercentage,
buffer = bufferPercentage,
seekForwardAmount = seekForwardPercentage,
seekRewindAmount = seekRewindPercentage,
onScrubbing = onScrubbing,
onSeek = if (onSeek == null) null else { progress -> onSeek(progress.toDouble() * duration) },
enabled = enabled,
colors = colors,
)
}
@Composable
fun Seekbar(
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
progress: Float = 0f,
buffer: Float = 0f,
seekForwardAmount: Float = 0.01f,
seekRewindAmount: Float = 0.01f,
onScrubbing: ((scrubbing: Boolean) -> Unit)? = null,
onSeek: ((progress: Float) -> Unit)? = null,
enabled: Boolean = true,
colors: SeekbarColors = SeekbarDefaults.colors(),
) {
val coroutineScope = rememberCoroutineScope()
val focused by interactionSource.collectIsFocusedAsState()
var progressOverride by remember { mutableStateOf<Float?>(null) }
val visibleProgress = progressOverride ?: progress
val knobAlpha by animateFloatAsState(if (focused) 1f else 0f)
var scrubCancelJob by remember { mutableStateOf<Job?>(null) }
Box(
modifier = modifier
.onKeyEvent {
if (!enabled) return@onKeyEvent false
val isForward = it.key == Key.DirectionRight
val isRewind = it.key == Key.DirectionLeft
val isScrubbing = isForward || isRewind
val isKeyUp = it.type == KeyEventType.KeyUp
val isKeyDown = it.type == KeyEventType.KeyDown
val newProgress = when {
isKeyDown && isForward -> (visibleProgress + seekForwardAmount).coerceAtMost(1f)
isKeyDown && isRewind -> (visibleProgress - seekRewindAmount).coerceAtLeast(0f)
else -> visibleProgress
}
if (isScrubbing && isKeyDown && onScrubbing != null) {
scrubCancelJob?.cancel()
onScrubbing(true)
}
if (visibleProgress != newProgress) {
progressOverride = newProgress
if (onSeek != null) onSeek(newProgress)
}
if (isScrubbing && isKeyUp && onScrubbing != null) {
scrubCancelJob?.cancel()
scrubCancelJob = coroutineScope.launch {
delay(300.milliseconds)
onScrubbing(false)
progressOverride = null
}
}
return@onKeyEvent isScrubbing
}
.focusable(interactionSource = interactionSource, enabled = enabled)
.drawWithContent {
val barCornerRadius = CornerRadius(size.minDimension, size.minDimension)
// Background bar
drawRoundRect(
color = colors.backgroundColor,
cornerRadius = barCornerRadius,
)
// Buffer bar
if (buffer > 0f) {
drawRoundRect(
color = colors.bufferColor,
size = size.copy(
width = buffer * size.width,
),
cornerRadius = barCornerRadius,
)
}
// Progress bar
if (visibleProgress > 0f) {
drawRoundRect(
color = colors.progressColor,
size = size.copy(
width = visibleProgress * size.width,
),
cornerRadius = barCornerRadius,
)
}
// Progress knob
drawCircle(
color = colors.knobColor,
alpha = knobAlpha,
center = center.copy(
x = visibleProgress * size.width,
),
radius = size.minDimension * 2,
)
}
)
}

View File

@@ -1,122 +0,0 @@
package org.jellyfin.androidtv.ui.base
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
@Composable
fun Text(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current
) {
val textColor = color.takeOrElse { style.color.takeOrElse { Color.Black } }
BasicText(
text = text,
modifier = modifier.graphicsLayer { this.compositingStrategy = CompositingStrategy.Offscreen },
style = style.merge(
color = textColor,
fontSize = fontSize,
fontWeight = fontWeight,
textAlign = textAlign ?: TextAlign.Unspecified,
lineHeight = lineHeight,
fontFamily = fontFamily,
textDecoration = textDecoration,
fontStyle = fontStyle,
letterSpacing = letterSpacing
),
onTextLayout = onTextLayout,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
minLines = minLines
)
}
@Composable
fun Text(
text: AnnotatedString,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1,
inlineContent: Map<String, InlineTextContent> = mapOf(),
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current
) {
val textColor = color.takeOrElse { style.color.takeOrElse { Color.Black } }
BasicText(
text = text,
modifier = modifier.graphicsLayer { this.compositingStrategy = CompositingStrategy.Offscreen },
style =
style.merge(
color = textColor,
fontSize = fontSize,
fontWeight = fontWeight,
textAlign = textAlign ?: TextAlign.Unspecified,
lineHeight = lineHeight,
fontFamily = fontFamily,
textDecoration = textDecoration,
fontStyle = fontStyle,
letterSpacing = letterSpacing
),
onTextLayout = onTextLayout,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
minLines = minLines,
inlineContent = inlineContent
)
}
val LocalTextStyle = compositionLocalOf(structuralEqualityPolicy()) { TypographyDefaults.Default }
@Composable
fun ProvideTextStyle(value: TextStyle, content: @Composable () -> Unit) {
val mergedStyle = LocalTextStyle.current.merge(value)
CompositionLocalProvider(LocalTextStyle provides mergedStyle, content = content)
}

View File

@@ -1,115 +0,0 @@
package org.jellyfin.androidtv.ui.base.button
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
import org.jellyfin.androidtv.ui.base.JellyfinTheme
object ButtonDefaults {
val Shape: Shape = CircleShape
val ContentPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 10.dp)
@ReadOnlyComposable
@Composable
fun colors(
containerColor: Color = JellyfinTheme.colorScheme.button,
contentColor: Color = JellyfinTheme.colorScheme.onButton,
focusedContainerColor: Color = JellyfinTheme.colorScheme.buttonFocused,
focusedContentColor: Color = JellyfinTheme.colorScheme.onButtonFocused,
disabledContainerColor: Color = JellyfinTheme.colorScheme.buttonDisabled,
disabledContentColor: Color = JellyfinTheme.colorScheme.onButtonDisabled,
) = ButtonColors(
containerColor = containerColor,
contentColor = contentColor,
focusedContainerColor = focusedContainerColor,
focusedContentColor = focusedContentColor,
disabledContainerColor = disabledContainerColor,
disabledContentColor = disabledContentColor,
)
}
@Composable
fun Button(
onClick: () -> Unit,
modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true,
shape: Shape = ButtonDefaults.Shape,
colors: ButtonColors = ButtonDefaults.colors(),
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
interactionSource: MutableInteractionSource? = null,
content: @Composable RowScope.() -> Unit
) {
ButtonBase(
onClick = onClick,
modifier = modifier,
onLongClick = onLongClick,
enabled = enabled,
shape = shape,
colors = colors,
interactionSource = interactionSource,
) {
ButtonRow(
contentPadding = contentPadding,
content = content,
)
}
}
@Composable
fun ProgressButton(
progress: Float,
onClick: () -> Unit,
modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true,
shape: Shape = ButtonDefaults.Shape,
colors: ButtonColors = ButtonDefaults.colors(),
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
interactionSource: MutableInteractionSource? = null,
content: @Composable RowScope.() -> Unit
) {
ProgressButtonBase(
progress = progress,
onClick = onClick,
modifier = modifier,
onLongClick = onLongClick,
enabled = enabled,
shape = shape,
colors = colors,
interactionSource = interactionSource,
) {
ButtonRow(
contentPadding = contentPadding,
content = content,
)
}
}
@Composable
private fun ButtonRow(
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
content: @Composable RowScope.() -> Unit,
) = Row(
modifier = Modifier
.defaultMinSize(
minWidth = 58.dp,
minHeight = 40.dp
)
.padding(contentPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
content = content
)

View File

@@ -1,62 +0,0 @@
package org.jellyfin.androidtv.ui.base.button
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.sp
import org.jellyfin.androidtv.ui.base.JellyfinTheme
import org.jellyfin.androidtv.ui.base.ProvideTextStyle
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ButtonBase(
onClick: () -> Unit,
modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true,
shape: Shape = ButtonDefaults.Shape,
colors: ButtonColors = ButtonDefaults.colors(),
interactionSource: MutableInteractionSource? = null,
content: @Composable BoxScope.() -> Unit
) {
@Suppress("NAME_SHADOWING")
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
val pressed by interactionSource.collectIsPressedAsState()
val colors = when {
!enabled -> colors.disabledContainerColor to colors.disabledContentColor
pressed -> colors.focusedContainerColor to colors.focusedContentColor
focused -> colors.focusedContainerColor to colors.focusedContentColor
else -> colors.containerColor to colors.contentColor
}
ProvideTextStyle(value = JellyfinTheme.typography.default.copy(fontSize = 14.sp, color = colors.second)) {
Box(
modifier = modifier
.combinedClickable(
interactionSource = interactionSource,
indication = null,
enabled = enabled,
role = Role.Button,
onClick = onClick,
onLongClick = onLongClick,
)
.background(colors.first, shape)
.clip(shape),
content = content,
)
}
}

View File

@@ -1,14 +0,0 @@
package org.jellyfin.androidtv.ui.base.button
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
@Immutable
data class ButtonColors(
val containerColor: Color,
val contentColor: Color,
val focusedContainerColor: Color,
val focusedContentColor: Color,
val disabledContainerColor: Color,
val disabledContentColor: Color,
)

View File

@@ -1,66 +0,0 @@
package org.jellyfin.androidtv.ui.base.button
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
import org.jellyfin.androidtv.ui.base.JellyfinTheme
object IconButtonDefaults {
val Shape: Shape = ButtonDefaults.Shape
val ContentPadding: PaddingValues = PaddingValues(horizontal = 10.dp, vertical = 10.dp)
@ReadOnlyComposable
@Composable
fun colors(
containerColor: Color = JellyfinTheme.colorScheme.button,
contentColor: Color = JellyfinTheme.colorScheme.onButton,
focusedContainerColor: Color = JellyfinTheme.colorScheme.buttonFocused,
focusedContentColor: Color = JellyfinTheme.colorScheme.onButtonFocused,
disabledContainerColor: Color = JellyfinTheme.colorScheme.buttonDisabled,
disabledContentColor: Color = JellyfinTheme.colorScheme.onButtonDisabled,
) = ButtonDefaults.colors(
containerColor = containerColor,
contentColor = contentColor,
focusedContainerColor = focusedContainerColor,
focusedContentColor = focusedContentColor,
disabledContainerColor = disabledContainerColor,
disabledContentColor = disabledContentColor,
)
}
@Composable
fun IconButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true,
shape: Shape = IconButtonDefaults.Shape,
colors: ButtonColors = ButtonDefaults.colors(),
contentPadding: PaddingValues = IconButtonDefaults.ContentPadding,
interactionSource: MutableInteractionSource? = null,
content: @Composable BoxScope.() -> Unit
) {
ButtonBase(
onClick = onClick,
modifier = modifier,
onLongClick = onLongClick,
enabled = enabled,
shape = shape,
colors = colors,
interactionSource = interactionSource,
) {
Box(
modifier = Modifier
.padding(contentPadding),
content = content,
)
}
}

View File

@@ -1,49 +0,0 @@
package org.jellyfin.androidtv.ui.base.button
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.colorResource
import org.jellyfin.androidtv.R
@Composable
fun ProgressButtonBase(
progress: Float,
onClick: () -> Unit,
modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true,
shape: Shape = ButtonDefaults.Shape,
colors: ButtonColors = ButtonDefaults.colors(),
interactionSource: MutableInteractionSource? = null,
content: @Composable BoxScope.() -> Unit
) {
val progressColor = colorResource(R.color.button_default_progress_background)
ButtonBase(
onClick = onClick,
modifier = modifier,
onLongClick = onLongClick,
enabled = enabled,
shape = shape,
colors = colors,
interactionSource = interactionSource,
) {
Box(Modifier.matchParentSize()) {
Box(
Modifier
.fillMaxHeight()
.fillMaxWidth(progress)
.background(progressColor)
)
}
content()
}
}

View File

@@ -1,52 +0,0 @@
package org.jellyfin.androidtv.ui.base
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
fun colorScheme(): ColorScheme = ColorScheme(
background = Color(0xFF101010),
onBackground = Color(0xFFFFFFFF),
button = Color(0xB3747474),
onButton = Color(0xFFDDDDDD),
buttonFocused = Color(0xE6CCCCCC),
onButtonFocused = Color(0xFF444444),
buttonDisabled = Color(0x33747474),
onButtonDisabled = Color(0xFF686868),
buttonActive = Color(0x4DCCCCCC),
onButtonActive = Color(0xFFDDDDDD),
input = Color(0xB3747474),
onInput = Color(0xE6CCCCCC),
inputFocused = Color(0xE6CCCCCC),
onInputFocused = Color(0xFFDDDDDD),
recording = Color(0xB3FF7474),
onRecording = Color(0xFFDDDDDD),
popover = Color(0xFF212225),
)
@Immutable
data class ColorScheme(
val background: Color,
val onBackground: Color,
val button: Color,
val onButton: Color,
val buttonFocused: Color,
val onButtonFocused: Color,
val buttonDisabled: Color,
val onButtonDisabled: Color,
val buttonActive: Color,
val onButtonActive: Color,
val input: Color,
val onInput: Color,
val inputFocused: Color,
val onInputFocused: Color,
val recording: Color,
val onRecording: Color,
val popover: Color,
)
val LocalColorScheme = staticCompositionLocalOf { colorScheme() }

View File

@@ -1,18 +0,0 @@
package org.jellyfin.androidtv.ui.base.modifier
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onPlaced
/**
* Automatically focus this composable once visible. Normally this would be used for a **single** composable in a screen/popup.
* Items that can disappear will regain focus when being made visible later.
*/
@Composable
fun Modifier.autoFocus(
focusRequester: FocusRequester = remember { FocusRequester() }
): Modifier = focusRequester(focusRequester)
.onPlaced { focusRequester.requestFocus() }

View File

@@ -1,99 +0,0 @@
package org.jellyfin.androidtv.ui.base.popover
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import org.jellyfin.androidtv.ui.base.JellyfinTheme
object PopoverDefaults {
val Shape: Shape = RoundedCornerShape(4.dp)
}
@Composable
fun Popover(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
alignment: Alignment = Alignment.TopStart,
offset: DpOffset = DpOffset.Zero,
shape: Shape = PopoverDefaults.Shape,
backgroundColor: Color = JellyfinTheme.colorScheme.popover,
content: @Composable BoxScope.() -> Unit,
) {
val density = LocalDensity.current
val focusRequester = remember { FocusRequester() }
val popupPositionProvider = remember(alignment, density, offset) {
PopoverMenuPositionProvider(
alignment = alignment,
offset = IntOffset(
x = with(density) { offset.x.roundToPx() },
y = with(density) { offset.y.roundToPx() },
)
)
}
val transition = updateTransition(expanded)
val alpha by transition.animateFloat(
targetValueByState = { expanded -> if (expanded) 1f else 0f }
)
if (alpha != 0f) {
Popup(
onDismissRequest = onDismissRequest,
properties = PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = true,
),
popupPositionProvider = popupPositionProvider
) {
Box(
modifier = modifier
.graphicsLayer(
shape = shape,
clip = true,
shadowElevation = with(density) { 4.dp.toPx() },
ambientShadowColor = Color.Black.copy(alpha = alpha),
spotShadowColor = Color.Black.copy(alpha = alpha),
)
.graphicsLayer(
shape = shape,
clip = true,
alpha = alpha,
)
.background(backgroundColor, shape)
.wrapContentSize()
.focusRequester(focusRequester)
.focusGroup()
) {
content()
}
}
LaunchedEffect(focusRequester) {
focusRequester.requestFocus()
}
}
}

View File

@@ -1,63 +0,0 @@
package org.jellyfin.androidtv.ui.base.popover
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.window.PopupPositionProvider
class PopoverMenuPositionProvider(
val alignment: Alignment = Alignment.Companion.BottomCenter,
val offset: IntOffset = IntOffset.Companion.Zero,
) : PopupPositionProvider {
private companion object {
private const val EPSILON = 1e-3f
private const val OVERSCAN_X = 48
private const val OVERSCAN_Y = 27
private val PROBE_SPACE = IntSize(10_000, 10_000)
}
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
// Calculate alignment if placed inside the anchor
val insidePosition = alignment.align(
size = popupContentSize,
space = anchorBounds.size,
layoutDirection = layoutDirection,
)
// Measure bias
val biasPosition = alignment.align(
size = IntSize.Companion.Zero,
space = PROBE_SPACE,
layoutDirection = layoutDirection,
)
val biasX = (2f * biasPosition.x / PROBE_SPACE.width) - 1f
val biasY = (2f * biasPosition.y / PROBE_SPACE.height) - 1f
// Calculate position
val x =
(anchorBounds.left + insidePosition.x + biasX.dir * popupContentSize.width + offset.x)
val y =
(anchorBounds.top + insidePosition.y + biasY.dir * popupContentSize.height + offset.y)
// Return position clamped to fit in window with overscan
return IntOffset(
x = x.coerceIn(OVERSCAN_X, windowSize.width - popupContentSize.width - OVERSCAN_X),
y = y.coerceIn(OVERSCAN_Y, windowSize.height - popupContentSize.height - OVERSCAN_Y),
)
}
private val Float.dir
get() = when {
this > EPSILON -> 1
this < -EPSILON -> -1
else -> 0
}
}

View File

@@ -1,26 +0,0 @@
package org.jellyfin.androidtv.ui.base
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.dp
object ShapeDefaults {
val ExtraSmall: CornerBasedShape = RoundedCornerShape(4.0.dp)
val Small: CornerBasedShape = RoundedCornerShape(8.0.dp)
val Medium: CornerBasedShape = RoundedCornerShape(12.0.dp)
val Large: CornerBasedShape = RoundedCornerShape(16.0.dp)
val ExtraLarge: CornerBasedShape = RoundedCornerShape(28.0.dp)
}
@Immutable
data class Shapes(
val extraSmall: CornerBasedShape = ShapeDefaults.ExtraSmall,
val small: CornerBasedShape = ShapeDefaults.Small,
val medium: CornerBasedShape = ShapeDefaults.Medium,
val large: CornerBasedShape = ShapeDefaults.Large,
val extraLarge: CornerBasedShape = ShapeDefaults.ExtraLarge,
)
val LocalShapes = staticCompositionLocalOf { Shapes() }

View File

@@ -1,16 +0,0 @@
package org.jellyfin.androidtv.ui.base
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.text.TextStyle
object TypographyDefaults {
val Default: TextStyle = TextStyle.Default
}
@Immutable
data class Typography(
val default: TextStyle = TypographyDefaults.Default,
)
val LocalTypography = staticCompositionLocalOf { Typography() }

View File

@@ -29,6 +29,7 @@ import androidx.leanback.widget.VerticalGridPresenter;
import androidx.lifecycle.Lifecycle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.auth.repository.UserRepository;
import org.jellyfin.androidtv.constant.ChangeTriggerType;
import org.jellyfin.androidtv.constant.CustomMessage;
import org.jellyfin.androidtv.constant.Extras;
@@ -50,7 +51,7 @@ import org.jellyfin.androidtv.ui.itemhandling.BaseRowItem;
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher;
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter;
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapterHelperKt;
import org.jellyfin.androidtv.ui.navigation.ActivityDestinations;
import org.jellyfin.androidtv.ui.navigation.Destinations;
import org.jellyfin.androidtv.ui.navigation.NavigationRepository;
import org.jellyfin.androidtv.ui.presentation.CardPresenter;
import org.jellyfin.androidtv.ui.presentation.HorizontalGridPresenter;
@@ -59,7 +60,7 @@ import org.jellyfin.androidtv.util.ImageHelper;
import org.jellyfin.androidtv.util.InfoLayoutHelper;
import org.jellyfin.androidtv.util.KeyProcessor;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.EmptyResponse;
import org.jellyfin.androidtv.util.apiclient.EmptyLifecycleAwareResponse;
import org.jellyfin.sdk.api.client.ApiClient;
import org.jellyfin.sdk.model.api.BaseItemDto;
import org.jellyfin.sdk.model.api.BaseItemKind;
@@ -116,6 +117,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
private final Lazy<BackgroundService> backgroundService = inject(BackgroundService.class);
private final Lazy<PreferencesRepository> preferencesRepository = inject(PreferencesRepository.class);
private final Lazy<UserViewsRepository> userViewsRepository = inject(UserViewsRepository.class);
private final Lazy<UserRepository> userRepository = inject(UserRepository.class);
private final Lazy<CustomMessageRepository> customMessageRepository = inject(CustomMessageRepository.class);
private final Lazy<NavigationRepository> navigationRepository = inject(NavigationRepository.class);
private final Lazy<ItemLauncher> itemLauncher = inject(ItemLauncher.class);
@@ -167,12 +169,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
sortOptions.put(3, new SortOption(getString(R.string.lbl_rating), ItemSortBy.OFFICIAL_RATING, SortOrder.ASCENDING));
sortOptions.put(4, new SortOption(getString(R.string.lbl_community_rating), ItemSortBy.COMMUNITY_RATING, SortOrder.DESCENDING));
sortOptions.put(5, new SortOption(getString(R.string.lbl_critic_rating), ItemSortBy.CRITIC_RATING, SortOrder.DESCENDING));
if (mFolder.getCollectionType() == CollectionType.TVSHOWS) {
sortOptions.put(6, new SortOption(getString(R.string.lbl_last_played), ItemSortBy.SERIES_DATE_PLAYED, SortOrder.DESCENDING));
} else {
sortOptions.put(6, new SortOption(getString(R.string.lbl_last_played), ItemSortBy.DATE_PLAYED, SortOrder.DESCENDING));
}
sortOptions.put(6, new SortOption(getString(R.string.lbl_last_played), ItemSortBy.DATE_PLAYED, SortOrder.DESCENDING));
if (mFolder.getCollectionType() != null && mFolder.getCollectionType() == CollectionType.MOVIES) {
sortOptions.put(7, new SortOption(getString(R.string.lbl_runtime), ItemSortBy.RUNTIME, SortOrder.ASCENDING));
@@ -188,7 +185,12 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
binding = HorizontalGridBrowseBinding.inflate(inflater, container, false);
// Hide the description because we don't have room for it
binding.npBug.showDescription(false);
return binding.getRoot();
}
@@ -471,11 +473,11 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
if (numRows > 0) {
double paddingPct = cardScaling / numRows;
double spacingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numRows - 1);
double spaceingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numRows - 1);
double wastedSpacePct = paddingPct + spacingPct;
double usableCardSpace = mGridHeight / (1.0 + wastedSpacePct); // decrease size
double cardHeight = usableCardSpace / numRows;
double wastedSpacePct = paddingPct + spaceingPct;
double useableCardSpace = mGridHeight / (1.0 + wastedSpacePct); // decrease size
double cardHeight = useableCardSpace / numRows;
// fix any rounding errors and make pixel perfect
cardHeightInt = (int) Math.round(cardHeight);
@@ -499,14 +501,14 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
mCardsScreenStride = numRows;
} else if (numCols > 0) {
double paddingPct = cardScaling / numCols;
double spacingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numCols - 1);
double spaceingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numCols - 1);
if (mImageType == ImageType.BANNER) {
spacingPct = ((paddingPct / 2.0) * CARD_SPACING_HORIZONTAL_BANNER_PCT) * (numCols - 1);
spaceingPct = ((paddingPct / 2.0) * CARD_SPACING_HORIZONTAL_BANNER_PCT) * (numCols - 1);
}
double wastedSpacePct = paddingPct + spacingPct;
double usableCardSpace = mGridWidth / (1.0 + wastedSpacePct); // decrease size
double cardWidth = usableCardSpace / numCols;
double wastedSpacePct = paddingPct + spaceingPct;
double useableCardSpace = mGridWidth / (1.0 + wastedSpacePct); // decrease size
double cardWidth = useableCardSpace / numCols;
// fix any rounding errors and make pixel perfect
cardHeightInt = (int) Math.round(getCardHeightBy(cardWidth, mImageType, mFolder));
@@ -619,7 +621,6 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
chunkSize = Math.min(mCardsScreenEst + mCardsScreenStride, 150); // cap at 150
Timber.d("buildAdapter adjusting chunkSize to <%s> screenEst <%s>", chunkSize, mCardsScreenEst);
}
chunkSize=100;
switch (mRowDef.getQueryType()) {
case NextUp:
@@ -659,10 +660,11 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
filters.setFavoriteOnly(libraryPreferences.get(LibraryPreferences.Companion.getFilterFavoritesOnly()));
filters.setUnwatchedOnly(libraryPreferences.get(LibraryPreferences.Companion.getFilterUnwatchedOnly()));
mAdapter.setRetrieveFinishedListener(new EmptyResponse(getLifecycle()) {
mAdapter.setRetrieveFinishedListener(new EmptyLifecycleAwareResponse(getLifecycle()) {
@Override
public void onResponse() {
if (!isActive()) return;
if (!getActive()) return;
setStatusText(mFolder.getName());
if (mCurrentItem == null) { // don't mess-up pos via loadMoreItemsIfNeeded
setItem(null);
@@ -815,7 +817,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
@Override
public void onClick(View v) {
boolean allowViewSelection = userViewsRepository.getValue().allowViewSelection(mFolder.getCollectionType());
startActivity(ActivityDestinations.INSTANCE.displayPreferences(getContext(), mFolder.getDisplayPreferencesId(), allowViewSelection));
navigationRepository.getValue().navigate(Destinations.INSTANCE.displayPreferences(mFolder.getDisplayPreferencesId(), allowViewSelection));
}
});
mSettingsButton.setContentDescription(getString(R.string.lbl_settings));

View File

@@ -17,6 +17,7 @@ import org.jellyfin.androidtv.ui.GridButton;
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter;
import org.jellyfin.androidtv.ui.presentation.GridButtonPresenter;
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.sdk.model.api.BaseItemDto;
import org.jellyfin.sdk.model.api.TimerInfoDto;
@@ -24,8 +25,6 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import timber.log.Timber;
public class BrowseRecordingsFragment extends EnhancedBrowseFragment {
@Override
public void onResume() {
@@ -91,7 +90,7 @@ public class BrowseRecordingsFragment extends EnhancedBrowseFragment {
}
return null;
}, exception -> {
Timber.e(exception, "Failed to get Live TV recordings / timers");
Utils.showToast(getContext(), exception.getLocalizedMessage());
return null;
});
}

View File

@@ -4,6 +4,7 @@ import org.jellyfin.androidtv.constant.ChangeTriggerType;
import org.jellyfin.androidtv.constant.QueryType;
import org.jellyfin.androidtv.data.querying.GetSeriesTimersRequest;
import org.jellyfin.androidtv.data.querying.GetSpecialsRequest;
import org.jellyfin.androidtv.data.querying.GetUserViewsRequest;
import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest;
import org.jellyfin.sdk.model.api.request.GetArtistsRequest;
import org.jellyfin.sdk.model.api.request.GetItemsRequest;
@@ -137,6 +138,12 @@ public class BrowseRowDef {
this.queryType = type;
}
public BrowseRowDef(String header, GetUserViewsRequest query) {
headerText = header;
this.staticHeight = true;
this.queryType = QueryType.Views;
}
public BrowseRowDef(String header, GetResumeItemsRequest query, int chunkSize, boolean preferParentThumb, boolean staticHeight, ChangeTriggerType[] changeTriggers) {
headerText = header;
this.resumeQuery = query;

View File

@@ -29,8 +29,6 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import timber.log.Timber;
public class BrowseViewFragment extends EnhancedBrowseFragment {
private boolean isLiveTvLibrary;
@@ -177,7 +175,7 @@ public class BrowseViewFragment extends EnhancedBrowseFragment {
return null;
}, exception -> {
Timber.e(exception, "Failed to get Live TV recordings / timers");
Utils.showToast(getContext(), exception.getLocalizedMessage());
return null;
});

View File

@@ -1,16 +1,13 @@
package org.jellyfin.androidtv.ui.browsing
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.data.repository.ItemRepository
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.LocationType
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.TimerInfoDto
@@ -26,19 +23,19 @@ fun EnhancedBrowseFragment.getLiveTvRecordingsAndTimers(
lifecycleScope.launch {
runCatching {
val recordings = async(Dispatchers.IO) {
api.liveTvApi.getRecordings(
fields = ItemRepository.itemFields,
enableImages = true,
limit = 40,
).content
}
val recordings by api.liveTvApi.getRecordings(
fields = setOf(
ItemFields.OVERVIEW,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
),
enableImages = true,
limit = 40,
)
val timers = async(Dispatchers.IO) {
api.liveTvApi.getTimers().content
}
val timers by api.liveTvApi.getTimers()
recordings.await() to timers.await()
recordings to timers
}.fold(
onSuccess = { (recordings, timers) -> callback(recordings, timers) },
onFailure = { exception -> errorCallback(exception) }
@@ -54,9 +51,7 @@ fun EnhancedBrowseFragment.getLiveTvTimers(
lifecycleScope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.liveTvApi.getTimers().content
}
api.liveTvApi.getTimers().content
}.fold(
onSuccess = { timers -> callback(timers) },
onFailure = { exception -> errorCallback(exception) }

View File

@@ -2,16 +2,14 @@ package org.jellyfin.androidtv.ui.browsing
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.data.repository.ItemRepository
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
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.CollectionType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemFilter
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
@@ -38,7 +36,7 @@ object BrowsingUtils {
type: BaseItemKind,
callback: (item: BaseItemDto?) -> Unit
) {
lifecycle.lifecycleScope.launch(Dispatchers.IO) {
lifecycle.lifecycleScope.launch {
try {
val result by api.itemsApi.getItems(
parentId = library.id,
@@ -48,15 +46,10 @@ object BrowsingUtils {
limit = 1,
)
withContext(Dispatchers.Main) {
callback(result.items.firstOrNull())
}
callback(result.items?.firstOrNull())
} catch (error: ApiClientException) {
Timber.w(error, "Failed to retrieve random item")
withContext(Dispatchers.Main) {
callback(null)
}
callback(null)
}
}
}
@@ -66,13 +59,22 @@ object BrowsingUtils {
limit = 50,
parentId = parentId,
imageTypeLimit = 1,
fields = ItemRepository.itemFields
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.CHILD_COUNT,
ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_STREAMS,
)
)
@JvmStatic
fun createSeriesGetNextUpRequest(parentId: UUID) = GetNextUpRequest(
seriesId = parentId,
fields = ItemRepository.itemFields
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
)
)
@JvmStatic
@@ -82,7 +84,13 @@ object BrowsingUtils {
itemType: BaseItemKind? = null,
groupItems: Boolean? = null
) = GetLatestMediaRequest(
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.CHILD_COUNT,
ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_STREAMS,
),
parentId = parentId,
limit = 50,
imageTypeLimit = 1,
@@ -93,26 +101,42 @@ object BrowsingUtils {
@JvmStatic
fun createSeasonsRequest(seriesId: UUID) = GetSeasonsRequest(
seriesId = seriesId,
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
),
)
@JvmStatic
fun createUpcomingEpisodesRequest(parentId: UUID) = GetUpcomingEpisodesRequest(
parentId = parentId,
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
),
)
@JvmStatic
fun createSimilarItemsRequest(itemId: UUID) = GetSimilarItemsRequest(
itemId = itemId,
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
),
limit = 20,
)
@JvmStatic
fun createLiveTVOnNowRequest() = GetRecommendedProgramsRequest(
isAiring = true,
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.OVERVIEW,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHANNEL_INFO,
ItemFields.CHILD_COUNT,
),
imageTypeLimit = 1,
enableTotalRecordCount = false,
limit = 150,
@@ -122,7 +146,12 @@ object BrowsingUtils {
fun createLiveTVUpcomingRequest() = GetRecommendedProgramsRequest(
isAiring = false,
hasAired = false,
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.OVERVIEW,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHANNEL_INFO,
ItemFields.CHILD_COUNT,
),
imageTypeLimit = 1,
enableTotalRecordCount = false,
limit = 150,
@@ -131,14 +160,22 @@ object BrowsingUtils {
@JvmStatic
@JvmOverloads
fun createLiveTVRecordingsRequest(limit: Int? = null) = GetRecordingsRequest(
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.OVERVIEW,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
),
enableImages = true,
limit = limit,
)
@JvmStatic
fun createLiveTVMovieRecordingsRequest() = GetRecordingsRequest(
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.OVERVIEW,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
),
enableImages = true,
limit = 60,
isMovie = true,
@@ -146,7 +183,11 @@ object BrowsingUtils {
@JvmStatic
fun createLiveTVSeriesRecordingsRequest() = GetRecordingsRequest(
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.OVERVIEW,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
),
enableImages = true,
limit = 60,
isSeries = true,
@@ -154,7 +195,11 @@ object BrowsingUtils {
@JvmStatic
fun createLiveTVSportsRecordingsRequest() = GetRecordingsRequest(
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.OVERVIEW,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
),
enableImages = true,
limit = 60,
isSports = true,
@@ -162,7 +207,11 @@ object BrowsingUtils {
@JvmStatic
fun createLiveTVKidsRecordingsRequest() = GetRecordingsRequest(
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.OVERVIEW,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
),
enableImages = true,
limit = 60,
isKids = true,
@@ -175,19 +224,31 @@ object BrowsingUtils {
@JvmStatic
fun createAlbumArtistsRequest(parentId: UUID) = GetAlbumArtistsRequest(
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.ITEM_COUNTS,
ItemFields.CHILD_COUNT,
),
parentId = parentId,
)
@JvmStatic
fun createArtistsRequest(parentId: UUID) = GetArtistsRequest(
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.ITEM_COUNTS,
ItemFields.CHILD_COUNT,
),
parentId = parentId,
)
@JvmStatic
fun createPersonItemsRequest(personId: UUID, itemType: BaseItemKind) = GetItemsRequest(
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
),
personIds = setOf(personId),
recursive = true,
includeItemTypes = setOf(itemType),
@@ -196,7 +257,11 @@ object BrowsingUtils {
@JvmStatic
fun createArtistItemsRequest(artistId: UUID, itemType: BaseItemKind) = GetItemsRequest(
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
),
artistIds = setOf(artistId),
recursive = true,
includeItemTypes = setOf(itemType),
@@ -205,7 +270,13 @@ object BrowsingUtils {
@JvmStatic
fun createNextEpisodesRequest(seasonId: UUID, indexNumber: Int) = GetItemsRequest(
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.ITEM_COUNTS,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
),
parentId = seasonId,
includeItemTypes = setOf(BaseItemKind.EPISODE),
startIndex = indexNumber,
@@ -214,7 +285,15 @@ object BrowsingUtils {
@JvmStatic
fun createResumeItemsRequest(parentId: UUID, itemType: BaseItemKind) = GetItemsRequest(
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.ITEM_COUNTS,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
ItemFields.MEDIA_STREAMS,
ItemFields.MEDIA_SOURCES,
),
includeItemTypes = setOf(itemType),
recursive = true,
parentId = parentId,
@@ -229,7 +308,15 @@ object BrowsingUtils {
@JvmStatic
fun createFavoriteItemsRequest(parentId: UUID, itemType: BaseItemKind) = GetItemsRequest(
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.ITEM_COUNTS,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
ItemFields.MEDIA_STREAMS,
ItemFields.MEDIA_SOURCES,
),
includeItemTypes = setOf(itemType),
recursive = true,
parentId = parentId,
@@ -240,7 +327,7 @@ object BrowsingUtils {
@JvmStatic
fun createCollectionsRequest(parentId: UUID) = GetItemsRequest(
fields = ItemRepository.itemFields,
fields = setOf(ItemFields.CHILD_COUNT),
includeItemTypes = setOf(BaseItemKind.BOX_SET),
recursive = true,
imageTypeLimit = 1,
@@ -250,10 +337,14 @@ object BrowsingUtils {
@JvmStatic
fun createPremieresRequest(parentId: UUID) = GetItemsRequest(
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.DATE_CREATED,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.CHILD_COUNT,
),
includeItemTypes = setOf(BaseItemKind.EPISODE),
parentId = parentId,
indexNumber = 1,
recursive = true,
isMissing = false,
imageTypeLimit = 1,
@@ -266,7 +357,13 @@ object BrowsingUtils {
@JvmStatic
fun createLastPlayedRequest(parentId: UUID) = GetItemsRequest(
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.ITEM_COUNTS,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
),
includeItemTypes = setOf(BaseItemKind.AUDIO),
recursive = true,
parentId = parentId,
@@ -280,7 +377,11 @@ object BrowsingUtils {
@JvmStatic
fun createPlaylistsRequest() = GetItemsRequest(
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CUMULATIVE_RUN_TIME_TICKS,
ItemFields.CHILD_COUNT,
),
includeItemTypes = setOf(BaseItemKind.PLAYLIST),
imageTypeLimit = 1,
recursive = true,
@@ -291,7 +392,13 @@ object BrowsingUtils {
@JvmStatic
fun createBrowseGridItemsRequest(parent: BaseItemDto): GetItemsRequest {
val baseRequest = GetItemsRequest(
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_STREAMS,
ItemFields.DISPLAY_PREFERENCES_ID,
),
parentId = parent.id,
)
@@ -307,6 +414,12 @@ object BrowsingUtils {
recursive = true,
)
CollectionType.BOXSETS -> baseRequest.copy(
includeItemTypes = setOf(BaseItemKind.BOX_SET),
parentId = null,
recursive = true,
)
CollectionType.MUSIC -> baseRequest.copy(
includeItemTypes = setOf(BaseItemKind.MUSIC_ALBUM),
recursive = true,

View File

@@ -1,11 +1,9 @@
package org.jellyfin.androidtv.ui.browsing
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.data.repository.ItemRepository
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.koin.android.ext.android.inject
@@ -18,21 +16,25 @@ class ByGenreFragment : BrowseFolderFragment() {
if (childCount <= 0) return
// Get all genres for this folder
val genresResponse = withContext(Dispatchers.IO) {
apiClient.genresApi.getGenres(
parentId = folder?.id,
sortBy = setOf(ItemSortBy.SORT_NAME),
).content
}
val genresResponse by apiClient.genresApi.getGenres(
parentId = folder?.id,
sortBy = setOf(ItemSortBy.SORT_NAME),
)
for (genre in genresResponse.items) {
for (genre in genresResponse.items.orEmpty()) {
val itemsRequest = GetItemsRequest(
parentId = folder?.id,
sortBy = setOf(ItemSortBy.SORT_NAME),
includeItemTypes = includeType?.let(BaseItemKind::fromNameOrNull)?.let(::setOf),
genres = setOf(genre.name.orEmpty()),
recursive = true,
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.ITEM_COUNTS,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
),
)
rows.add(BrowseRowDef(genre.name, itemsRequest, 40))
}

View File

@@ -1,8 +1,8 @@
package org.jellyfin.androidtv.ui.browsing
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.data.repository.ItemRepository
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.request.GetItemsRequest
@@ -20,7 +20,13 @@ class ByLetterFragment : BrowseFolderFragment() {
includeItemTypes = includeType?.let(BaseItemKind::fromNameOrNull)?.let(::setOf),
nameLessThan = letters.substring(0, 1),
recursive = true,
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.ITEM_COUNTS,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
),
)
rows.add(BrowseRowDef("#", numbersItemsRequest, 40))
@@ -33,7 +39,13 @@ class ByLetterFragment : BrowseFolderFragment() {
includeItemTypes = includeType?.let(BaseItemKind::fromNameOrNull)?.let(::setOf),
nameStartsWith = letter.toString(),
recursive = true,
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.ITEM_COUNTS,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
),
)
rows.add(BrowseRowDef(letter.toString(), letterItemsRequest, 40))

View File

@@ -1,28 +1,40 @@
package org.jellyfin.androidtv.ui.browsing
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.data.repository.ItemRepository
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.request.GetItemsRequest
class CollectionFragment : EnhancedBrowseFragment() {
companion object {
private val itemFields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.ITEM_COUNTS,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
ItemFields.MEDIA_STREAMS,
ItemFields.MEDIA_SOURCES,
)
}
override fun setupQueries(rowLoader: RowLoader) {
val movies = GetItemsRequest(
fields = ItemRepository.itemFields,
fields = itemFields,
parentId = mFolder.id,
includeItemTypes = setOf(BaseItemKind.MOVIE),
)
mRows.add(BrowseRowDef(getString(R.string.lbl_movies), movies, 100))
val series = GetItemsRequest(
fields = ItemRepository.itemFields,
fields = itemFields,
parentId = mFolder.id,
includeItemTypes = setOf(BaseItemKind.SERIES),
)
mRows.add(BrowseRowDef(getString(R.string.lbl_tv_series), series, 100))
val others = GetItemsRequest(
fields = ItemRepository.itemFields,
fields = itemFields,
parentId = mFolder.id,
excludeItemTypes = setOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
)

View File

@@ -1,195 +0,0 @@
package org.jellyfin.androidtv.ui.browsing
import android.annotation.SuppressLint
import android.content.Context
import android.os.Bundle
import android.os.Parcel
import android.os.Parcelable
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.core.os.BundleCompat
import androidx.core.os.ParcelCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.navigation.NavigationAction
import timber.log.Timber
import java.util.Stack
private class HistoryEntry(
val name: Class<out Fragment>,
val arguments: Bundle = bundleOf(),
var fragment: Fragment? = null,
var savedState: Fragment.SavedState? = null,
) : Parcelable {
override fun describeContents(): Int = 0
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeString(name.name)
dest.writeBundle(arguments)
dest.writeParcelable(savedState, 0)
}
companion object CREATOR : Parcelable.Creator<HistoryEntry> {
@Suppress("UNCHECKED_CAST")
override fun createFromParcel(parcel: Parcel): HistoryEntry = HistoryEntry(
name = Class.forName(parcel.readString()!!) as Class<out Fragment>,
arguments = parcel.readBundle(this::class.java.classLoader)!!,
fragment = null,
savedState = ParcelCompat.readParcelable(parcel, this::class.java.classLoader, Fragment.SavedState::class.java)!!,
)
override fun newArray(size: Int): Array<HistoryEntry?> = arrayOfNulls(size)
}
}
class DestinationFragmentView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : FrameLayout(context, attrs) {
private companion object {
private const val FRAGMENT_TAG_CONTENT = "content"
private const val BUNDLE_SUPER = "super"
private const val BUNDLE_HISTORY = "history"
}
private val fragmentManager by lazy {
FragmentManager.findFragmentManager(this)
}
private val container by lazy {
FragmentContainerView(context).also { view ->
view.id = R.id.container
addView(view)
}
}
private val history = Stack<HistoryEntry>()
fun navigate(action: NavigationAction.NavigateFragment) {
val entry = HistoryEntry(action.destination.fragment.java, action.destination.arguments)
// Create the base transaction so we can mutate everything at once
val transaction = fragmentManager.beginTransaction()
if (action.clear) {
// Clear all current fragments from the history before adding the new entry
history.mapNotNull { it.fragment }.forEach { transaction.remove(it) }
history.clear()
history.push(entry)
} else if (action.replace && action.addToBackStack && history.isNotEmpty()) {
// Remove the top-most entry before replacing it with the next
val currentFragment = history[history.size - 1].fragment
if (currentFragment != null) transaction.remove(currentFragment)
history[history.size - 1] = entry
} else {
// Add to the end of the history
saveCurrentFragmentState()
history.push(entry)
}
activateHistoryEntry(entry, transaction)
}
fun goBack(): Boolean {
// Require at least 2 items (current & previous) to go back
if (history.size < 2) return false
// Create the base transaction so we can mutate everything at once
val transaction = fragmentManager.beginTransaction()
// Remove current entry
val currentEntry = history.pop()
// Make sure to remove the associated fragment
val currentFragment = currentEntry.fragment
if (currentFragment != null) transaction.remove(currentFragment)
// Read & set previous entry
val entry = history.last()
activateHistoryEntry(entry, transaction)
return true
}
private fun saveCurrentFragmentState() {
if (history.isEmpty()) return
// Update the top-most history entry with state from current fragment
val fragment = requireNotNull(fragmentManager.findFragmentByTag(FRAGMENT_TAG_CONTENT))
history[history.size - 1].savedState = fragmentManager.saveFragmentInstanceState(fragment)
}
@SuppressLint("CommitTransaction")
private fun activateHistoryEntry(
entry: HistoryEntry,
transaction: FragmentTransaction,
) {
var fragment = entry.fragment
// Create if there is no existing fragment
if (fragment == null) {
fragment = fragmentManager.fragmentFactory.instantiate(context.classLoader, entry.name.name).apply {
setInitialSavedState(entry.savedState)
}
entry.fragment = fragment
}
// Update arguments
fragment.arguments = entry.arguments
transaction.apply {
// Set options
setReorderingAllowed(true)
setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out)
// Detach current fragment
fragmentManager.findFragmentByTag(FRAGMENT_TAG_CONTENT)?.let(::detach)
// Attach or add next fragment
if (fragment.isDetached) attach(fragment)
else replace(container.id, fragment, FRAGMENT_TAG_CONTENT)
}
if (fragmentManager.isDestroyed) {
Timber.w("FragmentManager is already destroyed")
} else if (fragmentManager.isStateSaved) {
transaction.commitAllowingStateLoss()
} else {
transaction.commit()
}
}
override fun onSaveInstanceState(): Parcelable {
// Always retrieve current state before writing
saveCurrentFragmentState()
// Save state
return bundleOf(
BUNDLE_SUPER to super.onSaveInstanceState(),
BUNDLE_HISTORY to ArrayList(history),
)
}
override fun onRestoreInstanceState(state: Parcelable?) {
// Ignore if not a bundle
if (state !is Bundle) return super.onRestoreInstanceState(state)
// Call parent
@Suppress("DEPRECATION")
val parent = state.getParcelable<Parcelable>(BUNDLE_SUPER)
super.onRestoreInstanceState(parent)
// Restore history
val savedHistory = BundleCompat.getParcelableArrayList(state, BUNDLE_HISTORY, HistoryEntry::class.java)
if (savedHistory != null) {
history.clear()
history.addAll(savedHistory)
if (history.isNotEmpty()) activateHistoryEntry(history.last(), fragmentManager.beginTransaction())
}
}
}

View File

@@ -2,8 +2,8 @@ package org.jellyfin.androidtv.ui.browsing
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.data.querying.GetSpecialsRequest
import org.jellyfin.androidtv.data.repository.ItemRepository
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemFilter
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
@@ -17,6 +17,14 @@ class GenericFolderFragment : EnhancedBrowseFragment() {
BaseItemKind.USER_VIEW,
BaseItemKind.CHANNEL_FOLDER_ITEM,
)
private val itemFields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.ITEM_COUNTS,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
)
}
override fun setupQueries(rowLoader: RowLoader) {
@@ -31,7 +39,7 @@ class GenericFolderFragment : EnhancedBrowseFragment() {
if (showSpecialViewTypes.contains(mFolder.type)) {
if (mFolder.type != BaseItemKind.CHANNEL_FOLDER_ITEM) {
val resume = GetItemsRequest(
fields = ItemRepository.itemFields,
fields = itemFields,
parentId = mFolder.id,
limit = 50,
filters = setOf(ItemFilter.IS_RESUMABLE),
@@ -42,7 +50,7 @@ class GenericFolderFragment : EnhancedBrowseFragment() {
}
val latest = GetItemsRequest(
fields = ItemRepository.itemFields,
fields = itemFields,
parentId = mFolder.id,
limit = 50,
filters = setOf(ItemFilter.IS_UNPLAYED),
@@ -53,7 +61,7 @@ class GenericFolderFragment : EnhancedBrowseFragment() {
}
val byName = GetItemsRequest(
fields = ItemRepository.itemFields,
fields = itemFields,
parentId = mFolder.id,
)
val header = when (mFolder.type) {

View File

@@ -9,20 +9,19 @@ import android.view.WindowManager
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.commit
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.auth.repository.SessionRepository
import org.jellyfin.androidtv.auth.repository.UserRepository
import org.jellyfin.androidtv.databinding.ActivityMainBinding
import org.jellyfin.androidtv.integration.LeanbackChannelWorker
import org.jellyfin.androidtv.ui.InteractionTrackerViewModel
import org.jellyfin.androidtv.ui.ScreensaverViewModel
import org.jellyfin.androidtv.ui.background.AppBackground
import org.jellyfin.androidtv.ui.navigation.NavigationAction
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
@@ -35,11 +34,15 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
import timber.log.Timber
class MainActivity : FragmentActivity() {
companion object {
private const val FRAGMENT_TAG_CONTENT = "content"
}
private val navigationRepository by inject<NavigationRepository>()
private val sessionRepository by inject<SessionRepository>()
private val userRepository by inject<UserRepository>()
private val interactionTrackerViewModel by viewModel<InteractionTrackerViewModel>()
private val workManager by inject<WorkManager>()
private val screensaverViewModel by viewModel<ScreensaverViewModel>()
private var inTransaction = false
private lateinit var binding: ActivityMainBinding
@@ -56,21 +59,27 @@ class MainActivity : FragmentActivity() {
if (!validateAuthentication()) return
interactionTrackerViewModel.keepScreenOn.flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
screensaverViewModel.keepScreenOn.flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
.onEach { keepScreenOn ->
if (keepScreenOn) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}.launchIn(lifecycleScope)
onBackPressedDispatcher.addCallback(this, backPressedCallback)
if (savedInstanceState == null && navigationRepository.canGoBack) navigationRepository.reset(clearHistory = true)
supportFragmentManager.addOnBackStackChangedListener {
if (!inTransaction && supportFragmentManager.backStackEntryCount == 0)
navigationRepository.reset()
}
if (savedInstanceState == null && navigationRepository.canGoBack) navigationRepository.reset()
navigationRepository.currentAction
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
.onEach { action ->
handleNavigationAction(action)
backPressedCallback.isEnabled = navigationRepository.canGoBack
interactionTrackerViewModel.notifyInteraction(canCancel = false, userInitiated = false)
screensaverViewModel.notifyInteraction(false)
}.launchIn(lifecycleScope)
binding = ActivityMainBinding.inflate(layoutInflater)
@@ -86,7 +95,7 @@ class MainActivity : FragmentActivity() {
applyTheme()
interactionTrackerViewModel.activityPaused = false
screensaverViewModel.activityPaused = false
}
private fun validateAuthentication(): Boolean {
@@ -103,29 +112,60 @@ class MainActivity : FragmentActivity() {
override fun onPause() {
super.onPause()
interactionTrackerViewModel.activityPaused = true
screensaverViewModel.activityPaused = true
}
override fun onStop() {
super.onStop()
workManager.enqueue(OneTimeWorkRequestBuilder<LeanbackChannelWorker>().build())
lifecycleScope.launch(Dispatchers.IO) {
lifecycleScope.launch {
Timber.d("MainActivity stopped")
sessionRepository.restoreSession(destroyOnly = true)
}
}
private fun handleNavigationAction(action: NavigationAction) {
interactionTrackerViewModel.notifyInteraction(canCancel = true, userInitiated = false)
private fun handleNavigationAction(action: NavigationAction) = when (action) {
is NavigationAction.NavigateFragment -> {
if (action.clear) {
// Clear the current back stack
val firstBackStackEntry = supportFragmentManager.getBackStackEntryAt(0)
supportFragmentManager.popBackStack(firstBackStackEntry.id, FragmentManager.POP_BACK_STACK_INCLUSIVE)
} else if (action.replace) {
// Prevent back stack changed listener from resetting when popping to
// the initial destination
inTransaction = true
supportFragmentManager.popBackStack()
inTransaction = false
}
when (action) {
is NavigationAction.NavigateFragment -> binding.contentView.navigate(action)
NavigationAction.GoBack -> binding.contentView.goBack()
supportFragmentManager.commit {
val destination = action.destination
val currentFragment = supportFragmentManager.findFragmentByTag(FRAGMENT_TAG_CONTENT)
val isSameFragment = currentFragment != null &&
destination.fragment.isInstance(currentFragment) &&
currentFragment.arguments == destination.arguments
NavigationAction.Nothing -> Unit
if (!isSameFragment) {
setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out)
replace(R.id.content_view, destination.fragment.java, destination.arguments, FRAGMENT_TAG_CONTENT)
}
if (action.addToBackStack) addToBackStack(null)
}
}
is NavigationAction.NavigateActivity -> {
val destination = action.destination
val intent = Intent(this@MainActivity, destination.activity.java)
intent.putExtras(destination.extras)
startActivity(intent)
action.onOpened()
}
NavigationAction.GoBack -> supportFragmentManager.popBackStack()
NavigationAction.Nothing -> Unit
}
// Forward key events to fragments
@@ -137,8 +177,8 @@ class MainActivity : FragmentActivity() {
private fun onKeyEvent(keyCode: Int, event: KeyEvent?): Boolean {
// Ignore the key event that closes the screensaver
if (interactionTrackerViewModel.visible.value) {
interactionTrackerViewModel.notifyInteraction(canCancel = event?.action == KeyEvent.ACTION_UP, userInitiated = true)
if (screensaverViewModel.visible.value) {
screensaverViewModel.notifyInteraction(canCancel = event?.action == KeyEvent.ACTION_UP)
return true
}
@@ -158,14 +198,14 @@ class MainActivity : FragmentActivity() {
override fun onUserInteraction() {
super.onUserInteraction()
interactionTrackerViewModel.notifyInteraction(false, userInitiated = true)
screensaverViewModel.notifyInteraction(false)
}
@Suppress("RestrictedApi") // False positive
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
// Ignore the key event that closes the screensaver
if (!event.isMediaSessionKeyEvent() && interactionTrackerViewModel.visible.value) {
interactionTrackerViewModel.notifyInteraction(canCancel = event.action == KeyEvent.ACTION_UP, userInitiated = true)
if (!event.isMediaSessionKeyEvent() && screensaverViewModel.visible.value) {
screensaverViewModel.notifyInteraction(canCancel = event.action == KeyEvent.ACTION_UP)
return true
}
@@ -176,8 +216,8 @@ class MainActivity : FragmentActivity() {
@Suppress("RestrictedApi") // False positive
override fun dispatchKeyShortcutEvent(event: KeyEvent): Boolean {
// Ignore the key event that closes the screensaver
if (!event.isMediaSessionKeyEvent() && interactionTrackerViewModel.visible.value) {
interactionTrackerViewModel.notifyInteraction(canCancel = event.action == KeyEvent.ACTION_UP, userInitiated = true)
if (!event.isMediaSessionKeyEvent() && screensaverViewModel.visible.value) {
screensaverViewModel.notifyInteraction(canCancel = event.action == KeyEvent.ACTION_UP)
return true
}
@@ -187,8 +227,8 @@ class MainActivity : FragmentActivity() {
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
// Ignore the touch event that closes the screensaver
if (interactionTrackerViewModel.visible.value) {
interactionTrackerViewModel.notifyInteraction(canCancel = true, userInitiated = true)
if (screensaverViewModel.visible.value) {
screensaverViewModel.notifyInteraction(true)
return true
}

View File

@@ -1,15 +1,13 @@
package org.jellyfin.androidtv.ui.browsing
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.constant.QueryType
import org.jellyfin.androidtv.data.repository.ItemRepository
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
@@ -24,21 +22,25 @@ class SuggestedMoviesFragment : EnhancedBrowseFragment() {
override fun setupQueries(rowLoader: RowLoader) {
lifecycleScope.launch {
val response = withContext(Dispatchers.IO) {
api.itemsApi.getItems(
parentId = mFolder.id,
includeItemTypes = setOf(BaseItemKind.MOVIE),
sortOrder = setOf(SortOrder.DESCENDING),
sortBy = setOf(ItemSortBy.DATE_PLAYED),
limit = 8,
recursive = true,
).content
}
val response by api.itemsApi.getItems(
parentId = mFolder.id,
includeItemTypes = setOf(BaseItemKind.MOVIE),
sortOrder = setOf(SortOrder.DESCENDING),
sortBy = setOf(ItemSortBy.DATE_PLAYED),
limit = 8,
recursive = true,
)
for (item in response.items) {
for (item in response.items.orEmpty()) {
val similar = GetSimilarItemsRequest(
itemId = item.id,
fields = ItemRepository.itemFields,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.CHILD_COUNT,
ItemFields.MEDIA_STREAMS,
ItemFields.MEDIA_SOURCES
),
limit = 7,
)
mRows.add(BrowseRowDef(getString(R.string.because_you_watched, item.name), similar, QueryType.SimilarMovies))

View File

@@ -8,18 +8,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Text
import kotlinx.coroutines.flow.MutableStateFlow
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.constant.RatingType
import org.jellyfin.androidtv.ui.base.Text
import org.jellyfin.androidtv.ui.composable.getResolutionName
import org.jellyfin.androidtv.util.TimeUtils
import org.jellyfin.androidtv.util.sdk.getProgramSubText
@@ -119,7 +118,7 @@ fun BaseItemInfoRowRuntime(
runTime: Duration,
) {
InfoRowItem(
icon = ImageVector.vectorResource(id = R.drawable.ic_time),
icon = painterResource(id = R.drawable.ic_time),
contentDescription = null,
) {
Text(TimeUtils.formatMillis(runTime.inWholeMilliseconds))
@@ -140,8 +139,8 @@ fun InfoRowSeasonEpisode(item: BaseItemDto) {
private fun List<MediaStream>.getDefault(type: MediaStreamType, defaultIndex: Int? = null): MediaStream? {
if (defaultIndex != null) {
val byIndex = getOrNull(defaultIndex)
if (byIndex?.type == type) return byIndex
val byIndex = get(defaultIndex)
if (byIndex.type == type) return byIndex
}
return firstOrNull { it.type == type }
@@ -151,20 +150,10 @@ private fun List<MediaStream>.getDefault(type: MediaStreamType, defaultIndex: In
fun InfoRowMediaDetails(mediaSource: MediaSourceInfo) {
val videoStream = mediaSource.mediaStreams?.getDefault(MediaStreamType.VIDEO)
val audioStream = mediaSource.mediaStreams?.getDefault(MediaStreamType.AUDIO, mediaSource.defaultAudioStreamIndex)
val hasSdhSubtitleStream = mediaSource.mediaStreams?.any { it.type == MediaStreamType.SUBTITLE && it.isHearingImpaired } == true
val hasCcSubtitleStream = mediaSource.mediaStreams?.any { it.type == MediaStreamType.SUBTITLE && !it.isHearingImpaired } == true
val subtitleStream = mediaSource.mediaStreams?.getDefault(MediaStreamType.SUBTITLE, mediaSource.defaultSubtitleStreamIndex)
// Subtitles
if (hasSdhSubtitleStream) {
InfoRowItem(
contentDescription = null,
colors = InfoRowColors.Default,
) {
Text(stringResource(R.string.indicator_subtitles_hearing_impaired))
}
}
if (hasCcSubtitleStream) {
if (subtitleStream != null) {
InfoRowItem(
contentDescription = null,
colors = InfoRowColors.Default,

View File

@@ -1,5 +1,6 @@
package org.jellyfin.androidtv.ui.browsing.composable.inforow
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
@@ -12,14 +13,12 @@ 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.vector.ImageVector
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jellyfin.androidtv.ui.base.Icon
import org.jellyfin.androidtv.ui.base.LocalTextStyle
import org.jellyfin.androidtv.ui.base.ProvideTextStyle
import androidx.tv.material3.ProvideTextStyle
/**
* A single item in the [BaseItemInfoRow].
@@ -27,8 +26,7 @@ import org.jellyfin.androidtv.ui.base.ProvideTextStyle
@Composable
fun InfoRowItem(
// Icon options
icon: ImageVector? = null,
iconTint: Color? = null,
icon: Painter? = null,
contentDescription: String?,
// Styling
colors: Pair<Color, Color> = InfoRowColors.Transparent,
@@ -58,11 +56,10 @@ fun InfoRowItem(
modifier = modifier.fillMaxHeight(),
) {
if (icon != null) {
Icon(
imageVector = icon,
Image(
painter = icon,
contentDescription = contentDescription,
modifier = Modifier.size(if (backgroundColor.alpha > 0f) 16.dp else 18.dp),
tint = iconTint ?: LocalTextStyle.current.color,
)
}

View File

@@ -1,12 +1,10 @@
package org.jellyfin.androidtv.ui.browsing.composable.inforow
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.tv.material3.Text
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.base.Text
import java.text.NumberFormat
/**
@@ -17,8 +15,7 @@ import java.text.NumberFormat
@Composable
fun InfoRowCommunityRating(communityRating: Float) {
InfoRowItem(
icon = ImageVector.vectorResource(R.drawable.ic_star),
iconTint = Color(0xFFEECE55),
icon = painterResource(R.drawable.ic_star),
contentDescription = stringResource(R.string.lbl_community_rating),
) {
Text(String.format("%.1f", communityRating * 10f))
@@ -36,10 +33,9 @@ private const val CRITIC_RATING_FRESH = 0.6f
fun InfoRowCriticRating(criticRating: Float) {
InfoRowItem(
icon = when {
criticRating >= CRITIC_RATING_FRESH -> ImageVector.vectorResource(R.drawable.ic_rt_fresh)
else -> ImageVector.vectorResource(R.drawable.ic_rt_rotten)
criticRating >= CRITIC_RATING_FRESH -> painterResource(R.drawable.ic_rt_fresh)
else -> painterResource(R.drawable.ic_rt_rotten)
},
iconTint = Color.Unspecified,
contentDescription = stringResource(R.string.lbl_critic_rating),
) {
Text(NumberFormat.getPercentInstance().format(criticRating))

View File

@@ -42,14 +42,14 @@ public class ChannelCardView extends FrameLayout {
.append(DateTimeExtensionsKt.getTimeFormatter(getContext()).format(program.getEndDate()))
);
if (program.getStartDate().isBefore(LocalDateTime.now()) && program.getEndDate().isAfter(LocalDateTime.now())) {
if (program.getStartDate().isAfter(LocalDateTime.now()) && program.getEndDate().isBefore(LocalDateTime.now())) {
Duration duration = Duration.between(program.getStartDate(), program.getEndDate());
Duration progress = Duration.between(program.getStartDate(), LocalDateTime.now());
binding.progress.setProgress((int) ((progress.getSeconds() / (double) duration.getSeconds()) * 100));
binding.progress.setProgress((int) (progress.getSeconds() / duration.getSeconds() * 100));
} else {
binding.progress.setProgress(0);
}
}
} else {
binding.time.setText("");
binding.progress.setProgress(0);

View File

@@ -0,0 +1,100 @@
package org.jellyfin.androidtv.ui.card
import android.content.Context
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.os.Build
import android.util.AttributeSet
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.view.updateLayoutParams
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.databinding.ViewCardDefaultBinding
import org.jellyfin.androidtv.util.MenuBuilder
import org.jellyfin.androidtv.util.popupMenu
import org.jellyfin.androidtv.util.showIfNotEmpty
import kotlin.math.roundToInt
class DefaultCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
init {
isFocusable = true
descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) defaultFocusHighlightEnabled = false
}
val binding = ViewCardDefaultBinding.inflate(LayoutInflater.from(context), this, true)
var title: String
get() = binding.label.text.toString()
set(value) {
binding.label.text = value
}
fun setSize(size: Size) = when (size) {
Size.SQUARE -> setSize(size.width, size.height)
}
private fun setSize(newWidth: Int, newHeight: Int) {
binding.bannerContainer.updateLayoutParams {
@Suppress("MagicNumber")
height = (newHeight * context.resources.displayMetrics.density + 0.5f).roundToInt()
}
val horizontalPadding = with(binding.container) { paddingStart + paddingEnd }
binding.container.updateLayoutParams {
@Suppress("MagicNumber")
width = (newWidth * context.resources.displayMetrics.density + 0.5f).roundToInt() + horizontalPadding
}
invalidate()
}
fun setImage(
url: String? = null,
blurHash: String? = null,
placeholder: Drawable? = null,
) = binding.banner.load(url, blurHash, placeholder)
fun setPopupMenu(init: MenuBuilder.() -> Unit) {
setOnLongClickListener {
popupMenu(context, binding.root, init = init).showIfNotEmpty()
}
}
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
if (super.onKeyUp(keyCode, event)) return true
// Menu key should show the popup menu
if (event.keyCode == KeyEvent.KEYCODE_MENU) return performLongClick()
return false
}
override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
val scaleRes = if (gainFocus) R.fraction.card_scale_focus else R.fraction.card_scale_default
val scale = resources.getFraction(scaleRes, 1, 1)
post {
animate().apply {
scaleX(scale)
scaleY(scale)
duration = resources.getInteger(R.integer.card_scale_duration).toLong()
withLayer()
}
}
}
@Suppress("MagicNumber")
enum class Size(val width: Int, val height: Int) {
SQUARE(110, 110)
}
}

View File

@@ -158,7 +158,7 @@ public class LegacyImageCardView extends BaseCardView {
}
}
public CharSequence getTitle() {
public CharSequence gettitle() {
if (binding.title == null) {
return null;
}
@@ -212,7 +212,7 @@ public class LegacyImageCardView extends BaseCardView {
}
private void setTextMaxLines() {
if (TextUtils.isEmpty(getTitle())) {
if (TextUtils.isEmpty(gettitle())) {
binding.contentText.setMaxLines(2);
} else {
binding.contentText.setMaxLines(1);

View File

@@ -1,188 +0,0 @@
package org.jellyfin.androidtv.ui.card
import android.content.Context
import android.graphics.Rect
import android.os.Build
import android.util.AttributeSet
import android.view.KeyEvent
import android.widget.ImageView
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
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.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.scale
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.base.Icon
import org.jellyfin.androidtv.ui.base.JellyfinTheme
import org.jellyfin.androidtv.ui.base.LocalTextStyle
import org.jellyfin.androidtv.ui.base.ProvideTextStyle
import org.jellyfin.androidtv.ui.base.Text
import org.jellyfin.androidtv.ui.composable.AsyncImage
import org.jellyfin.androidtv.util.MenuBuilder
import org.jellyfin.androidtv.util.popupMenu
import org.jellyfin.androidtv.util.showIfNotEmpty
@Composable
fun UserCard(
image: @Composable () -> Unit,
name: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = CircleShape,
) {
val focused by interactionSource.collectIsFocusedAsState()
// Mix button background with input foreground because we display text beneath the profile picture on a transparent background similar
// to the input text
val color = when {
focused -> JellyfinTheme.colorScheme.buttonFocused to JellyfinTheme.colorScheme.onInputFocused
else -> JellyfinTheme.colorScheme.button to JellyfinTheme.colorScheme.onInput
}
val scale by animateFloatAsState(if (focused) 1.1f else 1f, label = "UserCardFocusScale")
Column(
modifier = modifier
.scale(scale)
.focusable(interactionSource = interactionSource)
.clickable(interactionSource = interactionSource, onClick = onClick, indication = null)
) {
Box(
modifier = Modifier
.aspectRatio(1f)
.clip(shape)
.border(2.dp, color.first, shape)
) {
image()
}
Spacer(Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.basicMarquee(
iterations = if (focused) Int.MAX_VALUE else 0,
initialDelayMillis = 0,
),
contentAlignment = Alignment.TopCenter,
) {
ProvideTextStyle(
LocalTextStyle.current.copy(
color = color.second,
)
) {
name()
}
}
}
}
class UserCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : AbstractComposeView(context, attrs, defStyleAttr) {
var name by mutableStateOf<String?>(null)
var image by mutableStateOf<String?>(null)
private var focused by mutableStateOf(false)
init {
isFocusable = true
descendantFocusability = FOCUS_BLOCK_DESCENDANTS
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) defaultFocusHighlightEnabled = false
}
fun setPopupMenu(init: MenuBuilder.() -> Unit) {
setOnLongClickListener {
popupMenu(context, this, init = init).showIfNotEmpty()
}
}
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
if (super.onKeyUp(keyCode, event)) return true
// Menu key should show the popup menu
if (event.keyCode == KeyEvent.KEYCODE_MENU) return performLongClick()
return false
}
override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
focused = gainFocus
}
@Composable
override fun Content() {
val interactionSource = remember { MutableInteractionSource() }
// Forward focus events to the interaction source
val focusInteraction = remember { FocusInteraction.Focus() }
LaunchedEffect(focused) {
if (focused) interactionSource.emit(focusInteraction)
else interactionSource.emit(FocusInteraction.Unfocus(focusInteraction))
}
UserCard(
image = {
if (image != null) {
AsyncImage(
modifier = Modifier.fillMaxSize(),
scaleType = ImageView.ScaleType.CENTER_CROP,
url = image,
)
} else {
Box(modifier = Modifier.fillMaxSize()) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_user),
contentDescription = name,
modifier = Modifier
.size(48.dp)
.align(Alignment.Center)
)
}
}
},
name = {
Text(name.orEmpty(), maxLines = 1)
},
modifier = Modifier
.padding(horizontal = 6.dp, vertical = 8.dp)
.width(110.dp),
interactionSource = interactionSource,
// We use our own click handler for views used in presenters
onClick = {}
)
}
}

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