Compare commits

..

30 Commits

Author SHA1 Message Date
Niels van Velzen
3a6fdfece9 Run build workflow on release branches
(cherry picked from commit 81d84e47aa)
2025-12-28 16:44:26 +01:00
Niels van Velzen
23a5572024 Fix AVC high 10 reporting as main 10 in device profile
(cherry picked from commit 9f328d0281)
2025-12-25 11:21:28 +01:00
tobster093
9a3022a590 Fix Live TV direct play stability (#5229)
(cherry picked from commit ac89159455)
2025-12-23 10:53:24 +01:00
Niels van Velzen
f56faa2ad0 Bump upcomingMinimumServerVersion to 10.11
(cherry picked from commit 0e7f52fd42)
2025-12-23 10:53:11 +01:00
Niels van Velzen
5e1883935d Disallow seeking when duration is unknown
(cherry picked from commit 66cafdd3c8)
2025-12-13 10:56:51 +01:00
Niels van Velzen
69d6283cd5 Use AAC over HLS for music transcoding
(cherry picked from commit 25161f11db)
2025-12-13 10:56:45 +01:00
Damontecres
1b492c573d Add VC-1 to device direct play profile
Adds direct play support for VC-1 video codec on devices with a builtin decoder

(cherry picked from commit 6674239bae)
2025-12-13 10:56:40 +01:00
Jens van Almsick
d1909aef27 utilize group id for track selection
group ids always start at 0 so we don't need the ```indexStartsAtOne```
parameter anymore

(cherry picked from commit 66573c399f)
2025-12-01 11:34:02 +01:00
Jens van Almsick
6cea36f909 clear audio stream index on item start
(cherry picked from commit a1e6b17faf)
2025-12-01 11:34:01 +01:00
Niels van Velzen
7745856646 Fix tinting in account switcher button fallback icon
(cherry picked from commit 07e3809227)
2025-12-01 11:33:31 +01:00
clams4shoes
788f585199 Add AV1 detection support for older Android versions (#5186)
* Fix AV1 support on Fire OS

* Use API checks instead of reflection

(cherry picked from commit da71c6f6a0)
2025-12-01 11:33:02 +01:00
Douglas Newman
90f941fc9a Fix Vimu Player Integration (#5187)
* fix activity result status code handling for vimu

* Fixup

---------

Co-authored-by: Niels van Velzen <git@ndat.nl>
(cherry picked from commit 7e95582f89)
2025-12-01 11:32:56 +01:00
Niels van Velzen
b653d89285 Fix search input not set to single line 2025-11-17 17:56:35 +01:00
Niels van Velzen
c50a8356b3 Do not clear audio stream index when starting item
(cherry picked from commit 04da7eb69e)
2025-11-17 17:47:51 +01:00
Niels van Velzen
9be3b5f81f Increase log level for useful debug log messages
(cherry picked from commit fd5ff1cdd9)
2025-11-17 17:47:51 +01:00
Niels van Velzen
a7a37054f2 Use media source instead of item in switchAudioStream
(cherry picked from commit 7bc34dce91)
2025-11-17 17:47:50 +01:00
Niels van Velzen
49182a2a71 Use MediaSource runtime when available
(cherry picked from commit ceaaa51812)
2025-11-17 17:47:50 +01:00
Ivan Noleto
f473b3acb5 Fix play all and shuffle album/artist playback (#5135)
(cherry picked from commit 2e2d9ec9b4)
2025-11-17 17:47:49 +01:00
Ivan Noleto
5caf4ac11b Fix music playback sort order to match web client
(cherry picked from commit d3c84df367)
2025-11-17 17:47:49 +01:00
Niels van Velzen
4ec568c311 Fix getting supportedSampleRates on Fire OS throwing NullPointerException
(cherry picked from commit eb362165b1)
2025-11-17 17:47:49 +01:00
Niels van Velzen
3cdeba071c Fix crash in FullDetailsFragmentHelper.resumePlayback
(cherry picked from commit 0d9abb9f17)
2025-11-09 17:32:36 +01:00
Niels van Velzen
d80df757fc Remove display Dolby Vision checks
(cherry picked from commit a330cdcc26)
2025-11-09 17:32:35 +01:00
Niels van Velzen
58b936b334 Disable visibilityThreshold in rememberPlayerProgress
(cherry picked from commit 56ac9ca8ff)
2025-11-09 17:32:35 +01:00
Niels van Velzen
e22267b089 Get audio duration from player instead of item metadata in AudioNowPlayingFragment
(cherry picked from commit 1dba22557d)
2025-11-09 17:32:34 +01:00
Frederik Boster
bf9a5ea5af Fix low quality card images
To avoid low quality card images the scaling is moved to the server-side by requesting images with appropriate dimensions in all cases.

Fixes #4955

(cherry picked from commit f748a566dd)
2025-11-09 17:32:33 +01:00
Niels van Velzen
8fabf7904d Infer subtitle format from media stream path for external players
(cherry picked from commit 55f3fd7b05)
2025-11-09 17:32:32 +01:00
Niels van Velzen
7bbcfb50cd Avoid divide by zero in Seekbar
(cherry picked from commit ffc6765cfe)
2025-11-09 17:32:32 +01:00
Jens van Almsick
b13eaea746 fix: clear only audio stream index on media item start
Before clearPlaybackSessionOptions() was called which also cleared the
subtitle index, but because baking in subs will restart the playback
this lead to an infinite loop.

(cherry picked from commit a1dd6fcfd9)
2025-11-09 17:32:31 +01:00
Niels van Velzen
21b4103cb8 Revert "fix: audio track selection on subsequent video media items"
This reverts commit 5c4ebcf7da.

(cherry picked from commit 3fe6ed6f7b)
2025-11-01 15:09:48 +01:00
Niels van Velzen
540eb286b3 Downgrade AGP to v8.11.1 2025-11-01 14:33:19 +01:00
372 changed files with 6678 additions and 11888 deletions

2
.gitattributes vendored
View File

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

View File

@@ -10,8 +10,6 @@ body:
label: "This issue respects the following points:"
description: All conditions are **required**.
options:
- label: I have discussed this issue in the [Jellyfin forum](https://forum.jellyfin.org/) and/or [troubleshooting chat](https://jellyfin.org/contact) before opening this issue.
required: true
- label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin-androidtv/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
required: true
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
@@ -48,20 +46,6 @@ body:
- Copy the media info from the web interface
placeholder: Paste media info…
render: shell
- type: textarea
id: mediacapabilitiesreport
attributes:
label: Media capabilities report
description: |
Please share the media capability report generated from within the app. To do some follow these steps:
1. Open the Jellyfin for Android TV app
2. Connect to a server **that you have admin access to**
3. Go to Preferences -> Playback -> Advanced playback preferences
4. On the bottom, press the "Report playback capabilities to server" button
5. Go to the logs in your Jellyfin dashboard (in your browser)
6. Open the newest log file, it should be named similar to `upload_Jellyfin Android TV[...].log`
7. Press the "Download" button and paste the file contents
placeholder: Paste capability report contents…
- type: markdown
attributes:
value: |
@@ -137,7 +121,7 @@ body:
id: other-sources
attributes:
label: Other sources
description: Please link the related forum topics or chat messages that prompted you open this issue.
description: If this topic has been discussed outside of GitHub, please link it.
placeholder: https://forum.jellyfin.org/…
validations:
required: false

View File

@@ -9,8 +9,6 @@ body:
label: "This issue respects the following points:"
description: All conditions are **required**.
options:
- label: I have discussed this issue in the [Jellyfin forum](https://forum.jellyfin.org/) and/or [troubleshooting chat](https://jellyfin.org/contact) before opening this issue.
required: true
- label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin-androidtv/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
required: true
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
@@ -100,7 +98,7 @@ body:
id: other-sources
attributes:
label: Other sources
description: Please link the related forum topics or chat messages that prompted you open this issue.
description: If this topic has been discussed outside of GitHub, please link it.
placeholder: https://forum.jellyfin.org/…
validations:
required: false

View File

@@ -1,15 +1,11 @@
<!--
Ensure your title is short, descriptive, and in the imperative mood (Fix X, Change Y, instead of Fixed X, Changed Y).
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://jellyfin.org/docs/general/contributing/issues/ page.
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://docs.jellyfin.org/general/contributing/issues.html page.
-->
**Changes**
<!-- Describe your changes here in 1-5 sentences. -->
**Code assistance**
<!-- If code assistance was used, describe how it contributed
e.g., code generated by LLM, explanation of code base, debugging guidance. -->
**Issues**
<!-- Tag any issues that this PR solves here.
ex. Fixes # -->

View File

@@ -16,20 +16,20 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: temurin
java-version-file: .tools-versions
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: build-artifacts
retention-days: 14

View File

@@ -17,18 +17,18 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: temurin
java-version-file: .tools-versions
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Run detekt and lint tasks
run: ./gradlew detekt lint
- name: Upload SARIF files
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
uses: github/codeql-action/upload-sarif@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
if: ${{ always() }}
with:
sarif_file: .

View File

@@ -12,14 +12,14 @@ jobs:
if: ${{ contains(github.repository_owner, 'jellyfin') }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: temurin
java-version-file: .tools-versions
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.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@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
with:
host: ${{ secrets.REPO_HOST }}
username: ${{ secrets.REPO_USER }}

View File

@@ -16,13 +16,13 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: temurin
java-version-file: .tools-versions
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Run test task
run: ./gradlew test

View File

@@ -17,6 +17,6 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0

View File

@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-24.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
days-before-stale: 120

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
plugins {
alias(libs.plugins.aboutlibraries)
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
id("com.android.application")
kotlin("android")
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.aboutlibraries)
}
android {
@@ -23,7 +24,6 @@ android {
buildConfig = true
viewBinding = true
compose = true
resValues = true
}
compileOptions {
@@ -32,7 +32,7 @@ android {
buildTypes {
release {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
isMinifyEnabled = false
// Set package names used in various XML files
resValue("string", "app_id", namespace!!)
@@ -87,7 +87,6 @@ tasks.register("versionTxt") {
dependencies {
// Jellyfin
implementation(projects.design)
implementation(projects.playback.core)
implementation(projects.playback.jellyfin)
implementation(projects.playback.media3.exoplayer)
@@ -115,7 +114,6 @@ dependencies {
implementation(libs.androidx.fragment.compose)
implementation(libs.androidx.leanback.core)
implementation(libs.androidx.leanback.preference)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.preference)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.tvprovider)
@@ -138,7 +136,6 @@ dependencies {
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.ui)
implementation(libs.jellyfin.androidx.media3.ffmpeg.decoder)
implementation(libs.libass.media3)
// Markdown
implementation(libs.bundles.markwon)

View File

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

View File

@@ -35,11 +35,6 @@
android:host="youtube.com"
android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
</queries>
<application
@@ -52,10 +47,22 @@
android:icon="@mipmap/app_icon"
android:label="@string/app_name"
android:largeHeap="true"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Jellyfin"
android:usesCleartextTraffic="true">
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<!-- Legacy service required for AccountManagerMigration -->
<service
android:name=".auth.service.AuthenticatorService"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<!-- Screensaver -->
<service
@@ -128,6 +135,10 @@
android:launchMode="singleTask"
android:windowSoftInputMode="adjustNothing" />
<activity
android:name=".ui.preference.PreferencesActivity"
android:theme="@style/Theme.Jellyfin.Preferences" />
<activity android:name=".ui.playback.ExternalPlayerActivity" />
</application>
</manifest>

View File

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

View File

@@ -26,12 +26,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.BrandingOptionsDto
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.

View File

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

View File

@@ -2,6 +2,7 @@ package org.jellyfin.androidtv.auth.store
import android.content.Context
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -12,6 +13,7 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import org.jellyfin.androidtv.auth.AccountManagerMigration
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
import org.jellyfin.androidtv.auth.model.AuthenticationStoreUser
import org.jellyfin.sdk.model.serializer.UUIDSerializer
@@ -26,6 +28,7 @@ import java.util.UUID
*/
class AuthenticationStore(
private val context: Context,
private val accountManagerMigration: AccountManagerMigration,
) {
private val storePath
get() = context.filesDir.resolve("authentication_store.json")
@@ -56,13 +59,11 @@ class AuthenticationStore(
// Check for version
return when (root["version"]?.jsonPrimitive?.intOrNull) {
// Migration was removed, clear stored servers
1 -> {
Timber.e("Migrating from version 1 is no longer possible")
emptyMap()
}
1 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
// Add access tokens from account manager to stored users and save the migrated data
.let { servers -> accountManagerMigration.migrate(servers) }
.also { servers -> write(servers) }
// Current version, return as-is
2 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
null -> {

View File

@@ -2,6 +2,7 @@ package org.jellyfin.androidtv.data.eventhandling
import android.content.Context
import android.media.AudioManager
import android.os.Build
import android.widget.Toast
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
@@ -78,7 +79,7 @@ class SocketHandler(
add(GeneralCommandType.SEND_STRING)
// Note: These are used in the PlaySessionSocketService
if (!audioManager.isVolumeFixed) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !audioManager.isVolumeFixed) {
add(GeneralCommandType.VOLUME_UP)
add(GeneralCommandType.VOLUME_DOWN)
add(GeneralCommandType.SET_VOLUME)
@@ -171,14 +172,13 @@ class SocketHandler(
}
private fun onPlayMessage(message: PlayMessage) {
val itemIds = message.data?.itemIds ?: return
val itemId = message.data?.itemIds?.firstOrNull() ?: return
runCatching {
playbackHelper.retrieveAndPlay(
itemIds,
itemId,
false,
message.data?.startPositionTicks,
message.data?.startIndex,
context
)
}.onFailure { Timber.w(it, "Failed to start playback") }

View File

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

View File

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

View File

@@ -15,7 +15,6 @@ 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.preference.constant.BackdropBehavior
import org.jellyfin.androidtv.util.apiclient.getUrl
import org.jellyfin.androidtv.util.apiclient.itemBackdropImages
import org.jellyfin.androidtv.util.apiclient.parentBackdropImages
@@ -60,14 +59,12 @@ class BackgroundService(
* Use all available backdrops from [baseItem] as background.
*/
fun setBackground(baseItem: BaseItemDto?) {
val backdropBehavior = userPreferences[UserPreferences.backdropBehavior]
// Check if item is set and backgrounds are enabled
if (baseItem == null || backdropBehavior == BackdropBehavior.DISABLED)
if (baseItem == null || !userPreferences[UserPreferences.backdropEnabled])
return clearBackgrounds()
// Enable blur for backdrops
_blurBackground.value = backdropBehavior == BackdropBehavior.BACKDROP_WITH_BLUR
_blurBackground.value = true
// Get all backdrop urls
val backdropUrls = (baseItem.itemBackdropImages + baseItem.parentBackdropImages)
@@ -82,7 +79,7 @@ class BackgroundService(
*/
fun setBackground(server: Server) {
// Check if item is set and backgrounds are enabled
if (userPreferences[UserPreferences.backdropBehavior] == BackdropBehavior.DISABLED)
if (!userPreferences[UserPreferences.backdropEnabled])
return clearBackgrounds()
// Check if splashscreen is enabled in (cached) branding options

View File

@@ -20,7 +20,6 @@ import org.jellyfin.androidtv.data.eventhandling.SocketHandler
import org.jellyfin.androidtv.data.model.DataRefreshService
import org.jellyfin.androidtv.data.repository.CustomMessageRepository
import org.jellyfin.androidtv.data.repository.CustomMessageRepositoryImpl
import org.jellyfin.androidtv.data.repository.ExternalAppRepository
import org.jellyfin.androidtv.data.repository.ItemMutationRepository
import org.jellyfin.androidtv.data.repository.ItemMutationRepositoryImpl
import org.jellyfin.androidtv.data.repository.NotificationsRepository
@@ -44,7 +43,6 @@ import org.jellyfin.androidtv.ui.search.SearchFragmentDelegate
import org.jellyfin.androidtv.ui.search.SearchRepository
import org.jellyfin.androidtv.ui.search.SearchRepositoryImpl
import org.jellyfin.androidtv.ui.search.SearchViewModel
import org.jellyfin.androidtv.ui.settings.compat.SettingsViewModel
import org.jellyfin.androidtv.ui.startup.ServerAddViewModel
import org.jellyfin.androidtv.ui.startup.StartupViewModel
import org.jellyfin.androidtv.ui.startup.UserLoginViewModel
@@ -141,7 +139,6 @@ val appModule = module {
single<NavigationRepository> { NavigationRepositoryImpl(Destinations.home) }
single<SearchRepository> { SearchRepositoryImpl(get()) }
single<MediaSegmentRepository> { MediaSegmentRepositoryImpl(get(), get()) }
single<ExternalAppRepository> { ExternalAppRepository(get()) }
viewModel { StartupViewModel(get(), get(), get(), get()) }
viewModel { UserLoginViewModel(get(), get(), get(), get(defaultDeviceInfo)) }
@@ -151,7 +148,6 @@ val appModule = module {
viewModel { PhotoPlayerViewModel(get()) }
viewModel { SearchViewModel(get()) }
viewModel { DreamViewModel(get(), get(), get(), get(), get()) }
viewModel { SettingsViewModel() }
single { BackgroundService(get(), get(), get(), get(), get()) }

View File

@@ -1,5 +1,6 @@
package org.jellyfin.androidtv.di
import org.jellyfin.androidtv.auth.AccountManagerMigration
import org.jellyfin.androidtv.auth.repository.AuthenticationRepository
import org.jellyfin.androidtv.auth.repository.AuthenticationRepositoryImpl
import org.jellyfin.androidtv.auth.repository.ServerRepository
@@ -13,7 +14,8 @@ import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.koin.dsl.module
val authModule = module {
single { AuthenticationStore(get()) }
single { AccountManagerMigration(get()) }
single { AuthenticationStore(get(), get()) }
single { AuthenticationPreferences(get()) }
single<AuthenticationRepository> {

View File

@@ -26,11 +26,10 @@ import org.jellyfin.playback.media3.session.MediaSessionOptions
import org.jellyfin.playback.media3.session.media3SessionPlugin
import org.jellyfin.sdk.api.client.HttpClientOptions
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.model.api.MediaSegmentType
import org.koin.android.ext.koin.androidContext
import kotlin.time.Duration
import org.koin.core.scope.Scope
import org.koin.dsl.module
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import org.jellyfin.androidtv.ui.playback.PlaybackManager as LegacyPlaybackManager
@@ -72,7 +71,6 @@ fun Scope.createPlaybackManager() = playbackManager(androidContext()) {
val userPreferences = get<UserPreferences>()
val exoPlayerOptions = ExoPlayerOptions(
preferFfmpeg = userPreferences[UserPreferences.preferExoPlayerFfmpeg],
enableLibass = userPreferences[UserPreferences.assDirectPlay],
enableDebugLogging = userPreferences[UserPreferences.debuggingEnabled],
baseDataSourceFactory = get<HttpDataSource.Factory>(),
)
@@ -87,7 +85,7 @@ fun Scope.createPlaybackManager() = playbackManager(androidContext()) {
install(media3SessionPlugin(get(), mediaSessionOptions))
val deviceProfileBuilder = { createDeviceProfile(androidContext(), userPreferences, get()) }
install(jellyfinPlugin(get(), deviceProfileBuilder, setOf(MediaSegmentType.INTRO), ProcessLifecycleOwner.get().lifecycle))
install(jellyfinPlugin(get(), deviceProfileBuilder, ProcessLifecycleOwner.get().lifecycle))
// Options
val userSettingPreferences = get<UserSettingPreferences>()

View File

@@ -42,8 +42,8 @@ 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.lyrics
import org.jellyfin.playback.jellyfin.lyrics.lyricsFlow
import org.jellyfin.playback.jellyfin.lyrics
import org.jellyfin.playback.jellyfin.lyricsFlow
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.ImageType
import org.koin.compose.koinInject

View File

@@ -1,13 +1,14 @@
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.BackdropBehavior
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
@@ -41,9 +42,19 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
var appTheme = enumPreference("app_theme", AppTheme.DARK)
/**
* Behavior of app background while browsing
* Enable background images while browsing
*/
var backdropBehavior = enumPreference("backdrop_behavior", BackdropBehavior.BACKDROP_WITH_BLUR)
var backdropEnabled = booleanPreference("pref_show_backdrop", true)
/**
* Show premieres on home screen
*/
var premieresEnabled = booleanPreference("pref_enable_premieres", false)
/**
* Enable management of media like deleting items when the user has sufficient permissions.
*/
var mediaManagementEnabled = booleanPreference("enable_media_management", false)
/* Playback - General*/
/**
@@ -88,11 +99,6 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
*/
var useExternalPlayer = booleanPreference("external_player", false)
/**
* Component name for the external playback application.
*/
var externalPlayerComponentName = stringPreference("external_player_component", "")
/**
* Change refresh rate to match media when device supports it
*/
@@ -125,6 +131,16 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
*/
var liveTvDirectPlayEnabled = booleanPreference("pref_live_direct", true)
/**
* Shortcut used for changing the audio track
*/
var shortcutAudioTrack = intPreference("shortcut_audio_track", KeyEvent.KEYCODE_MEDIA_AUDIO_TRACK)
/**
* Shortcut used for changing the subtitle track
*/
var shortcutSubtitleTrack = intPreference("shortcut_subtitle_track", KeyEvent.KEYCODE_CAPTIONS)
/* Developer options */
/**
* Show additional debug information
@@ -141,6 +157,11 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
*/
var clockBehavior = enumPreference("pref_clock_behavior", ClockBehavior.ALWAYS)
/**
* Set which ratings provider should show on MyImageCardViews
*/
var defaultRatingType = enumPreference("pref_rating_type", RatingType.RATING_TOMATOES)
/**
* Set when watched indicators should show on MyImageCardViews
*/
@@ -174,7 +195,7 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
/**
* Subtitles font size
*/
var subtitlesTextSize = floatPreference("subtitles_text_size", 24f)
var subtitlesTextSize = floatPreference("subtitles_text_size", 1f)
/**
* Subtitles offset
@@ -228,12 +249,7 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
var trickPlayEnabled = booleanPreference("trick_play_enabled", false)
/**
* Enable libass.
*/
var assDirectPlay = booleanPreference("libass_enabled", false)
/**
* Enable PGS subtitle direct-play.
* Enable PGS subtitle direct-play.
*/
var pgsDirectPlay = booleanPreference("pgs_enabled", true)
}
@@ -259,19 +275,6 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
val subtitleStrokeSize = it.getInt("subtitles_stroke_size", 0)
putLong("subtitles_text_stroke_color", if (subtitleStrokeSize > 0) 0XFF000000L else 0X00FFFFFFL)
}
// v0.19.0 to v0.20.0
migration(toVersion = 9) {
// Reset subtitle text size as we changed from fractional sizing to absolute sizing
remove("subtitles_text_size")
// Set the BackdropBehavior if it was enabled in a previous version
val backdropEnabled = it.getBoolean("pref_show_backdrop", true)
putString(
"backdrop_behavior",
if (backdropEnabled) BackdropBehavior.BACKDROP_WITH_BLUR.name else BackdropBehavior.DISABLED.name
)
}
}
}
}

View File

@@ -1,20 +0,0 @@
package org.jellyfin.androidtv.preference.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
enum class BackdropBehavior(
override val nameRes: Int,
) : PreferenceEnum {
DISABLED(R.string.state_disabled),
/**
* When selected, backdrop is shown with a blur effect.
*/
BACKDROP_WITH_BLUR(R.string.backdrop_behavior_with_blur),
/**
* When selected, backdrop is shown without any blur effect.
*/
BACKDROP_WITHOUT_BLUR(R.string.backdrop_behavior_without_blur),
}

View File

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

View File

@@ -4,12 +4,16 @@ import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.RelativeLayout
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.auth.repository.UserRepository
import org.jellyfin.androidtv.databinding.ClockUserBugBinding
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.constant.ClockBehavior
import org.jellyfin.androidtv.ui.navigation.Destinations
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
import org.jellyfin.androidtv.util.ImageHelper
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -21,7 +25,9 @@ class ClockUserView @JvmOverloads constructor(
) : RelativeLayout(context, attrs, defStyleAttr, defStyleRes), KoinComponent {
private val binding: ClockUserBugBinding = ClockUserBugBinding.inflate(LayoutInflater.from(context), this, true)
private val userPreferences by inject<UserPreferences>()
private val userRepository by inject<UserRepository>()
private val navigationRepository by inject<NavigationRepository>()
private val imageHelper by inject<ImageHelper>()
var isVideoPlayer = false
set(value) {
@@ -34,6 +40,15 @@ class ClockUserView @JvmOverloads constructor(
init {
updateClockVisibility()
val currentUser = userRepository.currentUser.value
binding.clockUserImage.load(
url = currentUser?.let(imageHelper::getPrimaryImageUrl),
placeholder = ContextCompat.getDrawable(context, R.drawable.ic_user)
)
binding.clockUserImage.isVisible = currentUser != null
binding.home.setOnClickListener {
navigationRepository.reset(Destinations.home, clearHistory = true)
}

View File

@@ -23,7 +23,6 @@ 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.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalContext
@@ -34,7 +33,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.base.JellyfinTheme
import org.jellyfin.androidtv.ui.base.ProvideTextStyle
import org.jellyfin.androidtv.ui.base.Text
import org.jellyfin.androidtv.ui.base.button.ButtonBase
@@ -92,7 +90,6 @@ fun NowPlayingComposable(
.padding(3.dp)
) {
val image = item.itemImages[ImageType.PRIMARY] ?: item.albumPrimaryImage ?: item.parentImages[ImageType.PRIMARY]
val progressFillColor = JellyfinTheme.colorScheme.rangeControlFill
AsyncImage(
url = image?.getUrl(api),
@@ -105,18 +102,16 @@ fun NowPlayingComposable(
.drawWithContent {
drawContent()
val width = 3.dp.toPx()
// Background
drawCircle(
style = Stroke(width),
style = Stroke(width = 3.dp.toPx()),
color = Color.Black,
alpha = 0.4f,
)
// Foreground
drawArc(
style = Stroke(width, cap = StrokeCap.Round),
color = progressFillColor,
style = Stroke(width = 3.dp.toPx()),
color = Color.White,
useCenter = false,
startAngle = -90f,
sweepAngle = 360f * progress,

View File

@@ -0,0 +1,31 @@
package org.jellyfin.androidtv.ui
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.FrameLayout
import org.jellyfin.androidtv.databinding.ViewNumberSpinnerBinding
class NumberSpinnerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
val binding = ViewNumberSpinnerBinding.inflate(LayoutInflater.from(context), this, true)
var increment = 10L
var valueChangedListener: ValueChangedListener<Long>? = null
var value = 0L
set(value) {
field = value
binding.value.text = value.toString()
valueChangedListener?.onValueChanged(value)
}
init {
binding.increase.setOnClickListener { value += increment }
binding.decrease.setOnClickListener { value -= increment }
}
}

View File

@@ -0,0 +1,5 @@
package org.jellyfin.androidtv.ui
fun interface ValueChangedListener<T> {
fun onValueChanged(value: T)
}

View File

@@ -1,36 +0,0 @@
package org.jellyfin.androidtv.ui.base
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
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.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.design.Tokens
@Composable
fun Badge(
modifier: Modifier = Modifier,
shape: Shape = CircleShape,
containerColor: Color = JellyfinTheme.colorScheme.badge,
contentColor: Color = JellyfinTheme.colorScheme.onBadge,
content: @Composable BoxScope.() -> Unit,
) {
ProvideTextStyle(JellyfinTheme.typography.badge.copy(color = contentColor)) {
Box(
modifier = modifier
.defaultMinSize(minWidth = 24.dp, minHeight = 24.dp)
.background(containerColor, shape)
.padding(Tokens.Space.spaceXs),
contentAlignment = Alignment.Center,
) {
content()
}
}
}

View File

@@ -1,29 +0,0 @@
package org.jellyfin.androidtv.ui.base
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@Composable
@Stable
fun BaseScreen(
content: @Composable () -> Unit,
) {
// Work around a focus issue with our current fragment based navigation approach
// by always focusing the screen contents whenever the screen is created
val focusRequester = remember { FocusRequester() }
Box(
modifier = Modifier
.focusRequester(focusRequester)
.focusGroup()
) {
content()
}
LaunchedEffect(focusRequester) { focusRequester.requestFocus() }
}

View File

@@ -17,8 +17,8 @@ import androidx.compose.ui.graphics.drawscope.Stroke
@Composable
fun CircularProgressIndicator(
modifier: Modifier = Modifier,
color: Color = LocalTextStyle.current.color,
modifier: Modifier = Modifier,
) {
val infiniteTransition = rememberInfiniteTransition()
val rotation by infiniteTransition.animateFloat(

View File

@@ -16,11 +16,10 @@ fun JellyfinTheme(
LocalColorScheme provides colorScheme,
LocalShapes provides shapes,
LocalTypography provides typography,
LocalTextStyle provides LocalTextStyle.current.merge(typography.default),
// Disable overscroll
LocalOverscrollFactory provides null,
) {
content()
ProvideTextStyle(value = typography.default, content = content)
}
}

View File

@@ -1,54 +0,0 @@
package org.jellyfin.androidtv.ui.base
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.vectorResource
import coil3.compose.AsyncImagePainter
import coil3.compose.rememberAsyncImagePainter
import org.jellyfin.androidtv.R
@Composable
fun ProfilePicture(
url: String?,
modifier: Modifier = Modifier,
contentDescription: String? = null,
iconPadding: PaddingValues = PaddingValues.Zero,
) {
Box(
modifier = modifier
) {
val userImagePainter = rememberAsyncImagePainter(url)
val userImageState by userImagePainter.state.collectAsState()
val userImageVisible = userImageState is AsyncImagePainter.State.Success
if (!userImageVisible) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_user),
contentDescription = contentDescription,
modifier = Modifier
.align(Alignment.Center)
.padding(iconPadding)
.fillMaxSize()
)
} else {
Image(
painter = userImagePainter,
contentDescription = contentDescription,
contentScale = ContentScale.Crop,
modifier = Modifier
.aspectRatio(1f)
)
}
}
}

View File

@@ -41,10 +41,10 @@ object SeekbarDefaults {
@ReadOnlyComposable
@Composable
fun colors(
backgroundColor: Color = JellyfinTheme.colorScheme.rangeControlBackground,
bufferColor: Color = JellyfinTheme.colorScheme.seekbarBuffer,
progressColor: Color = JellyfinTheme.colorScheme.rangeControlFill,
knobColor: Color = JellyfinTheme.colorScheme.rangeControlKnob,
backgroundColor: Color = Color(0xCC4B4B4B),
bufferColor: Color = Color(0x40FFFFFF),
progressColor: Color = Color(0xFF00A4DC),
knobColor: Color = Color.White,
) = SeekbarColors(
backgroundColor = backgroundColor,
bufferColor = bufferColor,

View File

@@ -3,11 +3,10 @@ package org.jellyfin.androidtv.ui.base
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import org.jellyfin.design.Tokens
fun colorScheme(): ColorScheme = ColorScheme(
background = Tokens.Color.colorGrey975,
onBackground = Tokens.Color.colorBluegrey25,
background = Color(0xFF101010),
onBackground = Color(0xFFFFFFFF),
button = Color(0xB3747474),
onButton = Color(0xFFDDDDDD),
buttonFocused = Color(0xE6CCCCCC),
@@ -20,22 +19,9 @@ fun colorScheme(): ColorScheme = ColorScheme(
onInput = Color(0xE6CCCCCC),
inputFocused = Color(0xE6CCCCCC),
onInputFocused = Color(0xFFDDDDDD),
rangeControlBackground = Tokens.Color.colorBluegrey700,
rangeControlFill = Tokens.Color.colorCyan500,
rangeControlKnob = Tokens.Color.colorBluegrey100,
seekbarBuffer = Tokens.Color.colorBluegrey300,
recording = Tokens.Color.colorRed300,
onRecording = Tokens.Color.colorRed25,
badge = Tokens.Color.colorCyan500,
onBadge = Tokens.Color.colorBluegrey100,
listHeader = Tokens.Color.colorGrey50,
listOverline = Tokens.Color.colorGrey500,
listHeadline = Tokens.Color.colorGrey25,
listCaption = Tokens.Color.colorGrey200,
listButton = Color.Transparent,
listButtonFocused = Tokens.Color.colorBluegrey800,
surface = Tokens.Color.colorBluegrey900,
scrim = Tokens.Color.colorBlack.copy(alpha = 0.67f),
recording = Color(0xB3FF7474),
onRecording = Color(0xFFDDDDDD),
popover = Color(0xFF212225),
)
@Immutable
@@ -57,26 +43,10 @@ data class ColorScheme(
val inputFocused: Color,
val onInputFocused: Color,
val rangeControlBackground: Color,
val rangeControlFill: Color,
val rangeControlKnob: Color,
val seekbarBuffer: Color,
val recording: Color,
val onRecording: Color,
val badge: Color,
val onBadge: Color,
val listHeader: Color,
val listOverline: Color,
val listHeadline: Color,
val listCaption: Color,
val listButton: Color,
val listButtonFocused: Color,
val surface: Color,
val scrim: Color,
val popover: Color,
)
val LocalColorScheme = staticCompositionLocalOf { colorScheme() }

View File

@@ -1,84 +0,0 @@
package org.jellyfin.androidtv.ui.base.dialog
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
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.fillMaxSize
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import org.jellyfin.androidtv.ui.base.JellyfinTheme
import org.jellyfin.androidtv.ui.base.interactionTracker
@Composable
fun DialogBase(
visible: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
scrimColor: Color = JellyfinTheme.colorScheme.scrim,
enterTransition: EnterTransition = fadeIn(tween(300)),
exitTransition: ExitTransition = fadeOut(tween(300)),
contentAlignment: Alignment = Alignment.Center,
content: @Composable BoxScope.() -> Unit,
) {
val transition = updateTransition(visible)
val alpha by transition.animateFloat(
transitionSpec = { tween(1000) },
targetValueByState = { visible -> if (visible) 1f else 0f }
)
if (transition.currentState || transition.isRunning) {
val focusRequester = remember { FocusRequester() }
var contentVisible by remember { mutableStateOf(false) }
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true,
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
),
) {
Box(
modifier = modifier
.fillMaxSize()
.background(scrimColor.copy(alpha = scrimColor.alpha * alpha))
.focusRequester(focusRequester)
.focusGroup()
.interactionTracker(),
contentAlignment = contentAlignment,
) {
transition.AnimatedVisibility(
visible = { it && contentVisible },
enter = enterTransition,
exit = exitTransition,
) {
content()
LaunchedEffect(focusRequester) { focusRequester.requestFocus() }
}
}
}
LaunchedEffect(contentVisible) { contentVisible = true }
}
}

View File

@@ -1,49 +0,0 @@
package org.jellyfin.androidtv.ui.base.form
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
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.Shape
import androidx.compose.ui.res.painterResource
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
@Composable
fun Checkbox(
checked: Boolean,
modifier: Modifier = Modifier,
shape: Shape = JellyfinTheme.shapes.extraSmall,
containerColor: Color = JellyfinTheme.colorScheme.button,
contentColor: Color = JellyfinTheme.colorScheme.onButton
) {
Box(
modifier = modifier
.defaultMinSize(minWidth = 18.dp, minHeight = 18.dp)
.background(if (checked) containerColor else Color.Unspecified, shape)
.border(if (checked) 0.dp else 2.dp, containerColor, shape),
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(
visible = checked,
modifier = Modifier
.matchParentSize()
) {
Icon(
painterResource(R.drawable.ic_check),
tint = contentColor,
contentDescription = null,
modifier = Modifier
.padding(3.dp)
)
}
}
}

View File

@@ -1,51 +0,0 @@
package org.jellyfin.androidtv.ui.base.form
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
@Composable
fun ColorSwatch(
color: Color,
modifier: Modifier = Modifier,
shape: Shape = CircleShape,
) {
Box(
modifier = modifier
.defaultMinSize(minWidth = 18.dp, minHeight = 18.dp)
.clip(shape)
) {
Canvas(
modifier = Modifier
.matchParentSize()
) {
if (color.alpha < 1f) {
val squareSize = 6.dp.toPx()
repeat((size.height / squareSize).toInt()) { row ->
repeat((size.width / squareSize).toInt()) { col ->
val isLight = (row + col) % 2 == 0
drawRect(
color = if (isLight) Color.LightGray else Color.DarkGray,
topLeft = Offset(col * squareSize, row * squareSize),
size = Size(squareSize, squareSize),
)
}
}
}
drawRect(
color = color,
)
}
}
}

View File

@@ -1,50 +0,0 @@
package org.jellyfin.androidtv.ui.base.form
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.painterResource
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
@Composable
fun RadioButton(
checked: Boolean,
modifier: Modifier = Modifier,
shape: Shape = CircleShape,
containerColor: Color = JellyfinTheme.colorScheme.button,
contentColor: Color = JellyfinTheme.colorScheme.onButton
) {
Box(
modifier = modifier
.defaultMinSize(minWidth = 18.dp, minHeight = 18.dp)
.background(if (checked) containerColor else Color.Unspecified, shape)
.border(if (checked) 0.dp else 2.dp, containerColor, shape),
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(
visible = checked,
modifier = Modifier
.matchParentSize()
) {
Icon(
painterResource(R.drawable.ic_check),
tint = contentColor,
contentDescription = null,
modifier = Modifier
.padding(3.dp)
)
}
}
}

View File

@@ -1,115 +0,0 @@
package org.jellyfin.androidtv.ui.base.form
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.remember
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 org.jellyfin.androidtv.ui.base.JellyfinTheme
@Immutable
data class RangeControlColors(
val backgroundColor: Color,
val fillColor: Color,
val knobColor: Color,
)
object RangeControlDefaults {
@ReadOnlyComposable
@Composable
fun colors(
backgroundColor: Color = JellyfinTheme.colorScheme.rangeControlBackground,
fillColor: Color = JellyfinTheme.colorScheme.rangeControlFill,
knobColor: Color = JellyfinTheme.colorScheme.rangeControlKnob,
) = RangeControlColors(
backgroundColor = backgroundColor,
fillColor = fillColor,
knobColor = knobColor,
)
}
@Composable
fun RangeControl(
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
min: Float = 0f,
max: Float = 1f,
value: Float = 0f,
stepForward: Float = 0.01f,
stepBackward: Float = stepForward,
onValueChange: ((vakye: Float) -> Unit)? = null,
enabled: Boolean = true,
colors: RangeControlColors = RangeControlDefaults.colors(),
) {
val focused by interactionSource.collectIsFocusedAsState()
val knobSize by animateFloatAsState(if (focused) 1.75f else 1f)
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 isKeyDown = it.type == KeyEventType.KeyDown
val newValue = when {
isKeyDown && isForward -> (value + stepForward).coerceAtMost(max)
isKeyDown && isRewind -> (value - stepBackward).coerceAtLeast(min)
else -> value
}
if (value != newValue && onValueChange != null) onValueChange(newValue)
return@onKeyEvent isScrubbing
}
.focusable(interactionSource = interactionSource, enabled = enabled)
.drawWithContent {
val barCornerRadius = CornerRadius(size.minDimension, size.minDimension)
// Background bar
drawRoundRect(
color = colors.backgroundColor,
cornerRadius = barCornerRadius,
)
// Get percental value based on min/max options
val valuePercentage = (value - min) / (max - min)
// Value fill bar
if (valuePercentage > 0) {
drawRoundRect(
color = colors.fillColor,
size = size.copy(
width = valuePercentage * size.width,
),
cornerRadius = barCornerRadius,
)
}
// Progress knob
drawCircle(
color = colors.knobColor,
center = center.copy(
x = valuePercentage * size.width,
),
radius = size.minDimension * knobSize,
)
}
)
}

View File

@@ -1,38 +0,0 @@
package org.jellyfin.androidtv.ui.base
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.onPreviewKeyEvent
typealias InteractionTracker = () -> Unit
private val DefaultInteractionTracker: InteractionTracker = {}
val LocalInteractionTracker = staticCompositionLocalOf<InteractionTracker> { DefaultInteractionTracker }
@Composable
fun ProvideLocalInteractionTracker(
interactionTracker: InteractionTracker,
content: @Composable () -> Unit
) {
CompositionLocalProvider(
LocalInteractionTracker provides interactionTracker
) {
Box(
modifier = Modifier.interactionTracker(interactionTracker),
) {
content()
}
}
}
@Composable
fun Modifier.interactionTracker(
interactionTracker: InteractionTracker = LocalInteractionTracker.current,
): Modifier = onPreviewKeyEvent {
interactionTracker()
false
}

View File

@@ -1,45 +0,0 @@
package org.jellyfin.androidtv.ui.base.list
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
@Composable
fun ListButton(
onClick: () -> Unit,
headingContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource? = null,
colors: ListControlColors = ListControlDefaults.colors(),
overlineContent: (@Composable () -> Unit)? = null,
captionContent: (@Composable () -> Unit)? = null,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null,
footerContent: (@Composable () -> Unit)? = null,
) {
@Suppress("NAME_SHADOWING")
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
ListControl(
headingContent = headingContent,
modifier = modifier.combinedClickable(
interactionSource = interactionSource,
indication = null,
enabled = enabled,
role = Role.Button,
onClick = onClick,
),
enabled = enabled,
interactionSource = interactionSource,
colors = colors,
overlineContent = overlineContent,
captionContent = captionContent,
leadingContent = leadingContent,
trailingContent = trailingContent,
footerContent = footerContent,
)
}

View File

@@ -1,76 +0,0 @@
package org.jellyfin.androidtv.ui.base.list
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.InteractionSource
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.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import org.jellyfin.androidtv.ui.base.JellyfinTheme
import org.jellyfin.androidtv.ui.base.LocalShapes
object ListControlDefaults {
@ReadOnlyComposable
@Composable
fun colors(
containerColor: Color = JellyfinTheme.colorScheme.listButton,
focusedContainerColor: Color = JellyfinTheme.colorScheme.listButtonFocused,
) = ListControlColors(
containerColor = containerColor,
focusedContainerColor = focusedContainerColor,
)
}
@Composable
fun ListControl(
headingContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: InteractionSource? = null,
colors: ListControlColors = ListControlDefaults.colors(),
overlineContent: (@Composable () -> Unit)? = null,
captionContent: (@Composable () -> Unit)? = null,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null,
footerContent: (@Composable () -> Unit)? = null,
) {
@Suppress("NAME_SHADOWING")
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
val pressed by interactionSource.collectIsPressedAsState()
val backgroundColor = when {
!enabled -> colors.containerColor
pressed -> colors.focusedContainerColor
focused -> colors.focusedContainerColor
else -> colors.containerColor
}
Box(
modifier = modifier
.fillMaxWidth()
.background(backgroundColor, LocalShapes.current.large)
.clip(LocalShapes.current.large)
.alpha(if (enabled) 1f else 0.4f),
) {
ListItemContent(
headingContent = headingContent,
overlineContent = overlineContent,
captionContent = captionContent,
leadingContent = leadingContent,
trailingContent = trailingContent,
footerContent = footerContent,
headingStyle = JellyfinTheme.typography.listHeadline
.copy(color = JellyfinTheme.colorScheme.listHeadline),
)
}
}

View File

@@ -1,10 +0,0 @@
package org.jellyfin.androidtv.ui.base.list
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
@Immutable
data class ListControlColors(
val containerColor: Color,
val focusedContainerColor: Color,
)

View File

@@ -1,97 +0,0 @@
package org.jellyfin.androidtv.ui.base.list
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import org.jellyfin.androidtv.ui.base.JellyfinTheme
import org.jellyfin.androidtv.ui.base.LocalTextStyle
import org.jellyfin.androidtv.ui.base.ProvideTextStyle
import org.jellyfin.design.Tokens
@Composable
fun ListItemContent(
modifier: Modifier = Modifier,
headingContent: @Composable () -> Unit,
overlineContent: (@Composable () -> Unit)? = null,
captionContent: (@Composable () -> Unit)? = null,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null,
footerContent: (@Composable () -> Unit)? = null,
headingStyle: TextStyle,
) {
Column(
modifier = modifier
// TODO: Add suitable space token for this padding
.padding(12.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
leadingContent?.let { content ->
Box(
modifier = Modifier
.sizeIn(minWidth = 24.dp),
contentAlignment = Alignment.Center,
content = {
ProvideTextStyle(LocalTextStyle.current.copy(color = JellyfinTheme.colorScheme.listCaption)) {
content()
}
}
)
Spacer(Modifier.width(Tokens.Space.spaceMd))
}
Column(
modifier = Modifier
.weight(1f),
) {
overlineContent?.let { content ->
ProvideTextStyle(JellyfinTheme.typography.listOverline.copy(color = JellyfinTheme.colorScheme.listOverline)) {
content()
}
Spacer(Modifier.height(Tokens.Space.space2xs))
}
ProvideTextStyle(headingStyle) {
headingContent()
}
captionContent?.let { content ->
Spacer(Modifier.height(Tokens.Space.spaceXs))
ProvideTextStyle(JellyfinTheme.typography.listCaption.copy(color = JellyfinTheme.colorScheme.listCaption)) {
content()
}
}
}
trailingContent?.let { content ->
Spacer(Modifier.width(Tokens.Space.spaceMd))
Box(
modifier = Modifier
.sizeIn(minWidth = 24.dp),
contentAlignment = Alignment.Center,
content = { content() }
)
}
}
footerContent?.let { content ->
Spacer(Modifier.height(Tokens.Space.spaceXs))
ProvideTextStyle(LocalTextStyle.current.copy(color = JellyfinTheme.colorScheme.listCaption)) {
content()
}
}
}
}

View File

@@ -1,26 +0,0 @@
package org.jellyfin.androidtv.ui.base.list
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jellyfin.androidtv.ui.base.JellyfinTheme
import org.jellyfin.androidtv.ui.base.LocalTextStyle
import org.jellyfin.androidtv.ui.base.ProvideTextStyle
@Composable
fun ListMessage(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
// TODO: Add suitable space token for this padding
.padding(12.dp),
) {
ProvideTextStyle(LocalTextStyle.current.copy(color = JellyfinTheme.colorScheme.listCaption)) {
content()
}
}
}

View File

@@ -1,28 +0,0 @@
package org.jellyfin.androidtv.ui.base.list
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.jellyfin.androidtv.ui.base.JellyfinTheme
@Composable
fun ListSection(
modifier: Modifier = Modifier,
headingContent: @Composable () -> Unit,
overlineContent: (@Composable () -> Unit)? = null,
captionContent: (@Composable () -> Unit)? = null,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null,
footerContent: (@Composable () -> Unit)? = null,
) {
ListItemContent(
headingContent = headingContent,
overlineContent = overlineContent,
captionContent = captionContent,
leadingContent = leadingContent,
trailingContent = trailingContent,
footerContent = footerContent,
headingStyle = JellyfinTheme.typography.listHeader
.copy(color = JellyfinTheme.colorScheme.listHeader),
modifier = modifier,
)
}

View File

@@ -26,7 +26,6 @@ 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
import org.jellyfin.androidtv.ui.base.interactionTracker
object PopoverDefaults {
val Shape: Shape = RoundedCornerShape(4.dp)
@@ -40,10 +39,11 @@ fun Popover(
alignment: Alignment = Alignment.TopStart,
offset: DpOffset = DpOffset.Zero,
shape: Shape = PopoverDefaults.Shape,
backgroundColor: Color = JellyfinTheme.colorScheme.surface,
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,
@@ -60,8 +60,6 @@ fun Popover(
)
if (alpha != 0f) {
val focusRequester = remember { FocusRequester() }
Popup(
onDismissRequest = onDismissRequest,
properties = PopupProperties(
@@ -89,7 +87,6 @@ fun Popover(
.wrapContentSize()
.focusRequester(focusRequester)
.focusGroup()
.interactionTracker()
) {
content()
}

View File

@@ -3,51 +3,14 @@ package org.jellyfin.androidtv.ui.base
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.sp
object TypographyDefaults {
val Default: TextStyle = TextStyle.Default
val ListHeader: TextStyle = Default.copy(
fontSize = 15.sp,
lineHeight = 20.sp,
fontWeight = FontWeight.W700,
)
val ListOverline: TextStyle = Default.copy(
fontSize = 10.sp,
lineHeight = 12.sp,
fontWeight = FontWeight.W600,
letterSpacing = 0.65.sp,
)
val ListHeadline: TextStyle = Default.copy(
fontSize = 14.sp,
lineHeight = 28.sp,
fontWeight = FontWeight.W600,
)
val ListCaption: TextStyle = Default.copy(
fontSize = 11.sp,
lineHeight = 14.sp,
fontWeight = FontWeight.W500,
letterSpacing = 0.1.sp,
)
val Badge: TextStyle = Default.copy(
fontSize = 11.sp,
fontWeight = FontWeight.W700,
textAlign = TextAlign.Center,
)
}
@Immutable
data class Typography(
val default: TextStyle = TypographyDefaults.Default,
val listHeader: TextStyle = TypographyDefaults.ListHeader,
val listOverline: TextStyle = TypographyDefaults.ListOverline,
val listHeadline: TextStyle = TypographyDefaults.ListHeadline,
val listCaption: TextStyle = TypographyDefaults.ListCaption,
val badge: TextStyle = TypographyDefaults.Badge
)
val LocalTypography = staticCompositionLocalOf { Typography() }

View File

@@ -20,7 +20,6 @@ import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.leanback.widget.BaseGridView;
import androidx.leanback.widget.FocusHighlight;
import androidx.leanback.widget.OnItemViewClickedListener;
import androidx.leanback.widget.OnItemViewSelectedListener;
import androidx.leanback.widget.Presenter;
@@ -40,6 +39,7 @@ import org.jellyfin.androidtv.constant.QueryType;
import org.jellyfin.androidtv.data.model.FilterOptions;
import org.jellyfin.androidtv.data.querying.GetUserViewsRequest;
import org.jellyfin.androidtv.data.repository.CustomMessageRepository;
import org.jellyfin.androidtv.data.repository.UserViewsRepository;
import org.jellyfin.androidtv.data.service.BackgroundService;
import org.jellyfin.androidtv.databinding.HorizontalGridBrowseBinding;
import org.jellyfin.androidtv.databinding.PopupEmptyBinding;
@@ -50,6 +50,8 @@ 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.NavigationRepository;
import org.jellyfin.androidtv.ui.presentation.CardPresenter;
import org.jellyfin.androidtv.ui.presentation.HorizontalGridPresenter;
import org.jellyfin.androidtv.util.CoroutineUtils;
@@ -72,7 +74,6 @@ import java.util.Objects;
import java.util.UUID;
import kotlin.Lazy;
import kotlinx.coroutines.flow.MutableStateFlow;
import kotlinx.serialization.json.Json;
import timber.log.Timber;
@@ -114,7 +115,9 @@ 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<CustomMessageRepository> customMessageRepository = inject(CustomMessageRepository.class);
private final Lazy<NavigationRepository> navigationRepository = inject(NavigationRepository.class);
private final Lazy<ItemLauncher> itemLauncher = inject(ItemLauncher.class);
private final Lazy<KeyProcessor> keyProcessor = inject(KeyProcessor.class);
private final Lazy<ApiClient> api = inject(ApiClient.class);
@@ -152,7 +155,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
mCardFocusScale = getResources().getFraction(R.fraction.card_scale_focus, 1, 1);
if (mGridDirection.equals(GridDirection.VERTICAL))
setGridPresenter(new VerticalGridPresenter(FocusHighlight.ZOOM_FACTOR_LARGE, false));
setGridPresenter(new VerticalGridPresenter());
else
setGridPresenter(new HorizontalGridPresenter());
@@ -252,7 +255,6 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
}
gridPresenter.setOnItemViewSelectedListener(mRowSelectedListener);
gridPresenter.setOnItemViewClickedListener(mClickedListener);
gridPresenter.setShadowEnabled(false);
mGridPresenter = gridPresenter;
}
@@ -265,7 +267,6 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
}
gridPresenter.setOnItemViewSelectedListener(mRowSelectedListener);
gridPresenter.setOnItemViewClickedListener(mClickedListener);
gridPresenter.setShadowEnabled(false);
mGridPresenter = gridPresenter;
}
@@ -576,7 +577,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
mGridDirection = gridDirection;
if (mGridDirection.equals(GridDirection.VERTICAL) && (mGridPresenter == null || !(mGridPresenter instanceof VerticalGridPresenter))) {
setGridPresenter(new VerticalGridPresenter(FocusHighlight.ZOOM_FACTOR_LARGE, false));
setGridPresenter(new VerticalGridPresenter());
} else if (mGridDirection.equals(GridDirection.HORIZONTAL) && (mGridPresenter == null || !(mGridPresenter instanceof HorizontalGridPresenter))) {
setGridPresenter(new HorizontalGridPresenter());
}
@@ -607,7 +608,8 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
}
private void buildAdapter() {
mCardPresenter = new CardPresenter(false, mImageType, mCardHeight, true);
mCardPresenter = new CardPresenter(false, mImageType, mCardHeight);
mCardPresenter.setUniformAspect(true); // make grid layouts always uniform
Timber.d("buildAdapter cardHeight <%s> getCardWidthBy <%s> chunks <%s> type <%s>", mCardHeight, (int) getCardWidthBy(mCardHeight, mImageType, mFolder), mRowDef.getChunkSize(), mRowDef.getQueryType().toString());
@@ -805,22 +807,19 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
mLetterButton.setContentDescription(getString(R.string.lbl_by_letter));
binding.toolBar.addView(mLetterButton);
if (mFolder.getDisplayPreferencesId() != null) {
MutableStateFlow<Boolean> settingsVisible = BrowseGridFragmentHelperKt.createSettingsVisibility(BrowseGridFragment.this);
mSettingsButton = new ImageButton(requireContext(), null, 0, R.style.Button_Icon);
mSettingsButton.setImageResource(R.drawable.ic_settings);
mSettingsButton.setMaxHeight(size);
mSettingsButton.setAdjustViewBounds(true);
mSettingsButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
settingsVisible.setValue(true);
}
});
mSettingsButton.setContentDescription(getString(R.string.lbl_settings));
binding.toolBar.addView(mSettingsButton);
BrowseGridFragmentHelperKt.addSettings(BrowseGridFragment.this, binding.settings, mFolder.getId(), mFolder.getDisplayPreferencesId(), settingsVisible);
}
mSettingsButton = new ImageButton(requireContext(), null, 0, R.style.Button_Icon);
mSettingsButton.setImageResource(R.drawable.ic_settings);
mSettingsButton.setMaxHeight(size);
mSettingsButton.setAdjustViewBounds(true);
mSettingsButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
boolean allowViewSelection = userViewsRepository.getValue().allowViewSelection(mFolder.getCollectionType());
startActivity(ActivityDestinations.INSTANCE.displayPreferences(getContext(), mFolder.getDisplayPreferencesId(), allowViewSelection));
}
});
mSettingsButton.setContentDescription(getString(R.string.lbl_settings));
binding.toolBar.addView(mSettingsButton);
}
class JumplistPopup {

View File

@@ -1,41 +0,0 @@
package org.jellyfin.androidtv.ui.browsing
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.ComposeView
import kotlinx.coroutines.flow.MutableStateFlow
import org.jellyfin.androidtv.ui.navigation.ProvideRouter
import org.jellyfin.androidtv.ui.settings.Routes
import org.jellyfin.androidtv.ui.settings.composable.SettingsDialog
import org.jellyfin.androidtv.ui.settings.composable.SettingsRouterContent
import org.jellyfin.androidtv.ui.settings.routes
import java.util.UUID
fun BrowseGridFragment.createSettingsVisibility() = MutableStateFlow(false)
fun BrowseGridFragment.addSettings(
view: ComposeView,
itemId: UUID,
displayPreferencesId: String,
visible: MutableStateFlow<Boolean>,
) {
view.setContent {
val isVisible by visible.collectAsState(false)
ProvideRouter(
routes,
Routes.LIBRARIES_DISPLAY,
mapOf("itemId" to itemId.toString(), "displayPreferencesId" to displayPreferencesId)
) {
SettingsDialog(
visible = isVisible,
onDismissRequest = {
visible.value = false
onResume()
}
) {
SettingsRouterContent()
}
}
}
}

View File

@@ -12,6 +12,7 @@ import org.jellyfin.androidtv.constant.Extras;
import org.jellyfin.androidtv.constant.LiveTvOption;
import org.jellyfin.androidtv.constant.QueryType;
import org.jellyfin.androidtv.data.querying.GetSeriesTimersRequest;
import org.jellyfin.androidtv.preference.UserPreferences;
import org.jellyfin.androidtv.ui.GridButton;
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter;
import org.jellyfin.androidtv.ui.presentation.GridButtonPresenter;
@@ -64,6 +65,11 @@ public class BrowseViewFragment extends EnhancedBrowseFragment {
GetNextUpRequest getNextUpRequest = BrowsingUtils.createGetNextUpRequest(mFolder.getId());
mRows.add(new BrowseRowDef(getString(R.string.lbl_next_up), getNextUpRequest, new ChangeTriggerType[]{ChangeTriggerType.TvPlayback}));
//Premieres
if (KoinJavaComponent.<UserPreferences>get(UserPreferences.class).get(UserPreferences.Companion.getPremieresEnabled())) {
mRows.add(new BrowseRowDef(getString(R.string.lbl_new_premieres), BrowsingUtils.createPremieresRequest(mFolder.getId()), 0, true, true, new ChangeTriggerType[]{ChangeTriggerType.TvPlayback}, QueryType.Premieres));
}
//Latest content added
mRows.add(new BrowseRowDef(getString(R.string.lbl_latest), BrowsingUtils.createLatestMediaRequest(mFolder.getId(), BaseItemKind.EPISODE, true), new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated}));

View File

@@ -248,6 +248,22 @@ object BrowsingUtils {
sortBy = setOf(ItemSortBy.SORT_NAME),
)
@JvmStatic
fun createPremieresRequest(parentId: UUID) = GetItemsRequest(
fields = ItemRepository.itemFields,
includeItemTypes = setOf(BaseItemKind.EPISODE),
parentId = parentId,
indexNumber = 1,
recursive = true,
isMissing = false,
imageTypeLimit = 1,
filters = setOf(ItemFilter.IS_UNPLAYED),
sortBy = setOf(ItemSortBy.DATE_CREATED),
sortOrder = setOf(SortOrder.DESCENDING),
enableTotalRecordCount = false,
limit = 300,
)
@JvmStatic
fun createLastPlayedRequest(parentId: UUID) = GetItemsRequest(
fields = ItemRepository.itemFields,

View File

@@ -0,0 +1,61 @@
package org.jellyfin.androidtv.ui.browsing
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.constant.GridDirection
import org.jellyfin.androidtv.constant.ImageType
import org.jellyfin.androidtv.constant.PosterSize
import org.jellyfin.androidtv.preference.LibraryPreferences
import org.jellyfin.androidtv.preference.PreferencesRepository
import org.jellyfin.androidtv.ui.preference.dsl.OptionsFragment
import org.jellyfin.androidtv.ui.preference.dsl.checkbox
import org.jellyfin.androidtv.ui.preference.dsl.enum
import org.jellyfin.androidtv.ui.preference.dsl.optionsScreen
import org.jellyfin.preference.store.PreferenceStore
import org.koin.android.ext.android.inject
class DisplayPreferencesScreen : OptionsFragment() {
private val preferencesRepository: PreferencesRepository by inject()
private val libraryPreferences: LibraryPreferences by lazy {
preferencesRepository.getLibraryPreferences(preferencesId!!)
}
private val preferencesId by lazy { requireArguments().getString(ARG_PREFERENCES_ID) }
private val allowViewSelection by lazy { requireArguments().getBoolean(ARG_ALLOW_VIEW_SELECTION) }
override val stores: Array<PreferenceStore<*, *>>
get() = arrayOf(libraryPreferences)
override val screen by optionsScreen {
setTitle(R.string.lbl_display_preferences)
category {
enum<PosterSize> {
setTitle(R.string.lbl_image_size)
bind(libraryPreferences, LibraryPreferences.posterSize)
}
enum<ImageType> {
setTitle(R.string.lbl_image_type)
bind(libraryPreferences, LibraryPreferences.imageType)
}
enum<GridDirection> {
setTitle(R.string.grid_direction)
bind(libraryPreferences, LibraryPreferences.gridDirection)
}
if (allowViewSelection) {
checkbox {
setTitle(R.string.enable_smart_view)
contentOn = requireContext().getString(R.string.enable_smart_view_description)
contentOff = contentOn
bind(libraryPreferences, LibraryPreferences.enableSmartScreen)
}
}
}
}
companion object {
const val ARG_ALLOW_VIEW_SELECTION = "allow_view_selection"
const val ARG_PREFERENCES_ID = "preferences_id"
}
}

View File

@@ -357,13 +357,13 @@ public class EnhancedBrowseFragment extends Fragment implements RowLoader, View.
if (item instanceof GridButton) {
switch (((GridButton) item).getId()) {
case GRID:
navigationRepository.getValue().navigate(Destinations.INSTANCE.libraryBrowser(mFolder, null));
navigationRepository.getValue().navigate(Destinations.INSTANCE.libraryBrowser(mFolder));
break;
case ALBUMS:
mFolder = JavaCompat.copyWithDisplayPreferencesId(mFolder, mFolder.getId() + "AL");
navigationRepository.getValue().navigate(Destinations.INSTANCE.libraryBrowser(mFolder, null));
navigationRepository.getValue().navigate(Destinations.INSTANCE.libraryBrowser(mFolder, BaseItemKind.MUSIC_ALBUM.getSerialName()));
break;
case ALBUM_ARTISTS:

View File

@@ -3,9 +3,10 @@ package org.jellyfin.androidtv.ui.browsing
import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
@@ -19,17 +20,16 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
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.background.AppBackground
import org.jellyfin.androidtv.ui.base.JellyfinTheme
import org.jellyfin.androidtv.ui.base.ProvideLocalInteractionTracker
import org.jellyfin.androidtv.ui.composable.compat.AppNavigationHost
import org.jellyfin.androidtv.ui.navigation.NavigationAction
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
import org.jellyfin.androidtv.ui.screensaver.InAppScreensaver
import org.jellyfin.androidtv.ui.settings.compat.MainActivitySettings
import org.jellyfin.androidtv.ui.startup.StartupActivity
import org.jellyfin.androidtv.util.applyTheme
import org.jellyfin.androidtv.util.isMediaSessionKeyEvent
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import timber.log.Timber
@@ -41,6 +41,14 @@ class MainActivity : FragmentActivity() {
private val interactionTrackerViewModel by viewModel<InteractionTrackerViewModel>()
private val workManager by inject<WorkManager>()
private lateinit var binding: ActivityMainBinding
private val backPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (navigationRepository.canGoBack) navigationRepository.goBack()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
applyTheme()
@@ -54,28 +62,21 @@ class MainActivity : FragmentActivity() {
else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}.launchIn(lifecycleScope)
onBackPressedDispatcher.addCallback(this, backPressedCallback)
if (savedInstanceState == null && navigationRepository.canGoBack) navigationRepository.reset(clearHistory = true)
navigationRepository.currentAction
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.onEach {
.onEach { action ->
handleNavigationAction(action)
backPressedCallback.isEnabled = navigationRepository.canGoBack
interactionTrackerViewModel.notifyInteraction(canCancel = false, userInitiated = false)
}.launchIn(lifecycleScope)
setContent {
JellyfinTheme {
ProvideLocalInteractionTracker(
interactionTracker = { interactionTrackerViewModel.notifyInteraction(false, userInitiated = true) }
) {
AppBackground()
AppNavigationHost(
navigationRepository = navigationRepository,
)
InAppScreensaver()
MainActivitySettings()
}
}
}
binding = ActivityMainBinding.inflate(layoutInflater)
binding.background.setContent { AppBackground() }
binding.screensaver.setContent { InAppScreensaver() }
setContentView(binding.root)
}
override fun onResume() {
@@ -116,16 +117,34 @@ class MainActivity : FragmentActivity() {
}
}
// Forward key events to fragments
private fun handleNavigationAction(action: NavigationAction) {
interactionTrackerViewModel.notifyInteraction(canCancel = true, userInitiated = false)
when (action) {
is NavigationAction.NavigateFragment -> binding.contentView.navigate(action)
NavigationAction.GoBack -> binding.contentView.goBack()
NavigationAction.Nothing -> Unit
}
}
// Forward key events to fragments
private fun Fragment.onKeyEvent(keyCode: Int, event: KeyEvent?): Boolean {
var result = childFragmentManager.fragments.any { it.onKeyEvent(keyCode, event) }
if (!result && this is View.OnKeyListener) result = onKey(currentFocus, keyCode, event)
return result
}
private fun onKeyEvent(keyCode: Int, event: KeyEvent?): Boolean = supportFragmentManager.fragments
.any { it.onKeyEvent(keyCode, event) }
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)
return true
}
return supportFragmentManager.fragments
.any { it.onKeyEvent(keyCode, event) }
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean =
onKeyEvent(keyCode, event) || super.onKeyDown(keyCode, event)
@@ -135,4 +154,44 @@ class MainActivity : FragmentActivity() {
override fun onKeyLongPress(keyCode: Int, event: KeyEvent?): Boolean =
onKeyEvent(keyCode, event) || super.onKeyUp(keyCode, event)
override fun onUserInteraction() {
super.onUserInteraction()
interactionTrackerViewModel.notifyInteraction(false, userInitiated = true)
}
@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)
return true
}
@Suppress("RestrictedApi") // False positive
return super.dispatchKeyEvent(event)
}
@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)
return true
}
@Suppress("RestrictedApi") // False positive
return super.dispatchKeyShortcutEvent(event)
}
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
// Ignore the touch event that closes the screensaver
if (interactionTrackerViewModel.visible.value) {
interactionTrackerViewModel.notifyInteraction(canCancel = true, userInitiated = true)
return true
}
return super.dispatchTouchEvent(ev)
}
}

View File

@@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp
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
@@ -245,13 +246,16 @@ fun BaseItemInfoRow(
includeRuntime: Boolean,
) {
val userPreferences = koinInject<UserPreferences>()
val ratingType = userPreferences[UserPreferences.defaultRatingType]
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
item.communityRating?.let { InfoRowCommunityRating(it / 10f) }
item.criticRating?.let { InfoRowCriticRating(it / 100f) }
if (ratingType != RatingType.RATING_HIDDEN) {
item.communityRating?.let { InfoRowCommunityRating(it / 10f) }
item.criticRating?.let { InfoRowCriticRating(it / 100f) }
}
when (item.type) {
BaseItemKind.EPISODE -> {

View File

@@ -0,0 +1,261 @@
package org.jellyfin.androidtv.ui.card;
import android.app.Activity;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.text.TextUtils;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.leanback.widget.BaseCardView;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.databinding.ViewCardLegacyImageBinding;
import org.jellyfin.androidtv.ui.AsyncImageView;
import org.jellyfin.androidtv.ui.itemhandling.BaseItemDtoBaseRowItem;
import org.jellyfin.androidtv.ui.itemhandling.BaseRowItem;
import org.jellyfin.androidtv.util.ContextExtensionsKt;
import org.jellyfin.androidtv.util.DateTimeExtensionsKt;
import org.jellyfin.androidtv.util.Utils;
import java.text.NumberFormat;
/**
* Modified ImageCard with no fade on the badge
* A card view with an {@link ImageView} as its main region.
*/
public class LegacyImageCardView extends BaseCardView {
private ViewCardLegacyImageBinding binding = ViewCardLegacyImageBinding.inflate(LayoutInflater.from(getContext()), this);
private ImageView mBanner;
private int BANNER_SIZE = Utils.convertDpToPixel(getContext(), 50);
private int noIconMargin = Utils.convertDpToPixel(getContext(), 5);
private NumberFormat nf = NumberFormat.getInstance();
public LegacyImageCardView(Context context, boolean showInfo) {
super(context, null, androidx.leanback.R.attr.imageCardViewStyle);
if (!showInfo) {
setCardType(CARD_TYPE_MAIN_ONLY);
}
binding.mainImage.setClipToOutline(true);
// "hack" to trigger KeyProcessor to open the menu for this item on long press
setOnLongClickListener(v -> {
Activity activity = ContextExtensionsKt.getActivity(getContext());
if (activity == null) return false;
// Make sure the view is focused so the created menu uses it as anchor
if (!v.requestFocus()) return false;
return activity.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MENU));
});
setForeground(null);
}
public void setBanner(int bannerResource) {
if (mBanner == null) {
mBanner = new ImageView(getContext());
mBanner.setLayoutParams(new ViewGroup.LayoutParams(BANNER_SIZE, BANNER_SIZE));
((ViewGroup) getRootView()).addView(mBanner);
}
mBanner.setImageResource(bannerResource);
mBanner.setVisibility(VISIBLE);
}
public final AsyncImageView getMainImageView() {
return binding.mainImage;
}
public void setPlayingIndicator(boolean playing) {
if (playing) {
// TODO use decent animation for equalizer icon
binding.extraBadge.setBackgroundResource(R.drawable.ic_play);
binding.extraBadge.setVisibility(VISIBLE);
} else {
binding.extraBadge.setBackgroundResource(R.drawable.blank10x10);
}
}
public void setMainImageDimensions(int width, int height) {
setMainImageDimensions(width, height, ImageView.ScaleType.CENTER_CROP);
}
public void setMainImageDimensions(int width, int height, ImageView.ScaleType scaleType) {
ViewGroup.LayoutParams lp = binding.mainImage.getLayoutParams();
lp.width = Math.round(width * getResources().getDisplayMetrics().density);
lp.height = Math.round(height * getResources().getDisplayMetrics().density);
binding.mainImage.setLayoutParams(lp);
binding.mainImage.setScaleType(scaleType);
if (mBanner != null) mBanner.setX(lp.width - BANNER_SIZE);
ViewGroup.LayoutParams lp2 = binding.resumeProgress.getLayoutParams();
lp2.width = lp.width;
binding.resumeProgress.setLayoutParams(lp2);
}
public void setTitleText(CharSequence text) {
if (binding.title == null) {
return;
}
binding.title.setText(text);
setTextMaxLines();
}
public void setOverlayText(String text) {
if (getCardType() == BaseCardView.CARD_TYPE_MAIN_ONLY) {
binding.overlayText.setText(text);
binding.nameOverlay.setVisibility(VISIBLE);
} else {
binding.nameOverlay.setVisibility(GONE);
}
}
public void setOverlayInfo(BaseRowItem item) {
if (binding.overlayText == null) return;
if (getCardType() == BaseCardView.CARD_TYPE_MAIN_ONLY && item.getShowCardInfoOverlay()) {
switch (item.getBaseItem().getType()) {
case PHOTO:
insertCardData(item.getBaseItem().getPremiereDate() != null ? DateTimeExtensionsKt.getDateFormatter(getContext()).format(item.getBaseItem().getPremiereDate()) : item.getFullName(getContext()), R.drawable.ic_camera, true);
break;
case PHOTO_ALBUM:
insertCardData(item.getFullName(getContext()), R.drawable.ic_photos, true);
break;
case VIDEO:
insertCardData(item.getFullName(getContext()), R.drawable.ic_movie, true);
break;
case FOLDER:
insertCardData(item.getFullName(getContext()), R.drawable.ic_folder, true);
break;
case PLAYLIST:
case MUSIC_ARTIST:
case PERSON:
default:
binding.overlayText.setText(item.getFullName(getContext()));
break;
}
if (item instanceof BaseItemDtoBaseRowItem) {
binding.overlayCount.setText(((BaseItemDtoBaseRowItem) item).getChildCountStr());
} else {
binding.overlayCount.setText(null);
}
binding.nameOverlay.setVisibility(VISIBLE);
}
}
public void insertCardData (@Nullable String fullName, @NonNull int icon, @NonNull boolean iconVisible) {
binding.overlayText.setText(fullName);
if (iconVisible) {
binding.iconImage.setImageResource(icon);
binding.icon.setVisibility(VISIBLE);
}
}
public CharSequence getTitle() {
if (binding.title == null) {
return null;
}
return binding.title.getText();
}
public void setContentText(CharSequence text) {
if (binding.contentText == null) {
return;
}
binding.contentText.setText(text);
setTextMaxLines();
}
public CharSequence getContentText() {
if (binding.contentText == null) {
return null;
}
return binding.contentText.getText();
}
public void setRating(String rating) {
if (rating != null) {
binding.badgeText.setText(rating);
binding.badgeText.setVisibility(VISIBLE);
} else {
binding.badgeText.setText("");
binding.badgeText.setVisibility(GONE);
}
}
public void setBadgeImage(Drawable drawable) {
if (binding.extraBadge == null) {
return;
}
if (drawable != null) {
binding.extraBadge.setImageDrawable(drawable);
binding.extraBadge.setVisibility(View.VISIBLE);
} else {
binding.extraBadge.setVisibility(View.GONE);
}
}
@Override
public boolean hasOverlappingRendering() {
return false;
}
private void setTextMaxLines() {
if (TextUtils.isEmpty(getTitle())) {
binding.contentText.setMaxLines(2);
} else {
binding.contentText.setMaxLines(1);
}
if (TextUtils.isEmpty(getContentText())) {
binding.title.setMaxLines(2);
} else {
binding.title.setMaxLines(1);
}
}
public void clearBanner() {
if (mBanner != null) {
mBanner.setVisibility(GONE);
}
}
public void setUnwatchedCount(int count) {
if (count > 0) {
binding.unwatchedCount.setText(count > 99 ? getContext().getString(R.string.watch_count_overflow) : nf.format(count));
binding.unwatchedCount.setVisibility(VISIBLE);
binding.checkMark.setVisibility(INVISIBLE);
binding.watchedIndicator.setVisibility(VISIBLE);
} else if (count == 0) {
binding.checkMark.setVisibility(VISIBLE);
binding.unwatchedCount.setVisibility(INVISIBLE);
binding.watchedIndicator.setVisibility(VISIBLE);
} else {
binding.watchedIndicator.setVisibility(GONE);
}
}
public void setProgress(int pct) {
if (pct > 0) {
binding.resumeProgress.setProgress(pct);
binding.resumeProgress.setVisibility(VISIBLE);
} else {
binding.resumeProgress.setVisibility(GONE);
}
}
public void showFavIcon(boolean show) {
binding.favIcon.setVisibility(show ? VISIBLE : GONE);
}
}

View File

@@ -5,6 +5,7 @@ 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
@@ -15,13 +16,13 @@ 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.PaddingValues
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
@@ -35,13 +36,17 @@ 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.ProfilePicture
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
@@ -151,17 +156,26 @@ class UserCardView @JvmOverloads constructor(
UserCard(
image = {
ProfilePicture(
url = image,
iconPadding = PaddingValues(24.dp),
modifier = Modifier.fillMaxSize()
)
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(
text = name.orEmpty(),
maxLines = 1
)
Text(name.orEmpty(), maxLines = 1)
},
modifier = Modifier
.padding(horizontal = 6.dp, vertical = 8.dp)

View File

@@ -1,59 +0,0 @@
package org.jellyfin.androidtv.ui.composable.compat
import android.content.Context
import android.view.View
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.jellyfin.androidtv.ui.browsing.DestinationFragmentView
import org.jellyfin.androidtv.ui.navigation.NavigationAction
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
import org.koin.compose.koinInject
@Composable
fun AppNavigationHost(
modifier: Modifier = Modifier,
navigationRepository: NavigationRepository = koinInject(),
) {
val factory = remember { AppNavigationHostViewFactory() }
val currentAction by navigationRepository.currentAction.collectAsState(NavigationAction.Nothing)
val canGoBack by remember {
navigationRepository.currentAction.map { navigationRepository.canGoBack }.distinctUntilChanged()
}.collectAsState(navigationRepository.canGoBack)
BackHandler(canGoBack) { navigationRepository.goBack() }
AndroidView(
factory = factory,
modifier = modifier,
)
LaunchedEffect(currentAction) {
when (val currentAction = currentAction) {
is NavigationAction.NavigateFragment -> factory.view.navigate(currentAction)
NavigationAction.GoBack -> factory.view.goBack()
NavigationAction.Nothing -> Unit
}
}
}
private class AppNavigationHostViewFactory : (Context) -> View {
private var _view: DestinationFragmentView? = null
val view get() = requireNotNull(_view)
override operator fun invoke(
context: Context
): View = DestinationFragmentView(context).also { view ->
_view = view
}
}

View File

@@ -1,36 +0,0 @@
package org.jellyfin.androidtv.ui.composable.item
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import org.jellyfin.androidtv.ui.base.JellyfinTheme
@Composable
@Stable
fun ItemCard(
modifier: Modifier = Modifier,
image: @Composable BoxScope.() -> Unit,
overlay: (@Composable BoxScope.() -> Unit)? = null,
shape: Shape = JellyfinTheme.shapes.medium,
) {
Box(
modifier = modifier
.clip(shape)
.background(JellyfinTheme.colorScheme.surface, shape)
) {
image()
if (overlay != null) {
Box(
modifier = Modifier.fillMaxSize(),
content = overlay
)
}
}
}

View File

@@ -1,171 +0,0 @@
package org.jellyfin.androidtv.ui.composable.item
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.sizeIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.constant.WatchedIndicatorBehavior
import org.jellyfin.androidtv.ui.base.Badge
import org.jellyfin.androidtv.ui.base.Icon
import org.jellyfin.androidtv.ui.base.Seekbar
import org.jellyfin.androidtv.ui.base.Text
import org.jellyfin.androidtv.ui.composable.rememberPlayerProgress
import org.jellyfin.androidtv.ui.composable.rememberQueueEntry
import org.jellyfin.design.Tokens
import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.jellyfin.queue.baseItem
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.koin.compose.koinInject
@Composable
@Stable
fun ItemCardBaseItemOverlay(
item: BaseItemDto,
footer: (@Composable () -> Unit)? = null,
) = Box(
modifier = Modifier
.fillMaxSize()
.padding(Tokens.Space.spaceXs)
) {
StateIndicator(
item = item,
modifier = Modifier.align(Alignment.TopStart),
)
WatchIndicator(
item = item,
modifier = Modifier.align(Alignment.TopEnd)
)
Column(
modifier = Modifier.align(Alignment.BottomCenter),
verticalArrangement = Arrangement.spacedBy(Tokens.Space.spaceXs)
) {
ProgressIndicator(
item = item,
)
if (footer != null) footer()
}
}
@Composable
@Stable
private fun StateIndicator(
item: BaseItemDto,
modifier: Modifier = Modifier,
) {
val isFavorited = item.userData?.isFavorite == true
val isRecording = item.timerId?.takeIf { item.type == BaseItemKind.LIVE_TV_PROGRAM || item.type == BaseItemKind.PROGRAM } != null
val isRecordingActive = item.seriesTimerId != null && isRecording
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(Tokens.Space.spaceXs)
) {
if (isRecording) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_record_series),
contentDescription = null,
tint = if (isRecordingActive) Tokens.Color.colorRed600 else Tokens.Color.colorGrey100,
modifier = modifier
.size(24.dp)
)
}
if (isFavorited) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_heart),
contentDescription = null,
tint = Tokens.Color.colorRed500,
modifier = modifier
.size(24.dp)
)
}
}
}
@Composable
@Stable
private fun WatchIndicator(
item: BaseItemDto,
modifier: Modifier = Modifier,
) {
val userPreferences = koinInject<UserPreferences>()
val watchedIndicatorBehavior = userPreferences[UserPreferences.watchedIndicatorBehavior]
if (watchedIndicatorBehavior == WatchedIndicatorBehavior.NEVER) return
if (watchedIndicatorBehavior == WatchedIndicatorBehavior.EPISODES_ONLY && item.type != BaseItemKind.EPISODE) return
val isPlayed = item.userData?.played == true
val unplayedItems = item.userData?.unplayedItemCount?.takeIf { it > 0 }
if (isPlayed) {
Badge(
modifier = modifier
.size(24.dp),
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_watch),
contentDescription = null,
modifier = Modifier.size(12.dp)
)
}
} else if (unplayedItems != null) {
if (watchedIndicatorBehavior == WatchedIndicatorBehavior.HIDE_UNWATCHED) return
Badge(
modifier = modifier
.sizeIn(minWidth = 24.dp, minHeight = 24.dp),
) {
Text(
text = unplayedItems.toString(),
)
}
}
}
@Composable
private fun ProgressIndicator(
item: BaseItemDto,
modifier: Modifier = Modifier,
) {
val playbackManager = koinInject<org.jellyfin.playback.core.PlaybackManager>()
val playState by playbackManager.state.playState.collectAsState()
val currentQueueEntry by rememberQueueEntry(playbackManager)
val playedPercentage = if (playState == PlayState.PLAYING && currentQueueEntry?.baseItem?.id == item.id) {
rememberPlayerProgress(playbackManager)
} else {
item.userData?.playedPercentage?.toFloat()?.div(100f)?.coerceIn(0f, 1f)?.takeIf { it > 0f && it < 1f }
}
if (playedPercentage != null) {
Box(modifier = modifier.padding(Tokens.Space.spaceXs)) {
Seekbar(
progress = playedPercentage,
enabled = false,
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
)
}
}
}

View File

@@ -1,99 +0,0 @@
package org.jellyfin.androidtv.ui.composable.item
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp
import org.jellyfin.androidtv.ui.base.JellyfinTheme
import org.jellyfin.androidtv.ui.base.ProvideTextStyle
import org.jellyfin.design.Tokens
@Composable
@Stable
fun ItemPreview(
card: @Composable () -> Unit,
modifier: Modifier = Modifier,
title: (@Composable () -> Unit)? = null,
subtitle: (@Composable () -> Unit)? = null,
spacing: Dp = Tokens.Space.spaceXs,
) {
ItemPreviewLayout(
card = card,
metadata = {
ItemPreviewMetadata(
title = title,
subtitle = subtitle,
spacing = spacing,
)
},
spacing = spacing,
modifier = modifier
)
}
@Composable
@Stable
private fun ItemPreviewMetadata(
title: (@Composable () -> Unit)?,
subtitle: (@Composable () -> Unit)?,
spacing: Dp,
) {
Column(
modifier = Modifier.padding(spacing),
verticalArrangement = Arrangement.spacedBy(spacing),
) {
title?.let { content ->
ProvideTextStyle(
value = JellyfinTheme.typography.default.copy(
color = Tokens.Color.colorGrey100,
fontSize = 12.sp,
),
content = content,
)
}
subtitle?.let { content ->
ProvideTextStyle(
value = JellyfinTheme.typography.default.copy(
color = Tokens.Color.colorGrey300,
fontSize = 10.sp,
),
content = content,
)
}
}
}
@Composable
@Stable
fun ItemPreviewLayout(
card: @Composable () -> Unit,
metadata: @Composable () -> Unit,
spacing: Dp = Tokens.Space.spaceXs,
modifier: Modifier,
) {
SubcomposeLayout(
modifier = modifier,
) { constraints ->
val cardPlaceables = subcompose("card", card).map { it.measure(constraints) }
val cardWidth = cardPlaceables.maxOf { it.width }
val cardHeight = cardPlaceables.maxOf { it.height }
val childConstraints = constraints.copy(minWidth = cardWidth, maxWidth = cardWidth)
val metadataPlaceables = subcompose("metadata", metadata).map { it.measure(childConstraints) }
val metadataHeight = metadataPlaceables.maxOf { it.height }
val spacingPx = spacing.roundToPx()
val height = cardHeight + spacingPx + metadataHeight
layout(cardWidth, height) {
cardPlaceables.forEach { it.place(0, 0) }
metadataPlaceables.forEach { it.place(0, cardHeight + spacingPx) }
}
}
}

View File

@@ -5,20 +5,15 @@ import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import kotlinx.coroutines.delay
import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.core.model.PositionInfo
import org.jellyfin.playback.core.queue.queue
import org.koin.compose.koinInject
import kotlin.math.roundToInt
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@Composable
fun rememberQueueEntry(
@@ -27,29 +22,6 @@ fun rememberQueueEntry(
playbackManager.queue.entry
}.collectAsState()
@Composable
fun rememberPlayerPositionInfo(
playbackManager: PlaybackManager = koinInject(),
precision: Duration = 1.seconds
): MutableState<PositionInfo> {
val playState by playbackManager.state.playState.collectAsState()
val playing = playState == PlayState.PLAYING
val positionInfo = remember { mutableStateOf(playbackManager.state.positionInfo) }
LaunchedEffect(playing, precision) {
val precisionMs = precision.inWholeMilliseconds
while (playing) {
positionInfo.value = playbackManager.state.positionInfo
delay(precisionMs - (positionInfo.value.active.inWholeMilliseconds % precisionMs))
}
}
return positionInfo
}
@Composable
fun rememberPlayerProgress(
playbackManager: PlaybackManager = koinInject(),

View File

@@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.onEach
import org.jellyfin.androidtv.auth.repository.ServerRepository
import org.jellyfin.androidtv.auth.repository.SessionRepository
import org.jellyfin.androidtv.data.repository.NotificationsRepository
import org.jellyfin.androidtv.ui.base.JellyfinTheme
import org.jellyfin.androidtv.ui.shared.toolbar.MainToolbar
import org.jellyfin.androidtv.ui.shared.toolbar.MainToolbarActiveButton
import org.koin.android.ext.android.inject
@@ -46,33 +47,35 @@ class HomeFragment : Fragment() {
val rowsFocusRequester = remember { FocusRequester() }
LaunchedEffect(rowsFocusRequester) { rowsFocusRequester.requestFocus() }
Column {
MainToolbar(MainToolbarActiveButton.Home)
JellyfinTheme {
Column {
MainToolbar(MainToolbarActiveButton.Home)
// The leanback code has its own awful focus handling that doesn't work properly with Compose view inteop to workaround this
// issue we add custom behavior that only allows focus exit when the current selected row is the first one. Additionally when
// we do switch the focus, we reset the leanback state so it won't cause weird behavior when focus is regained
var rowsSupportFragment by remember { mutableStateOf<HomeRowsFragment?>(null) }
AndroidFragment<HomeRowsFragment>(
modifier = Modifier
.focusGroup()
.focusRequester(rowsFocusRequester)
.focusProperties {
onExit = {
val isFirstRowSelected = rowsSupportFragment?.selectedPosition?.let { it <= 0 } ?: false
if (requestedFocusDirection != FocusDirection.Up || !isFirstRowSelected) {
cancelFocusChange()
} else {
rowsSupportFragment?.selectedPosition = 0
rowsSupportFragment?.verticalGridView?.clearFocus()
// The leanback code has its own awful focus handling that doesn't work properly with Compose view inteop to workaround this
// issue we add custom behavior that only allows focus exit when the current selected row is the first one. Additionally when
// we do switch the focus, we reset the leanback state so it won't cause weird behavior when focus is regained
var rowsSupportFragment by remember { mutableStateOf<HomeRowsFragment?>(null) }
AndroidFragment<HomeRowsFragment>(
modifier = Modifier
.focusGroup()
.focusRequester(rowsFocusRequester)
.focusProperties {
onExit = {
val isFirstRowSelected = rowsSupportFragment?.selectedPosition?.let { it <= 0 } ?: false
if (requestedFocusDirection != FocusDirection.Up || !isFirstRowSelected) {
cancelFocusChange()
} else {
rowsSupportFragment?.selectedPosition = 0
rowsSupportFragment?.verticalGridView?.clearFocus()
}
}
}
.fillMaxSize(),
onUpdate = { fragment ->
rowsSupportFragment = fragment
}
.fillMaxSize(),
onUpdate = { fragment ->
rowsSupportFragment = fragment
}
)
)
}
}
}

View File

@@ -9,13 +9,14 @@ import org.jellyfin.androidtv.data.querying.GetUserViewsRequest
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter
import org.jellyfin.androidtv.ui.presentation.CardPresenter
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter
import org.jellyfin.androidtv.ui.presentation.UserViewCardPresenter
class HomeFragmentViewsRow(
val small: Boolean,
) : HomeFragmentRow {
private companion object {
val smallCardPresenter = CardPresenter(true, 75)
val largeCardPresenter = CardPresenter(true, 126)
val smallCardPresenter = UserViewCardPresenter(true)
val largeCardPresenter = UserViewCardPresenter(false)
}
override fun addToRowsAdapter(context: Context, cardPresenter: CardPresenter, rowsAdapter: MutableObjectAdapter<Row>) {

View File

@@ -547,7 +547,7 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
//Chapters
if (mBaseItem.getChapters() != null && !mBaseItem.getChapters().isEmpty()) {
List<ChapterItemInfo> chapters = BaseItemExtensionsKt.buildChapterItems(mBaseItem);
List<ChapterItemInfo> chapters = BaseItemExtensionsKt.buildChapterItems(mBaseItem, api.getValue());
ItemRowAdapter chapterAdapter = new ItemRowAdapter(requireContext(), chapters, new CardPresenter(true, 120), adapter);
addItemRow(adapter, chapterAdapter, 2, getString(R.string.lbl_chapters));
}
@@ -612,12 +612,6 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
break;
case EPISODE:
//Additional Parts
if (mBaseItem.getPartCount() != null && mBaseItem.getPartCount() > 0) {
ItemRowAdapter additionalPartsAdapter = new ItemRowAdapter(requireContext(), new GetAdditionalPartsRequest(mBaseItem.getId()), new CardPresenter(), adapter);
addItemRow(adapter, additionalPartsAdapter, 0, getString(R.string.lbl_additional_parts));
}
if (mBaseItem.getSeasonId() != null && mBaseItem.getIndexNumber() != null) {
// query index is zero-based but episode no is not
ItemRowAdapter nextAdapter = new ItemRowAdapter(requireContext(), BrowsingUtils.createNextEpisodesRequest(mBaseItem.getSeasonId(), mBaseItem.getIndexNumber()), 0, false, true, new CardPresenter(true, 120), adapter);
@@ -638,7 +632,7 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
//Chapters
if (mBaseItem.getChapters() != null && !mBaseItem.getChapters().isEmpty()) {
List<ChapterItemInfo> chapters = BaseItemExtensionsKt.buildChapterItems(mBaseItem);
List<ChapterItemInfo> chapters = BaseItemExtensionsKt.buildChapterItems(mBaseItem, api.getValue());
ItemRowAdapter chapterAdapter = new ItemRowAdapter(requireContext(), chapters, new CardPresenter(true, 120), adapter);
addItemRow(adapter, chapterAdapter, 1, getString(R.string.lbl_chapters));
}
@@ -1002,20 +996,22 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
mDetailsOverviewRow.addAction(goToSeriesButton);
}
boolean deletableItem = false;
UserDto currentUser = KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue();
if (mBaseItem.getType() == BaseItemKind.RECORDING && currentUser.getPolicy().getEnableLiveTvManagement() && mBaseItem.getCanDelete() != null)
deletableItem = mBaseItem.getCanDelete();
else if (mBaseItem.getCanDelete() != null) deletableItem = mBaseItem.getCanDelete();
if (userPreferences.getValue().get(UserPreferences.Companion.getMediaManagementEnabled())) {
boolean deletableItem = false;
UserDto currentUser = KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue();
if (mBaseItem.getType() == BaseItemKind.RECORDING && currentUser.getPolicy().getEnableLiveTvManagement() && mBaseItem.getCanDelete() != null)
deletableItem = mBaseItem.getCanDelete();
else if (mBaseItem.getCanDelete() != null) deletableItem = mBaseItem.getCanDelete();
if (deletableItem) {
deleteButton = TextUnderButton.create(requireContext(), R.drawable.ic_delete, buttonSize, 0, getString(R.string.lbl_delete), new View.OnClickListener() {
@Override
public void onClick(View v) {
deleteItem();
}
});
mDetailsOverviewRow.addAction(deleteButton);
if (deletableItem) {
deleteButton = TextUnderButton.create(requireContext(), R.drawable.ic_delete, buttonSize, 0, getString(R.string.lbl_delete), new View.OnClickListener() {
@Override
public void onClick(View v) {
deleteItem();
}
});
mDetailsOverviewRow.addAction(deleteButton);
}
}
if (mSeriesTimerInfo != null) {

View File

@@ -1,21 +1,20 @@
package org.jellyfin.androidtv.ui.itemhandling
import android.content.Context
import org.jellyfin.androidtv.util.apiclient.JellyfinImage
import org.jellyfin.androidtv.util.apiclient.albumPrimaryImage
import org.jellyfin.androidtv.util.apiclient.itemImages
import org.jellyfin.androidtv.util.apiclient.parentImages
import androidx.core.content.ContextCompat
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.constant.ImageType
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.androidtv.util.TimeUtils
import org.jellyfin.androidtv.util.apiclient.seriesPrimaryImage
import org.jellyfin.androidtv.util.apiclient.seriesThumbImage
import org.jellyfin.androidtv.util.getTimeFormatter
import org.jellyfin.androidtv.util.locale
import org.jellyfin.androidtv.util.sdk.getFullName
import org.jellyfin.androidtv.util.sdk.getSubName
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks
import java.time.format.DateTimeFormatter
import org.jellyfin.androidtv.constant.ImageType as BaseRowImageType
open class BaseItemDtoBaseRowItem @JvmOverloads constructor(
item: BaseItemDto,
@@ -60,6 +59,26 @@ open class BaseItemDtoBaseRowItem @JvmOverloads constructor(
override val isFavorite get() = baseItem?.userData?.isFavorite == true
override val isPlayed get() = baseItem?.userData?.played == true
val childCountStr: String?
get() {
// Playlist
if (baseItem?.type == BaseItemKind.PLAYLIST) {
val childCount = baseItem.cumulativeRunTimeTicks?.ticks?.let { duration ->
TimeUtils.formatMillis(duration.inWholeMilliseconds)
}
if (childCount != null) return childCount
}
// Folder
if (baseItem?.isFolder == true && baseItem.type != BaseItemKind.MUSIC_ARTIST) {
val childCount = baseItem.childCount
if (childCount != null && childCount > 0) return childCount.toString()
}
// Default
return null
}
override fun getCardName(context: Context) = when {
baseItem?.type == BaseItemKind.AUDIO && baseItem.artists != null -> baseItem.artists?.joinToString(", ")
baseItem?.type == BaseItemKind.AUDIO && baseItem.albumArtists != null -> baseItem.albumArtists?.joinToString(", ")
@@ -101,29 +120,58 @@ open class BaseItemDtoBaseRowItem @JvmOverloads constructor(
else -> baseItem?.getSubName(context)
}
override fun getImage(imageType: BaseRowImageType): JellyfinImage? {
val primaryImage = when {
preferSeriesPoster && baseItem?.type == BaseItemKind.EPISODE -> baseItem.parentImages[ImageType.PRIMARY]
?: baseItem.seriesPrimaryImage
override fun getImageUrl(
context: Context,
imageHelper: ImageHelper,
imageType: ImageType,
fillWidth: Int,
fillHeight: Int
): String? {
val seriesPrimaryImage = baseItem?.seriesPrimaryImage
preferParentThumb && baseItem?.type == BaseItemKind.EPISODE -> baseItem.parentImages[ImageType.THUMB]
?: baseItem.seriesThumbImage
return when {
preferSeriesPoster && seriesPrimaryImage != null -> imageHelper.getImageUrl(seriesPrimaryImage, fillWidth, fillHeight)
baseItem?.type == BaseItemKind.SEASON -> baseItem.seriesPrimaryImage
baseItem?.type == BaseItemKind.PROGRAM -> baseItem.itemImages[ImageType.THUMB]
baseItem?.type == BaseItemKind.AUDIO -> baseItem.albumPrimaryImage
else -> null
} ?: baseItem?.itemImages[ImageType.PRIMARY]
imageType == ImageType.BANNER -> imageHelper.getBannerImageUrl(
requireNotNull(
baseItem
), fillWidth, fillHeight
)
return when (imageType) {
BaseRowImageType.BANNER -> baseItem?.itemImages[ImageType.BANNER] ?: primaryImage
BaseRowImageType.THUMB -> baseItem?.itemImages[ImageType.THUMB] ?: primaryImage
else -> primaryImage
imageType == ImageType.THUMB -> imageHelper.getThumbImageUrl(
requireNotNull(
baseItem
), fillWidth, fillHeight
)
else -> imageHelper.getPrimaryImageUrl(
baseItem!!,
preferParentThumb,
null,
fillHeight
)
}
}
override fun equals(other: Any?): Boolean {
if (other is BaseItemDtoBaseRowItem) return other.baseItem == baseItem
return super.equals(other)
}
override fun getBadgeImage(
context: Context,
imageHelper: ImageHelper,
) = when (baseItem?.type) {
BaseItemKind.LIVE_TV_PROGRAM,
BaseItemKind.PROGRAM -> when {
baseItem.seriesTimerId != null -> R.drawable.ic_record_series_red
baseItem.timerId != null -> R.drawable.ic_record_red
else -> null
}
else -> when {
baseItem?.criticRating != null -> when {
baseItem.criticRating!! > 59f -> R.drawable.ic_rt_fresh
else -> R.drawable.ic_rt_rotten
}
else -> null
}
}?.let { ContextCompat.getDrawable(context, it) }
}

View File

@@ -2,7 +2,7 @@ package org.jellyfin.androidtv.ui.itemhandling
import android.content.Context
import org.jellyfin.androidtv.constant.ImageType
import org.jellyfin.androidtv.util.apiclient.primaryImage
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.sdk.model.api.BaseItemPerson
class BaseItemPersonBaseRowItem(
@@ -11,7 +11,14 @@ class BaseItemPersonBaseRowItem(
baseRowType = BaseRowType.Person,
staticHeight = true,
) {
override fun getImage(imageType: ImageType) = person.primaryImage
override fun getImageUrl(
context: Context,
imageHelper: ImageHelper,
imageType: ImageType,
fillWidth: Int,
fillHeight: Int
) = imageHelper.getPrimaryImageUrl(person, fillHeight)
override val itemId get() = person.id
override fun getFullName(context: Context) = person.name
override fun getName(context: Context) = person.name

View File

@@ -1,8 +1,9 @@
package org.jellyfin.androidtv.ui.itemhandling
import android.content.Context
import android.graphics.drawable.Drawable
import org.jellyfin.androidtv.constant.ImageType
import org.jellyfin.androidtv.util.apiclient.JellyfinImage
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.sdk.model.api.BaseItemDto
import java.util.UUID
@@ -20,12 +21,19 @@ abstract class BaseRowItem protected constructor(
open fun getCardName(context: Context): String? = getFullName(context)
open fun getImage(imageType: ImageType): JellyfinImage? = null
open fun getImageUrl(
context: Context,
imageHelper: ImageHelper,
imageType: ImageType,
fillWidth: Int,
fillHeight: Int,
): String? = null
open fun getFullName(context: Context): String? = null
open fun getName(context: Context): String? = null
open fun getSubText(context: Context): String? = null
open fun getSummary(context: Context): String? = null
open fun getBadgeImage(context: Context, imageHelper: ImageHelper): Drawable? = null
override fun equals(other: Any?): Boolean {
if (other is BaseRowItem) return other.itemId == itemId

View File

@@ -3,6 +3,7 @@ package org.jellyfin.androidtv.ui.itemhandling
import android.content.Context
import org.jellyfin.androidtv.constant.ImageType
import org.jellyfin.androidtv.data.model.ChapterItemInfo
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.androidtv.util.TimeUtils
import org.jellyfin.sdk.model.extensions.ticks
@@ -12,7 +13,14 @@ class ChapterItemInfoBaseRowItem(
baseRowType = BaseRowType.Chapter,
staticHeight = true,
) {
override fun getImage(imageType: ImageType) = chapterInfo.image
override fun getImageUrl(
context: Context,
imageHelper: ImageHelper,
imageType: ImageType,
fillWidth: Int,
fillHeight: Int
) = chapterInfo.imagePath
override val itemId get() = chapterInfo.itemId
override fun getFullName(context: Context) = chapterInfo.name
override fun getName(context: Context) = chapterInfo.name

View File

@@ -3,6 +3,7 @@ package org.jellyfin.androidtv.ui.itemhandling
import android.content.Context
import org.jellyfin.androidtv.constant.ImageType
import org.jellyfin.androidtv.ui.GridButton
import org.jellyfin.androidtv.util.ImageHelper
class GridButtonBaseRowItem(
val gridButton: GridButton,
@@ -10,7 +11,14 @@ class GridButtonBaseRowItem(
baseRowType = BaseRowType.GridButton,
staticHeight = true,
) {
override fun getImage(imageType: ImageType) = null
override fun getImageUrl(
context: Context,
imageHelper: ImageHelper,
imageType: ImageType,
fillWidth: Int,
fillHeight: Int
) = gridButton.imageRes?.let { imageHelper.getResourceUrl(context, it) }
override fun getFullName(context: Context) = gridButton.text
override fun getName(context: Context) = gridButton.text
}

View File

@@ -56,13 +56,13 @@ public class ItemLauncher {
LibraryPreferences displayPreferences = preferencesRepository.getValue().getLibraryPreferences(baseItem.getDisplayPreferencesId());
boolean enableSmartScreen = displayPreferences.get(LibraryPreferences.Companion.getEnableSmartScreen());
if (!enableSmartScreen) return Destinations.INSTANCE.libraryBrowser(baseItem, null);
if (!enableSmartScreen) return Destinations.INSTANCE.libraryBrowser(baseItem);
else return Destinations.INSTANCE.librarySmartScreen(baseItem);
case MUSIC:
case LIVETV:
return Destinations.INSTANCE.librarySmartScreen(baseItem);
default:
return Destinations.INSTANCE.libraryBrowser(baseItem, null);
return Destinations.INSTANCE.libraryBrowser(baseItem);
}
}
@@ -147,7 +147,7 @@ public class ItemLauncher {
baseItem = JavaCompat.copyWithDisplayPreferencesId(baseItem, baseItem.getId().toString());
}
navigationRepository.getValue().navigate(Destinations.INSTANCE.libraryBrowser(baseItem, null));
navigationRepository.getValue().navigate(Destinations.INSTANCE.libraryBrowser(baseItem));
} else {
switch (rowItem.getSelectAction()) {

View File

@@ -235,10 +235,11 @@ fun ItemRowAdapter.retrieveUserViews(api: ApiClient, userViewsRepository: UserVi
val filteredItems = response.items
.filter { userViewsRepository.isSupported(it.collectionType) }
.map { it.copy(displayPreferencesId = it.id.toString()) }
setItems(
items = filteredItems,
transform = { item, _ -> BaseItemDtoBaseRowItem(item, staticHeight = true) }
transform = { item, _ -> BaseItemDtoBaseRowItem(item) }
)
if (filteredItems.isEmpty()) removeRow()

View File

@@ -3,6 +3,7 @@ package org.jellyfin.androidtv.ui.itemhandling
import android.content.Context
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.constant.ImageType
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.androidtv.util.apiclient.getSeriesOverview
import org.jellyfin.sdk.model.api.SeriesTimerInfoDto
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@@ -12,7 +13,17 @@ class SeriesTimerInfoDtoBaseRowItem(
) : BaseRowItem(
baseRowType = BaseRowType.SeriesTimer,
) {
override fun getImage(imageType: ImageType) = null
override fun getImageUrl(
context: Context,
imageHelper: ImageHelper,
imageType: ImageType,
fillWidth: Int,
fillHeight: Int
) = imageHelper.getResourceUrl(
context,
R.drawable.tile_land_series_timer
)
override fun getFullName(context: Context) = seriesTimerInfo.name
override fun getName(context: Context) = seriesTimerInfo.name
override val itemId get() = seriesTimerInfo.id?.toUUIDOrNull()

View File

@@ -0,0 +1,34 @@
package org.jellyfin.androidtv.ui.livetv
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.preference.SystemPreferences
import org.jellyfin.androidtv.ui.preference.dsl.OptionsFragment
import org.jellyfin.androidtv.ui.preference.dsl.OptionsItemCheckbox
import org.jellyfin.androidtv.ui.preference.dsl.checkbox
import org.jellyfin.androidtv.ui.preference.dsl.optionsScreen
import org.koin.android.ext.android.inject
class GuideFiltersScreen : OptionsFragment() {
private val systemPreferences: SystemPreferences by inject()
override val screen by optionsScreen {
setTitle(R.string.lbl_filters)
category {
setOf(
SystemPreferences.liveTvGuideFilterMovies to getString(R.string.lbl_movies),
SystemPreferences.liveTvGuideFilterSeries to getString(R.string.lbl_series),
SystemPreferences.liveTvGuideFilterNews to getString(R.string.lbl_news),
SystemPreferences.liveTvGuideFilterKids to getString(R.string.lbl_kids),
SystemPreferences.liveTvGuideFilterSports to getString(R.string.lbl_sports),
SystemPreferences.liveTvGuideFilterPremiere to getString(R.string.lbl_new_only),
).forEach { (preference, label) ->
checkbox {
title = label
type = OptionsItemCheckbox.Type.SWITCH
bind(systemPreferences, preference)
}
}
}
}
}

View File

@@ -0,0 +1,63 @@
package org.jellyfin.androidtv.ui.livetv
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.preference.LiveTvPreferences
import org.jellyfin.androidtv.preference.constant.LiveTvChannelOrder
import org.jellyfin.androidtv.ui.preference.dsl.OptionsFragment
import org.jellyfin.androidtv.ui.preference.dsl.OptionsItemCheckbox
import org.jellyfin.androidtv.ui.preference.dsl.checkbox
import org.jellyfin.androidtv.ui.preference.dsl.enum
import org.jellyfin.androidtv.ui.preference.dsl.optionsScreen
import org.jellyfin.preference.store.PreferenceStore
import org.koin.android.ext.android.inject
class GuideOptionsScreen : OptionsFragment() {
private val liveTvPreferences: LiveTvPreferences by inject()
override val stores: Array<PreferenceStore<*, *>>
get() = arrayOf(liveTvPreferences)
override val screen by optionsScreen {
setTitle(R.string.live_tv_preferences)
category {
enum<LiveTvChannelOrder> {
title = getString(R.string.lbl_sort_by)
bind {
get { LiveTvChannelOrder.fromString(liveTvPreferences[LiveTvPreferences.channelOrder]) }
set { liveTvPreferences[LiveTvPreferences.channelOrder] = it.stringValue }
default { LiveTvChannelOrder.fromString(liveTvPreferences.getDefaultValue(LiveTvPreferences.channelOrder)) }
}
}
checkbox {
title = getString(R.string.lbl_start_favorites)
bind(liveTvPreferences, LiveTvPreferences.favsAtTop)
}
checkbox {
title = getString(R.string.lbl_colored_backgrounds)
bind(liveTvPreferences, LiveTvPreferences.colorCodeGuide)
}
}
category {
title = getString(R.string.lbl_show_indicators)
setOf(
LiveTvPreferences.showHDIndicator to getString(R.string.lbl_hd_programs),
LiveTvPreferences.showLiveIndicator to getString(R.string.lbl_live_broadcasts),
LiveTvPreferences.showNewIndicator to getString(R.string.lbl_new_episodes),
LiveTvPreferences.showPremiereIndicator to getString(R.string.lbl_premieres),
LiveTvPreferences.showRepeatIndicator to getString(R.string.lbl_repeat_episodes),
).forEach { (preference, label) ->
checkbox {
title = label
type = OptionsItemCheckbox.Type.SWITCH
bind(liveTvPreferences, preference)
}
}
}
}
}

View File

@@ -39,6 +39,7 @@ import org.jellyfin.androidtv.ui.ObservableHorizontalScrollView;
import org.jellyfin.androidtv.ui.ObservableScrollView;
import org.jellyfin.androidtv.ui.ProgramGridCell;
import org.jellyfin.androidtv.ui.ScrollViewListener;
import org.jellyfin.androidtv.ui.navigation.ActivityDestinations;
import org.jellyfin.androidtv.ui.playback.PlaybackLauncher;
import org.jellyfin.androidtv.util.CoroutineUtils;
import org.jellyfin.androidtv.util.DateTimeExtensionsKt;
@@ -58,7 +59,6 @@ import java.util.List;
import java.util.UUID;
import kotlin.Lazy;
import kotlinx.coroutines.flow.MutableStateFlow;
import timber.log.Timber;
public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.OnKeyListener {
@@ -105,8 +105,6 @@ public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.O
private final Lazy<PlaybackHelper> playbackHelper = inject(PlaybackHelper.class);
private final Lazy<ImageHelper> imageHelper = inject(ImageHelper.class);
private final Lazy<PlaybackLauncher> playbackLauncher = inject(PlaybackLauncher.class);
private MutableStateFlow<Boolean> showOptions;
private MutableStateFlow<Boolean> showFilterOptions;
@Nullable
@Override
@@ -131,22 +129,20 @@ public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.O
mSpinner = binding.spinner;
mSpinner.setVisibility(View.VISIBLE);
showFilterOptions = LiveTvGuideFragmentHelperKt.addSettingsFilters(this, binding);
View mFilterButton = binding.filterButton;
mFilterButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
showFilterOptions.setValue(true);
showFilterOptions();
}
});
mFilterButton.setContentDescription(getString(R.string.lbl_filters));
showOptions = LiveTvGuideFragmentHelperKt.addSettingsOptions(this, binding);
View mOptionsButton = binding.optionsButton;
mOptionsButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
showOptions.setValue(true);
showOptions();
}
});
mOptionsButton.setContentDescription(getString(R.string.lbl_other_options));
@@ -321,7 +317,7 @@ public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.O
switch (keyCode) {
case KeyEvent.KEYCODE_MENU:
// bring up filter selection
showFilterOptions.setValue(true);
showFilterOptions();
break;
case KeyEvent.KEYCODE_ENTER:
case KeyEvent.KEYCODE_DPAD_CENTER:
@@ -455,6 +451,16 @@ public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.O
mDetailPopup.show(mImage, mTitle.getLeft(), mTitle.getTop() - 10);
}
public void showFilterOptions() {
startActivity(ActivityDestinations.INSTANCE.liveTvGuideFilterPreferences(getContext()));
TvManager.forceReload();
}
public void showOptions() {
startActivity(ActivityDestinations.INSTANCE.liveTvGuideOptionPreferences(getContext()));
TvManager.forceReload();
}
public void displayChannels(int start, int max) {
int end = start + max;
if (end > mAllChannels.size()) {

View File

@@ -2,24 +2,15 @@ package org.jellyfin.androidtv.ui.livetv
import android.content.Context
import android.view.View
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.data.model.DataRefreshService
import org.jellyfin.androidtv.data.repository.ItemMutationRepository
import org.jellyfin.androidtv.databinding.LiveTvGuideBinding
import org.jellyfin.androidtv.ui.GuideChannelHeader
import org.jellyfin.androidtv.ui.navigation.ProvideRouter
import org.jellyfin.androidtv.ui.settings.Routes
import org.jellyfin.androidtv.ui.settings.composable.SettingsDialog
import org.jellyfin.androidtv.ui.settings.composable.SettingsRouterContent
import org.jellyfin.androidtv.ui.settings.routes
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemDto
@@ -83,55 +74,3 @@ fun LiveTvGuideFragment.refreshSelectedProgram() {
detailUpdateInternal();
}
}
fun LiveTvGuideFragment.addSettingsOptions(binding: LiveTvGuideBinding): MutableStateFlow<Boolean> {
val visible = MutableStateFlow(false)
binding.settingsOptions.setContent {
val isVisible by visible.collectAsState(false)
ProvideRouter(
routes,
Routes.LIVETV_GUIDE_OPTIONS
) {
SettingsDialog(
visible = isVisible,
onDismissRequest = {
visible.value = false
TvManager.forceReload()
doLoad()
}
) {
SettingsRouterContent()
}
}
}
return visible
}
fun LiveTvGuideFragment.addSettingsFilters(binding: LiveTvGuideBinding): MutableStateFlow<Boolean> {
val visible = MutableStateFlow(false)
binding.settingsFilters.setContent {
val isVisible by visible.collectAsState(false)
ProvideRouter(
routes,
Routes.LIVETV_GUIDE_FILTERS
) {
SettingsDialog(
visible = isVisible,
onDismissRequest = {
visible.value = false
TvManager.forceReload()
doLoad()
}
) {
SettingsRouterContent()
}
}
}
return visible
}

View File

@@ -3,11 +3,40 @@ package org.jellyfin.androidtv.ui.navigation
import android.content.Context
import android.content.Intent
import androidx.core.os.bundleOf
import org.jellyfin.androidtv.ui.browsing.DisplayPreferencesScreen
import org.jellyfin.androidtv.ui.livetv.GuideFiltersScreen
import org.jellyfin.androidtv.ui.livetv.GuideOptionsScreen
import org.jellyfin.androidtv.ui.playback.ExternalPlayerActivity
import org.jellyfin.androidtv.ui.preference.PreferencesActivity
import org.jellyfin.androidtv.ui.preference.dsl.OptionsFragment
import org.jellyfin.androidtv.ui.preference.screen.UserPreferencesScreen
import org.jellyfin.androidtv.ui.startup.StartupActivity
import kotlin.time.Duration
object ActivityDestinations {
private inline fun <reified T : OptionsFragment> preferenceIntent(
context: Context,
vararg screenArguments: Pair<String, Any?>
) = Intent(context, PreferencesActivity::class.java).apply {
putExtras(
bundleOf(
PreferencesActivity.EXTRA_SCREEN to T::class.qualifiedName,
PreferencesActivity.EXTRA_SCREEN_ARGS to bundleOf(*screenArguments),
)
)
}
fun userPreferences(context: Context) = preferenceIntent<UserPreferencesScreen>(context)
fun displayPreferences(context: Context, displayPreferencesId: String, allowViewSelection: Boolean) =
preferenceIntent<DisplayPreferencesScreen>(
context,
DisplayPreferencesScreen.ARG_PREFERENCES_ID to displayPreferencesId,
DisplayPreferencesScreen.ARG_ALLOW_VIEW_SELECTION to allowViewSelection,
)
fun liveTvGuideFilterPreferences(context: Context) = preferenceIntent<GuideFiltersScreen>(context)
fun liveTvGuideOptionPreferences(context: Context) = preferenceIntent<GuideOptionsScreen>(context)
fun externalPlayer(context: Context, position: Duration = Duration.ZERO) = Intent(context, ExternalPlayerActivity::class.java).apply {
putExtras(
bundleOf(

View File

@@ -39,45 +39,50 @@ object Destinations {
// Browsing
// TODO only pass item id instead of complete JSON to browsing destinations
fun libraryBrowser(item: BaseItemDto, includeType: String? = null) =
fun libraryBrowser(item: BaseItemDto) = fragmentDestination<BrowseGridFragment>(
Extras.Folder to Json.Default.encodeToString(item),
)
// TODO only pass item id instead of complete JSON to browsing destinations
fun libraryBrowser(item: BaseItemDto, includeType: String) =
fragmentDestination<BrowseGridFragment>(
Extras.Folder to Json.encodeToString(item),
Extras.Folder to Json.Default.encodeToString(item),
Extras.IncludeType to includeType,
)
// TODO only pass item id instead of complete JSON to browsing destinations
fun librarySmartScreen(item: BaseItemDto) = fragmentDestination<BrowseViewFragment>(
Extras.Folder to Json.encodeToString(item),
Extras.Folder to Json.Default.encodeToString(item),
)
// TODO only pass item id instead of complete JSON to browsing destinations
fun collectionBrowser(item: BaseItemDto) = fragmentDestination<CollectionFragment>(
Extras.Folder to Json.encodeToString(item),
Extras.Folder to Json.Default.encodeToString(item),
)
// TODO only pass item id instead of complete JSON to browsing destinations
fun folderBrowser(item: BaseItemDto) = fragmentDestination<GenericFolderFragment>(
Extras.Folder to Json.encodeToString(item),
Extras.Folder to Json.Default.encodeToString(item),
)
// TODO only pass item id instead of complete JSON to browsing destinations
fun libraryByGenres(item: BaseItemDto, includeType: String) =
fragmentDestination<ByGenreFragment>(
Extras.Folder to Json.encodeToString(item),
Extras.Folder to Json.Default.encodeToString(item),
Extras.IncludeType to includeType,
)
// TODO only pass item id instead of complete JSON to browsing destinations
fun libraryByLetter(item: BaseItemDto, includeType: String) =
fragmentDestination<ByLetterFragment>(
Extras.Folder to Json.encodeToString(item),
Extras.Folder to Json.Default.encodeToString(item),
Extras.IncludeType to includeType,
)
// TODO only pass item id instead of complete JSON to browsing destinations
fun librarySuggestions(item: BaseItemDto) =
fragmentDestination<SuggestedMoviesFragment>(
Extras.Folder to Json.encodeToString(item),
Extras.Folder to Json.Default.encodeToString(item),
)
// Item details
@@ -90,14 +95,14 @@ object Destinations {
fragmentDestination<FullDetailsFragment>(
"ItemId" to item.toString(),
"ChannelId" to channel.toString(),
"ProgramInfo" to Json.encodeToString(programInfo),
"ProgramInfo" to Json.Default.encodeToString(programInfo),
)
// TODO only pass item id instead of complete JSON to browsing destinations
fun seriesTimerDetails(item: UUID, seriesTimer: SeriesTimerInfoDto) =
fragmentDestination<FullDetailsFragment>(
"ItemId" to item.toString(),
"SeriesTimer" to Json.encodeToString(seriesTimer),
"SeriesTimer" to Json.Default.encodeToString(seriesTimer),
)
fun itemList(item: UUID) = fragmentDestination<ItemListFragment>(

View File

@@ -1,120 +0,0 @@
package org.jellyfin.androidtv.ui.navigation
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.scene.Scene
import androidx.navigation3.ui.NavDisplay
import androidx.navigation3.ui.defaultPopTransitionSpec
import androidx.navigation3.ui.defaultTransitionSpec
import androidx.navigationevent.NavigationEvent
typealias RouteParameters = Map<String, String>
typealias RouteComposable = @Composable ((context: RouteContext) -> Unit)
data class RouteContext(
val route: String,
val parameters: RouteParameters,
)
class Router(
val routes: Map<String, RouteComposable>,
val backStack: SnapshotStateList<RouteContext>,
) {
// Route resolving
fun resolve(route: String): RouteComposable? = routes[route]
fun verifyRoute(route: String, parameters: RouteParameters = emptyMap()) = require(resolve(route) != null) { "Invalid route $route" }
// Route manipulation
fun push(route: String, parameters: RouteParameters = emptyMap()) {
verifyRoute(route, parameters)
val context = RouteContext(route, parameters)
backStack.add(context)
}
fun replace(route: String, parameters: RouteParameters = emptyMap()) {
verifyRoute(route, parameters)
val context = RouteContext(route, parameters)
backStack.removeLastOrNull()
backStack.add(context)
}
fun back() {
backStack.removeLastOrNull()
}
}
val LocalRouter = compositionLocalOf<Router> { error("No router provided") }
val LocalRouterTransitionScope = compositionLocalOf<SharedTransitionScope> { error("No router transition scope provided") }
@Composable
fun ProvideRouter(
routes: Map<String, RouteComposable>,
defaultRoute: String,
defaultRouteParameters: RouteParameters = emptyMap(),
content: @Composable () -> Unit,
) {
val backStack = remember { mutableStateListOf(RouteContext(defaultRoute, defaultRouteParameters)) }
val router = remember(routes, backStack) {
Router(
routes = routes,
backStack = backStack,
)
}
CompositionLocalProvider(
LocalRouter provides router,
content = content
)
}
@Composable
fun RouterContent(
router: Router = LocalRouter.current,
fallbackRoute: String = "/",
transitionSpec: AnimatedContentTransitionScope<Scene<RouteContext>>.() -> ContentTransform = defaultTransitionSpec(),
popTransitionSpec: AnimatedContentTransitionScope<Scene<RouteContext>>.() -> ContentTransform = defaultPopTransitionSpec(),
predictivePopTransitionSpec: AnimatedContentTransitionScope<Scene<RouteContext>>.(@NavigationEvent.SwipeEdge Int) -> ContentTransform = { popTransitionSpec() },
) {
SharedTransitionLayout {
CompositionLocalProvider(LocalRouterTransitionScope provides this@SharedTransitionLayout) {
NavDisplay(
backStack = router.backStack,
onBack = { router.back() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
),
transitionSpec = transitionSpec,
popTransitionSpec = popTransitionSpec,
predictivePopTransitionSpec = predictivePopTransitionSpec,
entryProvider = { backStackEntry ->
NavEntry(backStackEntry) {
val route = backStackEntry.route
val composable = router.resolve(route)
if (composable == null) {
val fallbackComposable = router.resolve(fallbackRoute)
?: error("Unknown route $route, fallback $fallbackRoute is invalid")
val context = backStackEntry.copy(route = fallbackRoute)
fallbackComposable(context)
} else {
composable(backStackEntry)
}
}
}
)
}
}
}

View File

@@ -37,8 +37,8 @@ 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.lyrics
import org.jellyfin.playback.jellyfin.lyrics.lyricsFlow
import org.jellyfin.playback.jellyfin.lyrics
import org.jellyfin.playback.jellyfin.lyricsFlow
import org.jellyfin.playback.jellyfin.queue.baseItem
import org.jellyfin.playback.jellyfin.queue.baseItemFlow
import org.jellyfin.sdk.api.client.ApiClient

View File

@@ -7,6 +7,7 @@ import android.content.Context;
import android.graphics.Color;
import android.media.AudioManager;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.view.KeyEvent;
@@ -1282,7 +1283,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
if (chapters != null && !chapters.isEmpty()) {
// create chapter row for later use
ItemRowAdapter chapterAdapter = new ItemRowAdapter(requireContext(), BaseItemExtensionsKt.buildChapterItems(item), new CardPresenter(true, 110), new MutableObjectAdapter<Row>());
ItemRowAdapter chapterAdapter = new ItemRowAdapter(requireContext(), BaseItemExtensionsKt.buildChapterItems(item, api.getValue()), new CardPresenter(true, 110), new MutableObjectAdapter<Row>());
chapterAdapter.Retrieve();
if (mChapterRow != null) mPopupRowAdapter.remove(mChapterRow);
mChapterRow = new ListRow(new HeaderItem(requireContext().getString(R.string.chapters)), chapterAdapter);
@@ -1339,8 +1340,10 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
WindowCompat.getInsetsController(requireActivity().getWindow(), requireActivity().getWindow().getDecorView()).show(WindowInsetsCompat.Type.systemBars());
// Reset display mode
WindowManager.LayoutParams params = getActivity().getWindow().getAttributes();
params.preferredDisplayModeId = 0;
getActivity().getWindow().setAttributes(params);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
WindowManager.LayoutParams params = getActivity().getWindow().getAttributes();
params.preferredDisplayModeId = 0;
getActivity().getWindow().setAttributes(params);
}
}
}

View File

@@ -159,12 +159,12 @@ fun CustomPlaybackOverlayFragment.recordProgram(program: BaseItemDto, isSeries:
lifecycleScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val defaultTimer by api.liveTvApi.getDefaultTimer(program.id.toString())
val defaultTimer by api.liveTvApi.getDefaultTimer()
if (isSeries) {
api.liveTvApi.createSeriesTimer(defaultTimer)
api.liveTvApi.createSeriesTimer(defaultTimer.copy(programId = program.id.toString()))
} else {
api.liveTvApi.createTimer(defaultTimer.asTimerInfoDto())
api.liveTvApi.createTimer(defaultTimer.asTimerInfoDto().copy(programId = program.id.toString()))
}
}
}.fold(

View File

@@ -14,8 +14,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.data.model.DataRefreshService
import org.jellyfin.androidtv.data.repository.ExternalAppRepository
import org.jellyfin.androidtv.util.componentName
import org.jellyfin.androidtv.util.sdk.getDisplayName
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi
@@ -74,7 +72,6 @@ class ExternalPlayerActivity : FragmentActivity() {
private val videoQueueManager by inject<VideoQueueManager>()
private val dataRefreshService by inject<DataRefreshService>()
private val externalAppRepository by inject<ExternalAppRepository>()
private val api by inject<ApiClient>()
private val playVideoLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
@@ -158,12 +155,6 @@ class ExternalPlayerActivity : FragmentActivity() {
else -> null
}
// Set configured app to launch
externalAppRepository
.getCurrentExternalPlayerApp(this@ExternalPlayerActivity)
?.componentName
?.let(::setComponent)
setDataAndTypeAndNormalize(url.toUri(), mediaType)
putExtra(API_MX_SEEK_POSITION, position.inWholeMilliseconds.toInt())

View File

@@ -3,6 +3,7 @@ package org.jellyfin.androidtv.ui.playback;
import static org.koin.java.KoinJavaComponent.get;
import static org.koin.java.KoinJavaComponent.inject;
import android.annotation.TargetApi;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.os.Handler;
@@ -261,6 +262,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
}
}
@TargetApi(23)
private void getDisplayModes() {
if (mFragment == null)
return;
@@ -272,6 +274,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
}
}
@TargetApi(23)
private Display.Mode findBestDisplayMode(MediaStream videoStream) {
if (mFragment == null || mDisplayModes == null || videoStream.getRealFrameRate() == null)
return null;
@@ -328,6 +331,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
return bestMode;
}
@TargetApi(23)
private void setRefreshRate(MediaStream videoStream) {
if (videoStream == null || mFragment == null) {
Timber.e("Null video stream attempting to set refresh rate");

View File

@@ -9,7 +9,6 @@ import android.media.audiofx.DynamicsProcessing.Limiter;
import android.media.audiofx.Equalizer;
import android.net.Uri;
import android.os.Handler;
import android.util.TypedValue;
import android.view.View;
import android.widget.FrameLayout;
@@ -17,7 +16,6 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.core.graphics.TypefaceCompat;
import androidx.media3.common.AudioAttributes;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MediaItem;
@@ -39,7 +37,6 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector;
import androidx.media3.exoplayer.util.EventLogger;
import androidx.media3.extractor.DefaultExtractorsFactory;
import androidx.media3.extractor.ExtractorsFactory;
import androidx.media3.extractor.ts.TsExtractor;
import androidx.media3.ui.AspectRatioFrameLayout;
import androidx.media3.ui.CaptionStyleCompat;
@@ -60,13 +57,6 @@ import java.util.Collections;
import java.util.List;
import java.util.Optional;
import io.github.peerless2012.ass.media.AssHandler;
import io.github.peerless2012.ass.media.AssHandlerConfig;
import io.github.peerless2012.ass.media.factory.AssRenderersFactory;
import io.github.peerless2012.ass.media.kt.AssPlayerKt;
import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory;
import io.github.peerless2012.ass.media.type.AssRenderType;
import io.github.peerless2012.ass.media.widget.AssSubtitleView;
import timber.log.Timber;
@OptIn(markerClass = UnstableApi.class)
@@ -96,10 +86,7 @@ public class VideoManager {
_helper = helper;
nightModeEnabled = userPreferences.get(UserPreferences.Companion.getAudioNightMode());
boolean assDirectPlay = userPreferences.get(UserPreferences.Companion.getAssDirectPlay());
AssHandler assHandler = assDirectPlay ? new AssHandler(AssRenderType.OVERLAY_OPEN_GL, new AssHandlerConfig()) : null;
mExoPlayer = configureExoplayerBuilder(activity, assHandler).build();
mExoPlayer = configureExoplayerBuilder(activity).build();
if (userPreferences.get(UserPreferences.Companion.getDebuggingEnabled())) {
mExoPlayer.addAnalyticsListener(new EventLogger());
@@ -127,15 +114,10 @@ public class VideoManager {
strokeColor,
TypefaceCompat.create(activity, Typeface.DEFAULT, textWeight, false)
);
mExoPlayerView.getSubtitleView().setFixedTextSize(TypedValue.COMPLEX_UNIT_DIP, userPreferences.get(UserPreferences.Companion.getSubtitlesTextSize()));
mExoPlayerView.getSubtitleView().setFractionalTextSize(0.0533f * userPreferences.get(UserPreferences.Companion.getSubtitlesTextSize()));
mExoPlayerView.getSubtitleView().setBottomPaddingFraction(userPreferences.get(UserPreferences.Companion.getSubtitlesOffsetPosition()));
mExoPlayerView.getSubtitleView().setStyle(subtitleStyle);
if (assHandler != null) {
assHandler.init(mExoPlayer);
mExoPlayerView.getSubtitleView().addView(new AssSubtitleView(mActivity, assHandler));
}
mExoPlayer.addListener(new Player.Listener() {
@Override
public void onPlayerError(@NonNull PlaybackException error) {
@@ -214,7 +196,7 @@ public class VideoManager {
* @param context The associated context
* @return A configured builder for Exoplayer
*/
private ExoPlayer.Builder configureExoplayerBuilder(Context context, AssHandler assHandler) {
private ExoPlayer.Builder configureExoplayerBuilder(Context context) {
ExoPlayer.Builder exoPlayerBuilder = new ExoPlayer.Builder(context);
DefaultRenderersFactory defaultRendererFactory = new DefaultRenderersFactory(context);
defaultRendererFactory.setEnableDecoderFallback(true);
@@ -235,22 +217,8 @@ public class VideoManager {
extractorsFactory.setConstantBitrateSeekingEnabled(true);
extractorsFactory.setConstantBitrateSeekingAlwaysEnabled(true);
DefaultDataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(context, exoPlayerHttpDataSourceFactory);
if (assHandler != null) {
AssSubtitleParserFactory assSubtitleParserFactory = new AssSubtitleParserFactory(assHandler);
ExtractorsFactory assExtractorsFactory = AssPlayerKt.withAssMkvSupport(extractorsFactory, assSubtitleParserFactory, assHandler);
DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(dataSourceFactory, assExtractorsFactory);
mediaSourceFactory.setSubtitleParserFactory(assSubtitleParserFactory);
exoPlayerBuilder.setMediaSourceFactory(mediaSourceFactory);
exoPlayerBuilder.setRenderersFactory(new AssRenderersFactory(assHandler, defaultRendererFactory));
} else {
exoPlayerBuilder.setRenderersFactory(defaultRendererFactory);
exoPlayerBuilder.setMediaSourceFactory(new DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory));
}
exoPlayerBuilder.setAudioAttributes(new AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build(), true);
exoPlayerBuilder.setRenderersFactory(defaultRendererFactory);
exoPlayerBuilder.setMediaSourceFactory(new DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory));
return exoPlayerBuilder;
}

View File

@@ -241,11 +241,13 @@ class NextUpFragment : Fragment() {
container: ViewGroup?,
savedInstanceState: Bundle?,
) = content {
val id = remember(arguments) { arguments?.getString(ARGUMENT_ITEM_ID)?.toUUIDOrNull() }
if (id != null) {
NextUpScreen(
itemId = id,
)
JellyfinTheme {
val id = remember(arguments) { arguments?.getString(ARGUMENT_ITEM_ID)?.toUUIDOrNull() }
if (id != null) {
NextUpScreen(
itemId = id,
)
}
}
}
}

View File

@@ -380,10 +380,10 @@ public class CustomPlaybackTransportControlGlue extends PlaybackTransportControl
VideoPlayerAdapter playerAdapter = getPlayerAdapter();
if (playerAdapter.hasSubs() && keyCode == KeyEvent.KEYCODE_CAPTIONS) {
if (playerAdapter.hasSubs() && keyCode == KoinJavaComponent.<UserPreferences>get(UserPreferences.class).get(UserPreferences.Companion.getShortcutSubtitleTrack())) {
closedCaptionsAction.handleClickAction(playbackController, getPlayerAdapter(), getContext(), v);
}
if (playerAdapter.hasMultiAudio() && keyCode == KeyEvent.KEYCODE_MEDIA_AUDIO_TRACK) {
if (playerAdapter.hasMultiAudio() && keyCode == KoinJavaComponent.<UserPreferences>get(UserPreferences.class).get(UserPreferences.Companion.getShortcutAudioTrack())) {
selectAudioAction.handleClickAction(playbackController, getPlayerAdapter(), getContext(), v);
}
return super.onKey(v, keyCode, event);

View File

@@ -1,8 +1,6 @@
package org.jellyfin.androidtv.ui.playback.overlay
import android.content.Context
import android.graphics.Bitmap
import androidx.core.content.ContextCompat
import androidx.leanback.widget.PlaybackSeekDataProvider
import coil3.ImageLoader
import coil3.network.NetworkHeaders
@@ -14,7 +12,6 @@ import coil3.request.transformations
import coil3.size.Dimension
import coil3.size.Size
import coil3.toBitmap
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.util.coil.SubsetTransformation
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.trickplayApi
@@ -33,8 +30,6 @@ class CustomSeekProvider(
) : PlaybackSeekDataProvider() {
private val imageRequests = mutableMapOf<Int, Disposable>()
private var cachedPlaceholderThumbnail: Bitmap? = null
override fun getSeekPositions(): LongArray {
if (!videoPlayerAdapter.canSeek()) return LongArray(0)
@@ -77,8 +72,6 @@ class CustomSeekProvider(
mediaSourceId = mediaSourceId,
)
val placeholderThumbnail = getPlaceholderThumbnail(trickPlayInfo.width, trickPlayInfo.height)
imageRequests[index] = imageLoader.enqueue(ImageRequest.Builder(context).apply {
data(url)
size(Size.ORIGINAL)
@@ -99,8 +92,8 @@ class CustomSeekProvider(
transformations(SubsetTransformation(offsetX, offsetY, trickPlayInfo.width, trickPlayInfo.height))
target(
onStart = { _ -> callback.onThumbnailLoaded(placeholderThumbnail, index) },
onError = { _ -> callback.onThumbnailLoaded(placeholderThumbnail, index) },
onStart = { _ -> callback.onThumbnailLoaded(null, index) },
onError = { _ -> callback.onThumbnailLoaded(null, index) },
onSuccess = { image ->
val bitmap = image.toBitmap()
callback.onThumbnailLoaded(bitmap, index)
@@ -115,16 +108,4 @@ class CustomSeekProvider(
}
imageRequests.clear()
}
fun getPlaceholderThumbnail(width: Int, height: Int): Bitmap {
if (cachedPlaceholderThumbnail?.width == width && cachedPlaceholderThumbnail?.height == height) {
return cachedPlaceholderThumbnail!!
}
val color = ContextCompat.getColor(context, R.color.black_transparent_light)
val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
result.eraseColor(color)
cachedPlaceholderThumbnail = result
return result
}
}

View File

@@ -248,11 +248,13 @@ class StillWatchingFragment : Fragment() {
container: ViewGroup?,
savedInstanceState: Bundle?,
) = content {
val id = remember(arguments) { arguments?.getString(ARGUMENT_ITEM_ID)?.toUUIDOrNull() }
if (id != null) {
StillWatchingScreen(
itemId = id,
)
JellyfinTheme {
val id = remember(arguments) { arguments?.getString(ARGUMENT_ITEM_ID)?.toUUIDOrNull() }
if (id != null) {
StillWatchingScreen(
itemId = id,
)
}
}
}
}

View File

@@ -1,105 +0,0 @@
package org.jellyfin.androidtv.ui.player.base.toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import org.jellyfin.androidtv.ui.base.JellyfinTheme
import org.jellyfin.androidtv.ui.base.LocalTextStyle
import org.jellyfin.androidtv.ui.base.ProvideTextStyle
data class MediaToastColors(
val backgroundColor: Color,
val iconColor: Color,
val progressBackgroundColor: Color,
val progressFillColor: Color,
)
object MediaToastDefaults {
@ReadOnlyComposable
@Composable
fun colors(
backgroundColor: Color = Color.Black.copy(alpha = 0.5f),
iconColor: Color = Color.White,
progressBackgroundColor: Color = Color.Black,
progressFillColor: Color = JellyfinTheme.colorScheme.rangeControlFill,
) = MediaToastColors(
backgroundColor = backgroundColor,
iconColor = iconColor,
progressBackgroundColor = progressBackgroundColor,
progressFillColor = progressFillColor,
)
}
@Composable
fun MediaToast(
visible: Boolean,
icon: @Composable () -> Unit,
modifier: Modifier = Modifier,
progress: Float? = null,
colors: MediaToastColors = MediaToastDefaults.colors(),
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn() + scaleIn(initialScale = 0.8f),
exit = fadeOut() + scaleOut(targetScale = 1.2f),
modifier = modifier
.fillMaxSize()
.wrapContentSize(Alignment.Center)
) {
Box(
modifier = Modifier
.size(96.dp)
.drawWithContent {
// Toast background
drawCircle(colors.backgroundColor)
// Toast icon
drawContent()
// Toast progress
if (progress != null) {
val width = 4.dp.toPx()
// Background
drawCircle(
style = Stroke(width),
color = colors.progressBackgroundColor,
alpha = 0.4f,
)
// Foreground
drawArc(
style = Stroke(width, cap = StrokeCap.Round),
color = colors.progressFillColor,
useCenter = false,
startAngle = -90f,
sweepAngle = 360f * progress,
)
}
}
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
ProvideTextStyle(LocalTextStyle.current.copy(color = colors.iconColor)) {
icon()
}
}
}
}

View File

@@ -1,8 +0,0 @@
package org.jellyfin.androidtv.ui.player.base.toast
import androidx.annotation.DrawableRes
data class MediaToastData(
@DrawableRes val icon: Int,
val progress: Float? = null,
)

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