feat: android tv (#598)

* Add AddServerScreen

* Upgrade androidx-compose-material3 and androidx-compose-ui to alpha

* Add DiscoveredServerComponent

* Show discovered servers

* Add navigation using compose-destinations

* Implement Loginscreen

* Start of HomeScreen

* Use coil for home screen images and update layout with spacers

* Select correct startRoute based on conditions

* Upgrade compose material3 to 1.1.0-alpha05

* Add series title, max 1 line, padding

* Upgrade dependencies

* Switch to TvLazyColumn and TvLazyRow

* Add header to `HomeScreen`

* Add progress bar to Continue watching items

* Limit the number of lines under Movie or Show and use correct episode text

* chore: run ktlintFormat

* ci: assemble tv

* feat: `LibraryScreen`

* fix: update to reworked items system

* chore(deps): update androidx-paging-compose and compose-destination

Also fix lint issue on HomeScreen

* feat: start using androidx.material3 composables

* feat: add coil svg

* feat: experimenting with cards

* lint: fix linting issues

* feat: server select screen

* build: upgrade dependencies

* lint: run ktlintFormat

* feat(ServerSelectScreen): add "No servers found" text

* feat: update AddServerScreen

* feat: implement `UiText.asString()` composable

* lint: run ktlintFormat

* refactor(phone): remove livedata from `ServerSelectScreen`

* feat: add `UserSelectScreen`

* feat(UserSelectScreen): load user's profile picture

* feat: update LoginScreen

* feat: update progress indicator on `AddServerScreen`

* fix: change color of `ServerComponent`

* style(ServerSelect): use material typography

* chore: update ktlint config in build.gradle

* style: use material typography

* refactor: move home screen to separate layout function so it can be previewed

- Introduce dummy items
- Fix `UiText.asString()` composable

* refactor: preview `LibraryScreen`

* refactor: preview `ServerSelectScreen`

* refactor: preview `AddServerScreen`

* refactor: preview `UserSelectScreen`

* refactor: preview `LoginScreen`

* lint: run ktlintFormat

* feat: switch servers

* feat: main screen tabs navigation

* feat: add Live TV tab (not shown for now)

* chore: remove libraries from `HomeViewModel`

* chore: change colors of selected tab

* feat: new item card component

* chore: remove `HomeItem.Libraries`

* style: update spacing

* feat: add findroid icon and profile button on main screen

* style(main): add gradient background

* fix: use "latest" + library name in home screen

* fix: navigate from LoginScreen and UserSelectScreen to MainScreen

* style: update tab colors

* fix: remove reference to `HomeItem.Libraries` from `ViewListAdapter`

* chore: update kotlin compiler to 1.5.0

* feat: add horizontal item card variant

* feat: `LibrariesScreen`

* feat: `LibraryScreen`

* fix(`LibraryScreen`): remove hardcoded library name

* feat: `MovieScreen`

* feat(`MovieScreen`): Make trailer button work

Also hide the button when there is no trailer

* refactor(`MovieScreen`): move click logic out of layout

* refactor: create `FindroidImages` which holds all image uris

* build: upgrade androidx.tv to 1.0.0-alpha08

* chore: update compose libraries

* chore: update compose compiler to 1.5.1

* fix(ItemCard): only show progress when horizontal

* refactor: clean build.gradle.kts

* build: up minSdk to 28 and targetSdk to 34

* refactor: use spacings to provide paddings (#443)

* feat: add Spacings in MaterialTheme

* style: use MaterialTheme.spacings in layouts

Using sizes in Spacer & padding & PaddingValues to standardize it.

* fix: linting issues and a few paddings

* feat: use spacings in `LibraryScreen`

* feat: use spacings in `MovieScreen`

* fix: missing trailing comma

* refactor: replace hardcoded spacer in ItemCard with spacing

---------

Co-authored-by: Jarne Demeulemeester <jarnedemeulemeester@gmail.com>

* build: migrate to ksp and upgrade compose compiler

* build: upgrade compose libraries

* fix: align with main codebase

* chore: update agp to 8.1.3

* chore: update ksp and compose-destinations

ksp 1.9.10-1.0.13 -> 1.9.20-1.0.14
compose-destinations 1.9.51 -> 1.9.54

* refactor(UserSelectViewModel): use channel for events

* feat: basic video player

First implementation of the video player.
Uses the basic player view with no custom layout.
Only media keys are passed to the PlayerView.

* feat: show screen

Still a work in progress

* fix: make player background black

* fix(player): keep screen on

* feat: add border around focused tab

* lint: run ktlintFormat

* feat: focus improvements

* feat: logo for main screen

* fix: remember tab position

* feat: add loading indicator to main screen

And fix home and libraries screen list refresh on navigating back

* feat: add seasons to show screen

* feat: add season screen

* feat: add progress badge

* chore(deps) update dependencies

android-plugin 8.1.3 -> 8.1.4
androidx-activity 1.8.0 -> 1.8.1
androidx-media3 1.1.1 -> 1.2.0
coil 2.4.0 -> 2.5.0
kotlinx-serialization 1.6.0 -> 1.6.1

* ci: upload tv artifacts and don't build universal apks

* chore: get rid of deprecated android.defaults.buildfeatures.buildconfig

* build: upgrade dependencies

android-plugin 8.1.4 -> 8.2.0
androidx-room 2.6.0 -> 2.6.1
androidx-work 2.8.1 -> 2.9.0
jellyfin 1.4.5 -> 1.4.6

compose compiler 1.5.4 -> 1.5.5

* fix: workManagerConfiguration is now a property

* feat: add profile picture to main screen

* feat: start of settings screen

* refactor: base tv theme on normal compose material theme

* chore(deps): update kotlin to 1.9.21

kotlin 1.9.20 -> 1.9.21
ksp 1.9.20-1.0.14 -> 1.9.21-1.0.15
compose-compiler 1.5.5 -> 1.5.6

* feat(settings): add categories and pop backstack when navigating to main screen

* feat(settings): nested settings and switch setting

* feat(settings): settings select component

* feat(settings): icons for cache and about

* feat(settings): add option to toggle mpv player

* feat(settings): move preference value logic to viewmodel

* feat(settings): add dependencies

* chore: update compose compiler

compose-compiler 1.5.6 -> 1.5.7

* feat(settings): add settings detail select card

New sub settings screen with different layout
Settings detail select card to select an option

* feat: play episode from home screen

* feat(player): basic custom overlay

Courtesy of Android TV JetStreamCompose sample

* feat(player): add track selection dialog

* feat(player): add media session and clean up dpad events

* refactor(mpv): implement track selection via TrackSelectionParameters

Need to add ability to disable track type

* feat: implement watched and favorite buttons

* refactor: remove unused PreferenceType enum

---------
This commit is contained in:
Jarne Demeulemeester 2023-12-30 22:20:20 +01:00 committed by GitHub
parent 42650ee6c4
commit 7171ec72c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
113 changed files with 7504 additions and 489 deletions

View file

@ -40,11 +40,6 @@ jobs:
- name: Build with Gradle
run: ./gradlew assembleDebug
# Upload all build artifacts in separate steps. This can be shortened once https://github.com/actions/upload-artifact/pull/354 is merged.
- name: Upload artifact phone-libre-universal-debug.apk
uses: actions/upload-artifact@v3
with:
name: phone-libre-universal-debug.apk
path: ./app/phone/build/outputs/apk/libre/debug/phone-libre-universal-debug.apk
- name: Upload artifact phone-libre-arm64-v8a-debug.apk
uses: actions/upload-artifact@v3
with:
@ -65,3 +60,23 @@ jobs:
with:
name: phone-libre-x86-debug.apk
path: ./app/phone/build/outputs/apk/libre/debug/phone-libre-x86-debug.apk
- name: Upload artifact tv-libre-arm64-v8a-debug.apk
uses: actions/upload-artifact@v3
with:
name: tv-libre-arm64-v8a-debug.apk
path: ./app/tv/build/outputs/apk/libre/debug/tv-libre-arm64-v8a-debug.apk
- name: Upload artifact tv-libre-armeabi-v7a-debug.apk
uses: actions/upload-artifact@v3
with:
name: tv-libre-armeabi-v7a-debug.apk
path: ./app/tv/build/outputs/apk/libre/debug/tv-libre-armeabi-v7a-debug.apk
- name: Upload artifact tv-libre-x86_64-debug.apk
uses: actions/upload-artifact@v3
with:
name: tv-libre-x86_64-debug.apk
path: ./app/tv/build/outputs/apk/libre/debug/tv-libre-x86_64-debug.apk
- name: Upload artifact tv-libre-x86-debug.apk
uses: actions/upload-artifact@v3
with:
name: tv-libre-x86-debug.apk
path: ./app/tv/build/outputs/apk/libre/debug/tv-libre-x86-debug.apk

View file

@ -57,7 +57,6 @@ android {
isEnable = true
reset()
include("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
isUniversalApk = true
}
}
@ -67,6 +66,7 @@ android {
}
buildFeatures {
buildConfig = true
viewBinding = true
}
}

View file

@ -22,8 +22,8 @@ class BaseApplication : Application(), Configuration.Provider, ImageLoaderFactor
@Inject
lateinit var workerFactory: HiltWorkerFactory
override fun getWorkManagerConfiguration() =
Configuration.Builder()
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()

View file

@ -121,7 +121,6 @@ class ViewListAdapter(
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is HomeItem.OfflineCard -> ITEM_VIEW_TYPE_OFFLINE_CARD
is HomeItem.Libraries -> -1
is HomeItem.Section -> ITEM_VIEW_TYPE_NEXT_UP
is HomeItem.ViewItem -> ITEM_VIEW_TYPE_VIEW
}

View file

@ -35,6 +35,7 @@ import dev.jdtech.jellyfin.models.isDownloading
import dev.jdtech.jellyfin.utils.setIconTintColorAttribute
import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetEvent
import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.DateTime
@ -130,13 +131,15 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
}
}
}
}
}
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
when (playerItems) {
is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems)
launch {
playerViewModel.eventsChannelFlow.collect { event ->
when (event) {
is PlayerItemsEvent.PlayerItemsReady -> bindPlayerItems(event.items)
is PlayerItemsEvent.PlayerItemsError -> bindPlayerItemsError(event.error)
}
}
}
}
}
@ -145,13 +148,11 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
}
binding.itemActions.checkButton.setOnClickListener {
val played = viewModel.togglePlayed()
bindCheckButtonState(played)
viewModel.togglePlayed()
}
binding.itemActions.favoriteButton.setOnClickListener {
val favorite = viewModel.toggleFavorite()
bindFavoriteButtonState(favorite)
viewModel.toggleFavorite()
}
binding.itemActions.downloadButton.setOnClickListener {
@ -310,8 +311,8 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
binding.overview.text = uiState.error.message
}
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
navigateToPlayerActivity(items.items.toTypedArray())
private fun bindPlayerItems(items: List<PlayerItem>) {
navigateToPlayerActivity(items.toTypedArray())
binding.itemActions.playButton.setIconResource(CoreR.drawable.ic_play)
binding.itemActions.progressPlay.visibility = View.INVISIBLE
}
@ -347,12 +348,12 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
}
}
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
Timber.e(error.error.message)
private fun bindPlayerItemsError(error: Exception) {
Timber.e(error.message)
binding.playerItemsError.isVisible = true
playButtonNormal()
binding.playerItemsErrorDetails.setOnClickListener {
ErrorDialogFragment.newInstance(error.error).show(parentFragmentManager, ErrorDialogFragment.TAG)
ErrorDialogFragment.newInstance(error).show(parentFragmentManager, ErrorDialogFragment.TAG)
}
}

View file

@ -39,6 +39,7 @@ import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.utils.setIconTintColorAttribute
import dev.jdtech.jellyfin.viewmodels.MovieEvent
import dev.jdtech.jellyfin.viewmodels.MovieViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import kotlinx.coroutines.launch
import timber.log.Timber
@ -126,6 +127,15 @@ class MovieFragment : Fragment() {
}
}
}
launch {
playerViewModel.eventsChannelFlow.collect { event ->
when (event) {
is PlayerItemsEvent.PlayerItemsReady -> bindPlayerItems(event.items)
is PlayerItemsEvent.PlayerItemsError -> bindPlayerItemsError(event.error)
}
}
}
}
}
@ -137,13 +147,6 @@ class MovieFragment : Fragment() {
errorDialog.show(parentFragmentManager, ErrorDialogFragment.TAG)
}
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
when (playerItems) {
is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems)
}
}
binding.itemActions.playButton.setOnClickListener {
binding.itemActions.playButton.isEnabled = false
binding.itemActions.playButton.setIconResource(android.R.color.transparent)
@ -180,13 +183,11 @@ class MovieFragment : Fragment() {
}
binding.itemActions.checkButton.setOnClickListener {
val played = viewModel.togglePlayed()
bindCheckButtonState(played)
viewModel.togglePlayed()
}
binding.itemActions.favoriteButton.setOnClickListener {
val favorite = viewModel.toggleFavorite()
bindFavoriteButtonState(favorite)
viewModel.toggleFavorite()
}
binding.itemActions.downloadButton.setOnClickListener {
@ -433,18 +434,18 @@ class MovieFragment : Fragment() {
}
}
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
navigateToPlayerActivity(items.items.toTypedArray())
private fun bindPlayerItems(items: List<PlayerItem>) {
navigateToPlayerActivity(items.toTypedArray())
binding.itemActions.playButton.setIconResource(CoreR.drawable.ic_play)
binding.itemActions.progressPlay.visibility = View.INVISIBLE
}
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
Timber.e(error.error.message)
private fun bindPlayerItemsError(error: Exception) {
Timber.e(error.message)
binding.playerItemsError.visibility = View.VISIBLE
playButtonNormal()
binding.playerItemsErrorDetails.setOnClickListener {
ErrorDialogFragment.newInstance(error.error)
ErrorDialogFragment.newInstance(error)
.show(parentFragmentManager, ErrorDialogFragment.TAG)
}
}

View file

@ -17,9 +17,7 @@ import dev.jdtech.jellyfin.adapters.EpisodeListAdapter
import dev.jdtech.jellyfin.databinding.FragmentSeasonBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.models.FindroidEpisode
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import dev.jdtech.jellyfin.viewmodels.SeasonEvent
import dev.jdtech.jellyfin.viewmodels.SeasonViewModel
import kotlinx.coroutines.launch
@ -30,7 +28,6 @@ class SeasonFragment : Fragment() {
private lateinit var binding: FragmentSeasonBinding
private val viewModel: SeasonViewModel by viewModels()
private val playerViewModel: PlayerViewModel by viewModels()
private val args: SeasonFragmentArgs by navArgs()
private lateinit var errorDialog: ErrorDialogFragment
@ -74,15 +71,6 @@ class SeasonFragment : Fragment() {
viewModel.loadEpisodes(args.seriesId, args.seasonId, args.offline)
}
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
when (playerItems) {
is PlayerViewModel.PlayerItems -> {
navigateToPlayerActivity(playerItems.items.toTypedArray())
}
is PlayerViewModel.PlayerItemError -> {}
}
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
errorDialog.show(parentFragmentManager, ErrorDialogFragment.TAG)
}
@ -129,14 +117,4 @@ class SeasonFragment : Fragment() {
),
)
}
private fun navigateToPlayerActivity(
playerItems: Array<PlayerItem>,
) {
findNavController().navigate(
SeasonFragmentDirections.actionSeasonFragmentToPlayerActivity(
playerItems,
),
)
}
}

View file

@ -31,6 +31,7 @@ import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.models.isDownloaded
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.utils.setIconTintColorAttribute
import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import dev.jdtech.jellyfin.viewmodels.ShowEvent
import dev.jdtech.jellyfin.viewmodels.ShowViewModel
@ -86,6 +87,15 @@ class ShowFragment : Fragment() {
}
}
}
launch {
playerViewModel.eventsChannelFlow.collect { event ->
when (event) {
is PlayerItemsEvent.PlayerItemsReady -> bindPlayerItems(event.items)
is PlayerItemsEvent.PlayerItemsError -> bindPlayerItemsError(event.error)
}
}
}
}
}
@ -96,13 +106,6 @@ class ShowFragment : Fragment() {
viewModel.loadData(args.itemId, args.offline)
}
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
when (playerItems) {
is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems)
}
}
binding.itemActions.trailerButton.setOnClickListener {
viewModel.item.trailer.let { trailerUri ->
val intent = Intent(
@ -144,13 +147,11 @@ class ShowFragment : Fragment() {
}
binding.itemActions.checkButton.setOnClickListener {
val played = viewModel.togglePlayed()
bindCheckButtonState(played)
viewModel.togglePlayed()
}
binding.itemActions.favoriteButton.setOnClickListener {
val favorite = viewModel.toggleFavorite()
bindFavoriteButtonState(favorite)
viewModel.toggleFavorite()
}
}
@ -290,18 +291,18 @@ class ShowFragment : Fragment() {
}
}
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
navigateToPlayerActivity(items.items.toTypedArray())
private fun bindPlayerItems(items: List<PlayerItem>) {
navigateToPlayerActivity(items.toTypedArray())
binding.itemActions.playButton.setIconResource(CoreR.drawable.ic_play)
binding.itemActions.progressPlay.visibility = View.INVISIBLE
}
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
Timber.e(error.error.message)
private fun bindPlayerItemsError(error: Exception) {
Timber.e(error.message)
binding.playerItemsError.visibility = View.VISIBLE
playButtonNormal()
binding.playerItemsErrorDetails.setOnClickListener {
ErrorDialogFragment.newInstance(error.error)
ErrorDialogFragment.newInstance(error)
.show(parentFragmentManager, ErrorDialogFragment.TAG)
}
}

112
app/tv/build.gradle.kts Normal file
View file

@ -0,0 +1,112 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
alias(libs.plugins.ktlint)
}
android {
namespace = "dev.jdtech.jellyfin"
compileSdk = Versions.compileSdk
buildToolsVersion = Versions.buildTools
defaultConfig {
applicationId = "dev.jdtech.jellyfin"
minSdk = Versions.minSdk
targetSdk = Versions.targetSdk
versionCode = Versions.appCode
versionName = Versions.appName
}
buildTypes {
named("debug") {
applicationIdSuffix = ".debug"
}
named("release") {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
register("staging") {
initWith(getByName("release"))
applicationIdSuffix = ".staging"
}
}
flavorDimensions += "variant"
productFlavors {
register("libre") {
dimension = "variant"
isDefault = true
}
}
splits {
abi {
isEnable = true
reset()
include("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
}
}
compileOptions {
sourceCompatibility = Versions.java
targetCompatibility = Versions.java
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = Versions.composeCompiler
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
ktlint {
version.set(Versions.ktlint)
android.set(true)
ignoreFailures.set(false)
}
dependencies {
implementation(project(":core"))
implementation(project(":data"))
implementation(project(":preferences"))
implementation(project(":player:core"))
implementation(project(":player:video"))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.core)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.androidx.media3.session)
implementation(libs.androidx.paging.compose)
implementation(libs.coil.compose)
implementation(libs.coil.svg)
implementation(libs.compose.destinations.core)
ksp(libs.compose.destinations.ksp)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.jellyfin.core)
implementation(libs.androidx.tv.foundation)
implementation(libs.androidx.tv.material)
debugImplementation(libs.androidx.compose.ui.tooling)
}

30
app/tv/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,30 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# These classes are from okhttp and are not used in Android
-dontwarn org.bouncycastle.jsse.BCSSLSocket
-dontwarn org.bouncycastle.jsse.BCSSLParameters
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
-dontwarn org.conscrypt.*
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
-dontwarn org.openjsse.net.ssl.OpenJSSE

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature
android:name="android.software.leanback"
android:required="true" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<application
android:name=".BaseApplication"
android:allowBackup="true"
android:banner="@mipmap/ic_banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Findroid">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Findroid">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".PlayerActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:screenOrientation="sensorLandscape" />
</application>
</manifest>

View file

@ -0,0 +1,18 @@
package dev.jdtech.jellyfin
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.SvgDecoder
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class BaseApplication : Application(), ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.components {
add(SvgDecoder.Factory())
}
.build()
}
}

View file

@ -0,0 +1,90 @@
package dev.jdtech.jellyfin
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.NonInteractiveSurfaceDefaults
import androidx.tv.material3.Surface
import com.ramcosta.composedestinations.DestinationsNavHost
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.database.ServerDatabaseDao
import dev.jdtech.jellyfin.destinations.AddServerScreenDestination
import dev.jdtech.jellyfin.destinations.LoginScreenDestination
import dev.jdtech.jellyfin.destinations.ServerSelectScreenDestination
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.viewmodels.MainViewModel
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
@Inject
lateinit var database: ServerDatabaseDao
@Inject
lateinit var appPreferences: AppPreferences
@OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
var startRoute = NavGraphs.root.startRoute
if (checkServersEmpty()) {
startRoute = AddServerScreenDestination
} else if (checkUser()) {
startRoute = LoginScreenDestination
}
// TODO remove temp always show server selection screen
startRoute = ServerSelectScreenDestination
setContent {
FindroidTheme {
Surface(
colors = NonInteractiveSurfaceDefaults.colors(
containerColor = MaterialTheme.colorScheme.background,
),
shape = RectangleShape,
modifier = Modifier.fillMaxSize(),
) {
DestinationsNavHost(
navGraph = NavGraphs.root,
startRoute = startRoute,
)
}
}
}
}
private fun checkServersEmpty(): Boolean {
if (!viewModel.startDestinationChanged) {
val nServers = database.getServersCount()
if (nServers < 1) {
viewModel.startDestinationChanged = true
return true
}
}
return false
}
private fun checkUser(): Boolean {
if (!viewModel.startDestinationChanged) {
appPreferences.currentServer?.let {
val currentUser = database.getServerCurrentUser(it)
if (currentUser == null) {
viewModel.startDestinationChanged = true
return true
}
}
}
return false
}
}

View file

@ -0,0 +1,52 @@
package dev.jdtech.jellyfin
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.annotation.ActivityDestination
import com.ramcosta.composedestinations.manualcomposablecalls.composable
import com.ramcosta.composedestinations.scope.resultRecipient
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.destinations.PlayerActivityDestination
import dev.jdtech.jellyfin.destinations.PlayerScreenDestination
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.ui.PlayerScreen
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
data class PlayerActivityNavArgs(
val items: ArrayList<PlayerItem>,
)
@AndroidEntryPoint
@ActivityDestination(
navArgsDelegate = PlayerActivityNavArgs::class,
)
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
class PlayerActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val args = PlayerActivityDestination.argsFrom(intent)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
setContent {
FindroidTheme {
DestinationsNavHost(
navGraph = NavGraphs.root,
startRoute = PlayerScreenDestination,
) {
composable(PlayerScreenDestination) {
PlayerScreen(
navigator = destinationsNavigator,
items = args.items,
resultRecipient = resultRecipient(),
)
}
}
}
}
}
}

View file

@ -0,0 +1,194 @@
package dev.jdtech.jellyfin.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.material3.Button
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import dev.jdtech.jellyfin.destinations.LoginScreenDestination
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
import dev.jdtech.jellyfin.utils.ObserveAsEvents
import dev.jdtech.jellyfin.viewmodels.AddServerEvent
import dev.jdtech.jellyfin.viewmodels.AddServerViewModel
import dev.jdtech.jellyfin.core.R as CoreR
@Destination
@Composable
fun AddServerScreen(
navigator: DestinationsNavigator,
addServerViewModel: AddServerViewModel = hiltViewModel(),
) {
val uiState by addServerViewModel.uiState.collectAsState()
ObserveAsEvents(addServerViewModel.eventsChannelFlow) { event ->
when (event) {
is AddServerEvent.NavigateToLogin -> {
navigator.navigate(LoginScreenDestination)
}
}
}
AddServerScreenLayout(
uiState = uiState,
onConnectClick = { serverAddress ->
addServerViewModel.checkServer(serverAddress)
},
)
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun AddServerScreenLayout(
uiState: AddServerViewModel.UiState,
onConnectClick: (String) -> Unit,
) {
var serverAddress by rememberSaveable {
mutableStateOf("")
}
val isError = uiState is AddServerViewModel.UiState.Error
val isLoading = uiState is AddServerViewModel.UiState.Loading
val context = LocalContext.current
val focusRequester = remember { FocusRequester() }
Box(
modifier = Modifier
.fillMaxSize()
.background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center),
) {
Text(
text = stringResource(id = CoreR.string.add_server),
style = MaterialTheme.typography.displayMedium,
)
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
OutlinedTextField(
value = serverAddress,
leadingIcon = {
Icon(
painter = painterResource(id = CoreR.drawable.ic_server),
contentDescription = null,
)
},
onValueChange = { serverAddress = it },
label = {
Text(
text = stringResource(id = CoreR.string.edit_text_server_address_hint),
)
},
singleLine = true,
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go,
),
isError = isError,
enabled = !isLoading,
supportingText = {
if (isError) {
Text(
text = (uiState as AddServerViewModel.UiState.Error).message.joinToString {
it.asString(
context.resources,
)
},
color = MaterialTheme.colorScheme.error,
)
}
},
modifier = Modifier
.width(360.dp)
.focusRequester(focusRequester),
)
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
Box {
Button(
onClick = {
onConnectClick(serverAddress)
},
enabled = !isLoading,
modifier = Modifier.width(360.dp),
) {
Box(
modifier = Modifier.fillMaxWidth(),
) {
if (isLoading) {
CircularProgressIndicator(
color = LocalContentColor.current,
modifier = Modifier
.size(24.dp)
.align(Alignment.CenterStart),
)
}
Text(
text = stringResource(id = CoreR.string.button_connect),
modifier = Modifier.align(Alignment.Center),
)
}
}
}
}
}
LaunchedEffect(true) {
focusRequester.requestFocus()
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview(widthDp = 960, heightDp = 540)
@Composable
private fun AddServerScreenLayoutPreview() {
FindroidTheme {
Surface {
AddServerScreenLayout(
uiState = AddServerViewModel.UiState.Normal,
onConnectClick = {},
)
}
}
}

View file

@ -0,0 +1,212 @@
package dev.jdtech.jellyfin.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.foundation.lazy.list.TvLazyColumn
import androidx.tv.foundation.lazy.list.TvLazyRow
import androidx.tv.foundation.lazy.list.items
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import dev.jdtech.jellyfin.destinations.MovieScreenDestination
import dev.jdtech.jellyfin.destinations.PlayerActivityDestination
import dev.jdtech.jellyfin.destinations.ShowScreenDestination
import dev.jdtech.jellyfin.models.FindroidEpisode
import dev.jdtech.jellyfin.models.FindroidItem
import dev.jdtech.jellyfin.models.FindroidMovie
import dev.jdtech.jellyfin.models.FindroidShow
import dev.jdtech.jellyfin.models.HomeItem
import dev.jdtech.jellyfin.ui.components.Direction
import dev.jdtech.jellyfin.ui.components.ItemCard
import dev.jdtech.jellyfin.ui.dummy.dummyHomeItems
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
import dev.jdtech.jellyfin.utils.ObserveAsEvents
import dev.jdtech.jellyfin.viewmodels.HomeViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import dev.jdtech.jellyfin.core.R as CoreR
@Destination
@Composable
fun HomeScreen(
navigator: DestinationsNavigator,
homeViewModel: HomeViewModel = hiltViewModel(),
playerViewModel: PlayerViewModel = hiltViewModel(),
isLoading: (Boolean) -> Unit,
) {
LaunchedEffect(key1 = true) {
homeViewModel.loadData()
}
ObserveAsEvents(playerViewModel.eventsChannelFlow) { event ->
when (event) {
is PlayerItemsEvent.PlayerItemsReady -> {
navigator.navigate(PlayerActivityDestination(items = ArrayList(event.items)))
}
is PlayerItemsEvent.PlayerItemsError -> Unit
}
}
val delegatedUiState by homeViewModel.uiState.collectAsState()
HomeScreenLayout(
uiState = delegatedUiState,
isLoading = isLoading,
onClick = { item ->
when (item) {
is FindroidMovie -> {
navigator.navigate(MovieScreenDestination(item.id))
}
is FindroidShow -> {
navigator.navigate(ShowScreenDestination(item.id))
}
is FindroidEpisode -> {
playerViewModel.loadPlayerItems(item = item)
}
}
},
)
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun HomeScreenLayout(
uiState: HomeViewModel.UiState,
isLoading: (Boolean) -> Unit,
onClick: (FindroidItem) -> Unit,
) {
var homeItems: List<HomeItem> by remember { mutableStateOf(emptyList()) }
val focusRequester = remember { FocusRequester() }
when (uiState) {
is HomeViewModel.UiState.Normal -> {
homeItems = uiState.homeItems
isLoading(false)
}
is HomeViewModel.UiState.Loading -> {
isLoading(true)
}
else -> Unit
}
TvLazyColumn(
contentPadding = PaddingValues(bottom = MaterialTheme.spacings.large),
modifier = Modifier
.fillMaxSize()
.focusRequester(focusRequester),
) {
items(homeItems, key = { it.id }) { homeItem ->
when (homeItem) {
is HomeItem.Section -> {
Text(
text = homeItem.homeSection.name.asString(),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(start = MaterialTheme.spacings.large),
)
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
TvLazyRow(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.large),
) {
items(homeItem.homeSection.items, key = { it.id }) { item ->
ItemCard(
item = item,
direction = Direction.HORIZONTAL,
onClick = {
onClick(it)
},
)
}
}
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
}
is HomeItem.ViewItem -> {
Text(
text = stringResource(id = CoreR.string.latest_library, homeItem.view.name),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(start = MaterialTheme.spacings.large),
)
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
TvLazyRow(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.large),
) {
items(homeItem.view.items.orEmpty(), key = { it.id }) { item ->
ItemCard(
item = item,
direction = Direction.VERTICAL,
onClick = {
onClick(it)
},
)
}
}
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
}
else -> Unit
}
}
}
LaunchedEffect(homeItems) {
focusRequester.requestFocus()
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview(widthDp = 960, heightDp = 540)
@Composable
private fun HomeScreenLayoutPreview() {
FindroidTheme {
Surface {
HomeScreenLayout(
uiState = HomeViewModel.UiState.Normal(dummyHomeItems),
isLoading = {},
onClick = {},
)
}
}
}
@Preview(showBackground = true)
@Composable
fun Header(modifier: Modifier = Modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.height(80.dp),
) {
Image(
painter = painterResource(id = CoreR.drawable.ic_banner),
contentDescription = null,
modifier = Modifier.height(40.dp),
)
}
}

View file

@ -0,0 +1,118 @@
package dev.jdtech.jellyfin.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.foundation.lazy.grid.TvGridCells
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
import androidx.tv.foundation.lazy.grid.items
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import dev.jdtech.jellyfin.destinations.LibraryScreenDestination
import dev.jdtech.jellyfin.models.CollectionType
import dev.jdtech.jellyfin.models.FindroidCollection
import dev.jdtech.jellyfin.ui.components.Direction
import dev.jdtech.jellyfin.ui.components.ItemCard
import dev.jdtech.jellyfin.ui.dummy.dummyCollections
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
import dev.jdtech.jellyfin.viewmodels.MediaViewModel
import java.util.UUID
@Destination
@Composable
fun LibrariesScreen(
navigator: DestinationsNavigator,
isLoading: (Boolean) -> Unit,
mediaViewModel: MediaViewModel = hiltViewModel(),
) {
val delegatedUiState by mediaViewModel.uiState.collectAsState()
LibrariesScreenLayout(
uiState = delegatedUiState,
isLoading = isLoading,
onClick = { libraryId, libraryName, libraryType ->
navigator.navigate(LibraryScreenDestination(libraryId, libraryName, libraryType))
},
)
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun LibrariesScreenLayout(
uiState: MediaViewModel.UiState,
isLoading: (Boolean) -> Unit,
onClick: (UUID, String, CollectionType) -> Unit,
) {
var collections: List<FindroidCollection> by remember {
mutableStateOf(emptyList())
}
when (uiState) {
is MediaViewModel.UiState.Normal -> {
collections = uiState.collections
isLoading(false)
}
is MediaViewModel.UiState.Loading -> {
isLoading(true)
}
else -> Unit
}
val focusRequester = remember { FocusRequester() }
TvLazyVerticalGrid(
columns = TvGridCells.Fixed(3),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large),
contentPadding = PaddingValues(
start = MaterialTheme.spacings.large,
top = MaterialTheme.spacings.small,
end = MaterialTheme.spacings.large,
bottom = MaterialTheme.spacings.large,
),
modifier = Modifier.focusRequester(focusRequester),
) {
items(collections, key = { it.id }) { collection ->
ItemCard(
item = collection,
direction = Direction.HORIZONTAL,
onClick = {
onClick(collection.id, collection.name, collection.type)
},
)
}
}
LaunchedEffect(collections) {
focusRequester.requestFocus()
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview(widthDp = 960, heightDp = 540)
@Composable
private fun LibrariesScreenLayoutPreview() {
FindroidTheme {
Surface {
LibrariesScreenLayout(
uiState = MediaViewModel.UiState.Normal(dummyCollections),
isLoading = {},
onClick = { _, _, _ -> },
)
}
}
}

View file

@ -0,0 +1,143 @@
package dev.jdtech.jellyfin.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.tv.foundation.lazy.grid.TvGridCells
import androidx.tv.foundation.lazy.grid.TvGridItemSpan
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import dev.jdtech.jellyfin.destinations.MovieScreenDestination
import dev.jdtech.jellyfin.destinations.ShowScreenDestination
import dev.jdtech.jellyfin.models.CollectionType
import dev.jdtech.jellyfin.models.FindroidItem
import dev.jdtech.jellyfin.models.FindroidMovie
import dev.jdtech.jellyfin.models.FindroidShow
import dev.jdtech.jellyfin.ui.components.Direction
import dev.jdtech.jellyfin.ui.components.ItemCard
import dev.jdtech.jellyfin.ui.dummy.dummyMovies
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
import dev.jdtech.jellyfin.viewmodels.LibraryViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import java.util.UUID
@Destination
@Composable
fun LibraryScreen(
navigator: DestinationsNavigator,
libraryId: UUID,
libraryName: String,
libraryType: CollectionType,
libraryViewModel: LibraryViewModel = hiltViewModel(),
) {
LaunchedEffect(true) {
libraryViewModel.loadItems(libraryId, libraryType)
}
val delegatedUiState by libraryViewModel.uiState.collectAsState()
LibraryScreenLayout(
libraryName = libraryName,
uiState = delegatedUiState,
onClick = { item ->
when (item) {
is FindroidMovie -> {
navigator.navigate(MovieScreenDestination(item.id))
}
is FindroidShow -> {
navigator.navigate(ShowScreenDestination(item.id))
}
}
},
)
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun LibraryScreenLayout(
libraryName: String,
uiState: LibraryViewModel.UiState,
onClick: (FindroidItem) -> Unit,
) {
val focusRequester = remember { FocusRequester() }
when (uiState) {
is LibraryViewModel.UiState.Loading -> Text(text = "LOADING")
is LibraryViewModel.UiState.Normal -> {
val items = uiState.items.collectAsLazyPagingItems()
TvLazyVerticalGrid(
columns = TvGridCells.Fixed(5),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.default * 2, vertical = MaterialTheme.spacings.large),
modifier = Modifier
.fillMaxSize()
.background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721))))
.focusRequester(focusRequester),
) {
item(span = { TvGridItemSpan(this.maxLineSpan) }) {
Text(
text = libraryName,
style = MaterialTheme.typography.displayMedium,
)
}
items(items.itemCount) { i ->
val item = items[i]
item?.let {
ItemCard(
item = item,
direction = Direction.VERTICAL,
onClick = {
onClick(item)
},
)
}
}
}
LaunchedEffect(items.itemCount > 0) {
if (items.itemCount > 0) {
focusRequester.requestFocus()
}
}
}
is LibraryViewModel.UiState.Error -> Text(text = uiState.error.toString())
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview(widthDp = 960, heightDp = 540)
@Composable
private fun LibraryScreenLayoutPreview() {
val data: Flow<PagingData<FindroidItem>> = flowOf(PagingData.from(dummyMovies))
FindroidTheme {
Surface {
LibraryScreenLayout(
libraryName = "Movies",
uiState = LibraryViewModel.UiState.Normal(data),
onClick = {},
)
}
}
}

View file

@ -0,0 +1,287 @@
package dev.jdtech.jellyfin.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.material3.Button
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.OutlinedButton
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.popUpTo
import dev.jdtech.jellyfin.NavGraphs
import dev.jdtech.jellyfin.destinations.MainScreenDestination
import dev.jdtech.jellyfin.models.UiText
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
import dev.jdtech.jellyfin.utils.ObserveAsEvents
import dev.jdtech.jellyfin.viewmodels.LoginEvent
import dev.jdtech.jellyfin.viewmodels.LoginViewModel
import dev.jdtech.jellyfin.core.R as CoreR
@Destination
@Composable
fun LoginScreen(
navigator: DestinationsNavigator,
loginViewModel: LoginViewModel = hiltViewModel(),
) {
val delegatedUiState by loginViewModel.uiState.collectAsState()
val delegatedQuickConnectUiState by loginViewModel.quickConnectUiState.collectAsState(
initial = LoginViewModel.QuickConnectUiState.Disabled,
)
ObserveAsEvents(loginViewModel.eventsChannelFlow) { event ->
when (event) {
is LoginEvent.NavigateToHome -> {
navigator.navigate(MainScreenDestination) {
popUpTo(NavGraphs.root) {
inclusive = true
}
}
}
}
}
LoginScreenLayout(
uiState = delegatedUiState,
quickConnectUiState = delegatedQuickConnectUiState,
onLoginClick = { username, password ->
loginViewModel.login(username, password)
},
onQuickConnectClick = {
loginViewModel.useQuickConnect()
},
)
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun LoginScreenLayout(
uiState: LoginViewModel.UiState,
quickConnectUiState: LoginViewModel.QuickConnectUiState,
onLoginClick: (String, String) -> Unit,
onQuickConnectClick: () -> Unit,
) {
var username by rememberSaveable {
mutableStateOf("")
}
var password by rememberSaveable {
mutableStateOf("")
}
var quickConnectValue = stringResource(id = CoreR.string.quick_connect)
when (quickConnectUiState) {
is LoginViewModel.QuickConnectUiState.Waiting -> {
quickConnectValue = quickConnectUiState.code
}
else -> Unit
}
val isError = uiState is LoginViewModel.UiState.Error
val isLoading = uiState is LoginViewModel.UiState.Loading
val quickConnectEnabled = quickConnectUiState !is LoginViewModel.QuickConnectUiState.Disabled
val isWaiting = quickConnectUiState is LoginViewModel.QuickConnectUiState.Waiting
val focusRequester = remember { FocusRequester() }
Box(
modifier = Modifier
.fillMaxSize()
.background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center),
) {
Text(
text = stringResource(id = CoreR.string.login),
style = MaterialTheme.typography.displayMedium,
)
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
OutlinedTextField(
value = username,
leadingIcon = {
Icon(
painter = painterResource(id = CoreR.drawable.ic_user),
contentDescription = null,
)
},
onValueChange = { username = it },
label = { Text(text = stringResource(id = CoreR.string.edit_text_username_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
),
isError = isError,
enabled = !isLoading,
modifier = Modifier
.width(360.dp)
.focusRequester(focusRequester),
)
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
OutlinedTextField(
value = password,
leadingIcon = {
Icon(
painter = painterResource(id = CoreR.drawable.ic_lock),
contentDescription = null,
)
},
onValueChange = { password = it },
label = { Text(text = stringResource(id = CoreR.string.edit_text_password_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
visualTransformation = PasswordVisualTransformation(),
isError = isError,
enabled = !isLoading,
supportingText = {
if (isError) {
Text(
text = (uiState as LoginViewModel.UiState.Error).message.asString(),
color = MaterialTheme.colorScheme.error,
)
}
},
modifier = Modifier
.width(360.dp),
)
Spacer(modifier = Modifier.height(MaterialTheme.spacings.default))
Box {
Button(
onClick = {
onLoginClick(username, password)
},
enabled = !isLoading,
modifier = Modifier.width(360.dp),
) {
Box(
modifier = Modifier.fillMaxWidth(),
) {
if (isLoading) {
CircularProgressIndicator(
color = LocalContentColor.current,
modifier = Modifier
.size(24.dp)
.align(Alignment.CenterStart),
)
}
Text(
text = stringResource(id = CoreR.string.button_login),
modifier = Modifier.align(Alignment.Center),
)
}
}
}
if (quickConnectEnabled) {
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
Box {
OutlinedButton(
onClick = {
onQuickConnectClick()
},
modifier = Modifier.width(360.dp),
) {
Box(
modifier = Modifier.fillMaxWidth(),
) {
if (isWaiting) {
CircularProgressIndicator(
color = LocalContentColor.current,
modifier = Modifier
.size(24.dp)
.align(Alignment.CenterStart),
)
}
Text(
text = quickConnectValue,
modifier = Modifier.align(Alignment.Center),
)
}
}
}
}
}
}
LaunchedEffect(true) {
focusRequester.requestFocus()
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview(widthDp = 960, heightDp = 540)
@Composable
private fun LoginScreenLayoutPreview() {
FindroidTheme {
Surface {
LoginScreenLayout(
uiState = LoginViewModel.UiState.Normal,
quickConnectUiState = LoginViewModel.QuickConnectUiState.Normal,
onLoginClick = { _, _ -> },
onQuickConnectClick = {},
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview(widthDp = 960, heightDp = 540)
@Composable
private fun LoginScreenLayoutPreviewError() {
FindroidTheme {
Surface {
LoginScreenLayout(
uiState = LoginViewModel.UiState.Error(UiText.DynamicString("Invalid username or password")),
quickConnectUiState = LoginViewModel.QuickConnectUiState.Normal,
onLoginClick = { _, _ -> },
onQuickConnectClick = {},
)
}
}
}

View file

@ -0,0 +1,210 @@
package dev.jdtech.jellyfin.ui
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Tab
import androidx.tv.material3.TabDefaults
import androidx.tv.material3.TabRow
import androidx.tv.material3.TabRowDefaults
import androidx.tv.material3.Text
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
import dev.jdtech.jellyfin.destinations.SettingsScreenDestination
import dev.jdtech.jellyfin.models.User
import dev.jdtech.jellyfin.ui.components.LoadingIndicator
import dev.jdtech.jellyfin.ui.components.PillBorderIndicator
import dev.jdtech.jellyfin.ui.components.ProfileButton
import dev.jdtech.jellyfin.ui.dummy.dummyServer
import dev.jdtech.jellyfin.ui.dummy.dummyUser
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
import dev.jdtech.jellyfin.viewmodels.MainViewModel
import dev.jdtech.jellyfin.core.R as CoreR
@RootNavGraph(start = true)
@Destination
@Composable
fun MainScreen(
mainViewModel: MainViewModel = hiltViewModel(),
navigator: DestinationsNavigator,
) {
val delegatedUiState by mainViewModel.uiState.collectAsState()
MainScreenLayout(
uiState = delegatedUiState,
navigator = navigator,
)
}
enum class TabDestination(
@DrawableRes val icon: Int,
@StringRes val label: Int,
) {
Search(CoreR.drawable.ic_search, CoreR.string.search),
Home(CoreR.drawable.ic_home, CoreR.string.title_home),
Libraries(CoreR.drawable.ic_library, CoreR.string.libraries),
// LiveTV(CoreR.drawable.ic_tv, CoreR.string.live_tv)
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun MainScreenLayout(
uiState: MainViewModel.UiState,
navigator: DestinationsNavigator,
) {
var focusedTabIndex by rememberSaveable { mutableIntStateOf(1) }
var activeTabIndex by rememberSaveable { mutableIntStateOf(focusedTabIndex) }
var isLoading by remember { mutableStateOf(false) }
var user: User? = null
when (uiState) {
is MainViewModel.UiState.Normal -> {
user = uiState.user
}
else -> Unit
}
Column(
modifier = Modifier
.fillMaxSize()
.background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.padding(horizontal = MaterialTheme.spacings.default),
) {
Icon(
painter = painterResource(id = CoreR.drawable.ic_logo),
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier
.size(32.dp)
.align(Alignment.CenterStart),
)
TabRow(
selectedTabIndex = focusedTabIndex,
indicator = { tabPositions, isActivated ->
// FocusedTab's indicator
PillBorderIndicator(
currentTabPosition = tabPositions[focusedTabIndex],
activeBorderColor = Color.White,
inactiveBorderColor = Color.Transparent,
doesTabRowHaveFocus = isActivated,
)
// SelectedTab's indicator
TabRowDefaults.PillIndicator(
currentTabPosition = tabPositions[activeTabIndex],
activeColor = Color.White,
inactiveColor = Color.White,
doesTabRowHaveFocus = isActivated,
)
},
modifier = Modifier.align(Alignment.Center),
) {
TabDestination.entries.forEachIndexed { index, tab ->
Tab(
selected = activeTabIndex == index,
onFocus = { focusedTabIndex = index },
colors = TabDefaults.pillIndicatorTabColors(
contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f),
selectedContentColor = MaterialTheme.colorScheme.onPrimary,
focusedContentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f),
focusedSelectedContentColor = MaterialTheme.colorScheme.onPrimary,
),
onClick = {
focusedTabIndex = index
activeTabIndex = index
},
modifier = Modifier.padding(horizontal = MaterialTheme.spacings.default / 2, vertical = MaterialTheme.spacings.small),
) {
Icon(
painter = painterResource(id = tab.icon),
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(MaterialTheme.spacings.extraSmall))
Text(
text = stringResource(id = tab.label),
style = MaterialTheme.typography.titleSmall,
)
}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium),
modifier = Modifier.align(Alignment.CenterEnd),
) {
if (isLoading) {
LoadingIndicator()
}
ProfileButton(
user = user,
onClick = {
navigator.navigate(SettingsScreenDestination())
},
)
}
}
when (activeTabIndex) {
1 -> {
HomeScreen(navigator = navigator, isLoading = { isLoading = it })
}
2 -> {
LibrariesScreen(navigator = navigator, isLoading = { isLoading = it })
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview(widthDp = 960, heightDp = 540)
@Composable
private fun MainScreenLayoutPreview() {
FindroidTheme {
Surface {
MainScreenLayout(
uiState = MainViewModel.UiState.Normal(server = dummyServer, user = dummyUser),
navigator = EmptyDestinationsNavigator,
)
}
}
}

View file

@ -0,0 +1,375 @@
package dev.jdtech.jellyfin.ui
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.material3.Button
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import dev.jdtech.jellyfin.destinations.PlayerActivityDestination
import dev.jdtech.jellyfin.models.AudioChannel
import dev.jdtech.jellyfin.models.AudioCodec
import dev.jdtech.jellyfin.models.DisplayProfile
import dev.jdtech.jellyfin.models.Resolution
import dev.jdtech.jellyfin.models.VideoMetadata
import dev.jdtech.jellyfin.ui.dummy.dummyMovie
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.Yellow
import dev.jdtech.jellyfin.ui.theme.spacings
import dev.jdtech.jellyfin.utils.ObserveAsEvents
import dev.jdtech.jellyfin.viewmodels.MovieViewModel
import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import org.jellyfin.sdk.model.api.BaseItemPerson
import java.util.UUID
import dev.jdtech.jellyfin.core.R as CoreR
@Destination
@Composable
fun MovieScreen(
navigator: DestinationsNavigator,
itemId: UUID,
movieViewModel: MovieViewModel = hiltViewModel(),
playerViewModel: PlayerViewModel = hiltViewModel(),
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
movieViewModel.loadData(itemId)
}
ObserveAsEvents(playerViewModel.eventsChannelFlow) { event ->
when (event) {
is PlayerItemsEvent.PlayerItemsReady -> {
navigator.navigate(PlayerActivityDestination(items = ArrayList(event.items)))
}
is PlayerItemsEvent.PlayerItemsError -> Unit
}
}
val delegatedUiState by movieViewModel.uiState.collectAsState()
MovieScreenLayout(
uiState = delegatedUiState,
onPlayClick = {
playerViewModel.loadPlayerItems(movieViewModel.item)
},
onTrailerClick = { trailerUri ->
try {
Intent(
Intent.ACTION_VIEW,
Uri.parse(trailerUri),
).also {
context.startActivity(it)
}
} catch (e: Exception) {
Toast.makeText(context, e.localizedMessage, Toast.LENGTH_SHORT).show()
}
},
onPlayedClick = {
movieViewModel.togglePlayed()
},
onFavoriteClick = {
movieViewModel.toggleFavorite()
},
)
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun MovieScreenLayout(
uiState: MovieViewModel.UiState,
onPlayClick: () -> Unit,
onTrailerClick: (String) -> Unit,
onPlayedClick: () -> Unit,
onFavoriteClick: () -> Unit,
) {
val focusRequester = remember { FocusRequester() }
when (uiState) {
is MovieViewModel.UiState.Loading -> Text(text = "LOADING")
is MovieViewModel.UiState.Normal -> {
val item = uiState.item
var size by remember {
mutableStateOf(Size.Zero)
}
Box(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { coordinates ->
size = coordinates.size.toSize()
},
) {
AsyncImage(
model = item.images.backdrop,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize(),
)
if (size != Size.Zero) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.radialGradient(
listOf(Color.Black.copy(alpha = .2f), Color.Black),
center = Offset(size.width, 0f),
radius = size.width * .8f,
),
),
)
}
Column(
modifier = Modifier
.padding(start = MaterialTheme.spacings.default * 2, end = MaterialTheme.spacings.default * 2),
) {
Spacer(modifier = Modifier.height(112.dp))
Text(
text = item.name,
style = MaterialTheme.typography.displayMedium,
)
if (item.originalTitle != item.name) {
item.originalTitle?.let { originalTitle ->
Text(
text = originalTitle,
style = MaterialTheme.typography.bodyMedium,
)
}
}
Spacer(modifier = Modifier.height(MaterialTheme.spacings.small))
Row(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.small),
) {
Text(
text = uiState.dateString,
style = MaterialTheme.typography.labelMedium,
)
Text(
text = uiState.runTime,
style = MaterialTheme.typography.labelMedium,
)
item.officialRating?.let {
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
)
}
item.communityRating?.let {
Row {
Icon(
painter = painterResource(id = CoreR.drawable.ic_star),
contentDescription = null,
tint = Yellow,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(MaterialTheme.spacings.extraSmall))
Text(
text = String.format("%.1f", item.communityRating),
style = MaterialTheme.typography.labelMedium,
)
}
}
}
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
Text(
text = item.overview,
style = MaterialTheme.typography.bodyMedium,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(640.dp),
)
Spacer(modifier = Modifier.height(MaterialTheme.spacings.default))
Row(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium),
) {
Button(
onClick = {
onPlayClick()
},
modifier = Modifier.focusRequester(focusRequester),
) {
Icon(
painter = painterResource(id = CoreR.drawable.ic_play),
contentDescription = null,
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = stringResource(id = CoreR.string.play))
}
item.trailer?.let { trailerUri ->
Button(
onClick = {
onTrailerClick(trailerUri)
},
) {
Icon(
painter = painterResource(id = CoreR.drawable.ic_film),
contentDescription = null,
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = stringResource(id = CoreR.string.watch_trailer))
}
}
Button(
onClick = {
onPlayedClick()
},
) {
Icon(
painter = painterResource(id = CoreR.drawable.ic_check),
contentDescription = null,
tint = if (item.played) Color.Red else LocalContentColor.current,
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = stringResource(id = if (item.played) CoreR.string.unmark_as_played else CoreR.string.mark_as_played))
}
Button(
onClick = {
onFavoriteClick()
},
) {
Icon(
painter = painterResource(id = if (item.favorite) CoreR.drawable.ic_heart_filled else CoreR.drawable.ic_heart),
contentDescription = null,
tint = if (item.favorite) Color.Red else LocalContentColor.current,
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = stringResource(id = if (item.favorite) CoreR.string.remove_from_favorites else CoreR.string.add_to_favorites))
}
}
Spacer(modifier = Modifier.height(MaterialTheme.spacings.default))
Row(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large),
) {
Column {
Text(
text = stringResource(id = CoreR.string.genres),
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = .5f),
)
Text(
text = uiState.genresString,
style = MaterialTheme.typography.bodyMedium,
)
}
uiState.director?.let { director ->
Column {
Text(
text = stringResource(id = CoreR.string.director),
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = .5f),
)
Text(
text = director.name ?: "Unknown",
style = MaterialTheme.typography.bodyMedium,
)
}
}
Column {
Text(
text = stringResource(id = CoreR.string.writers),
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = .5f),
)
Text(
text = uiState.writersString,
style = MaterialTheme.typography.bodyMedium,
)
}
}
// Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
// Text(
// text = stringResource(id = CoreR.string.cast_amp_crew),
// style = MaterialTheme.typography.headlineMedium,
// )
}
}
LaunchedEffect(true) {
focusRequester.requestFocus()
}
}
is MovieViewModel.UiState.Error -> Text(text = uiState.error.toString())
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview(widthDp = 960, heightDp = 540)
@Composable
private fun MovieScreenLayoutPreview() {
FindroidTheme {
Surface {
MovieScreenLayout(
uiState = MovieViewModel.UiState.Normal(
item = dummyMovie,
actors = emptyList(),
director = BaseItemPerson(
id = UUID.randomUUID(),
name = "Robert Rodriguez",
),
writers = emptyList(),
videoMetadata = VideoMetadata(
resolution = listOf(Resolution.UHD),
displayProfiles = listOf(DisplayProfile.HDR10),
audioChannels = listOf(AudioChannel.CH_5_1),
audioCodecs = listOf(AudioCodec.EAC3),
isAtmos = listOf(false),
),
writersString = "James Cameron, Laeta Kalogridis, Yukito Kishiro",
genresString = "Action, Science Fiction, Adventure",
videoString = "",
audioString = "",
subtitleString = "",
runTime = "121 min",
dateString = "2019",
),
onPlayClick = {},
onTrailerClick = {},
onPlayedClick = {},
onFavoriteClick = {},
)
}
}
}

View file

@ -0,0 +1,297 @@
package dev.jdtech.jellyfin.ui
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
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.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaSession
import androidx.media3.ui.PlayerView
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.result.NavResult
import com.ramcosta.composedestinations.result.ResultRecipient
import dev.jdtech.jellyfin.core.R
import dev.jdtech.jellyfin.destinations.VideoPlayerTrackSelectorDialogDestination
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.models.Track
import dev.jdtech.jellyfin.ui.components.player.VideoPlayerControlsLayout
import dev.jdtech.jellyfin.ui.components.player.VideoPlayerMediaButton
import dev.jdtech.jellyfin.ui.components.player.VideoPlayerMediaTitle
import dev.jdtech.jellyfin.ui.components.player.VideoPlayerOverlay
import dev.jdtech.jellyfin.ui.components.player.VideoPlayerSeeker
import dev.jdtech.jellyfin.ui.components.player.VideoPlayerState
import dev.jdtech.jellyfin.ui.components.player.rememberVideoPlayerState
import dev.jdtech.jellyfin.ui.theme.spacings
import dev.jdtech.jellyfin.utils.handleDPadKeyEvents
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
import kotlinx.coroutines.delay
import java.util.Locale
import kotlin.time.Duration.Companion.milliseconds
@Destination
@Composable
fun PlayerScreen(
navigator: DestinationsNavigator,
items: ArrayList<PlayerItem>,
resultRecipient: ResultRecipient<VideoPlayerTrackSelectorDialogDestination, Int>,
) {
val viewModel = hiltViewModel<PlayerActivityViewModel>()
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
var lifecycle by remember {
mutableStateOf(Lifecycle.Event.ON_CREATE)
}
var mediaSession by remember {
mutableStateOf<MediaSession?>(null)
}
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
lifecycle = event
// Handle creation and release of media session
when (lifecycle) {
Lifecycle.Event.ON_STOP -> {
println("ON_STOP")
mediaSession?.release()
}
Lifecycle.Event.ON_START -> {
println("ON_START")
mediaSession = MediaSession.Builder(context, viewModel.player).build()
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
val videoPlayerState = rememberVideoPlayerState()
var currentPosition by remember {
mutableLongStateOf(0L)
}
var isPlaying by remember {
mutableStateOf(viewModel.player.isPlaying)
}
LaunchedEffect(Unit) {
while (true) {
delay(300)
currentPosition = viewModel.player.currentPosition
isPlaying = viewModel.player.isPlaying
}
}
resultRecipient.onNavResult { result ->
when (result) {
is NavResult.Canceled -> Unit
is NavResult.Value -> {
viewModel.player.trackSelectionParameters = viewModel.player.trackSelectionParameters
.buildUpon()
.setOverrideForType(
TrackSelectionOverride(viewModel.player.currentTracks.groups[result.value].mediaTrackGroup, 0),
)
.build()
}
}
}
Box(
modifier = Modifier
.dPadEvents(
exoPlayer = viewModel.player,
videoPlayerState = videoPlayerState,
)
.focusable(),
) {
AndroidView(
factory = { context ->
PlayerView(context).also { playerView ->
playerView.player = viewModel.player
playerView.useController = false
viewModel.initializePlayer(items.toTypedArray())
playerView.setBackgroundColor(
context.resources.getColor(
android.R.color.black,
context.theme,
),
)
}
},
update = {
when (lifecycle) {
Lifecycle.Event.ON_PAUSE -> {
it.onPause()
it.player?.pause()
}
Lifecycle.Event.ON_RESUME -> {
it.onResume()
}
else -> Unit
}
},
modifier = Modifier
.fillMaxSize(),
)
val focusRequester = remember { FocusRequester() }
VideoPlayerOverlay(
modifier = Modifier.align(Alignment.BottomCenter),
focusRequester = focusRequester,
state = videoPlayerState,
isPlaying = isPlaying,
controls = {
VideoPlayerControls(
title = uiState.currentItemTitle,
isPlaying = isPlaying,
contentCurrentPosition = currentPosition,
player = viewModel.player,
state = videoPlayerState,
focusRequester = focusRequester,
navigator = navigator,
)
},
)
}
}
@androidx.annotation.OptIn(UnstableApi::class)
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun VideoPlayerControls(
title: String,
isPlaying: Boolean,
contentCurrentPosition: Long,
player: Player,
state: VideoPlayerState,
focusRequester: FocusRequester,
navigator: DestinationsNavigator,
) {
val onPlayPauseToggle = { shouldPlay: Boolean ->
if (shouldPlay) {
player.play()
} else {
player.pause()
}
}
VideoPlayerControlsLayout(
mediaTitle = {
VideoPlayerMediaTitle(
title = title,
subtitle = null,
)
},
seeker = {
VideoPlayerSeeker(
focusRequester = focusRequester,
state = state,
isPlaying = isPlaying,
onPlayPauseToggle = onPlayPauseToggle,
onSeek = { player.seekTo(player.duration.times(it).toLong()) },
contentProgress = contentCurrentPosition.milliseconds,
contentDuration = player.duration.milliseconds,
)
},
mediaActions = {
Row(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium),
) {
VideoPlayerMediaButton(
icon = painterResource(id = R.drawable.ic_speaker),
state = state,
isPlaying = isPlaying,
onClick = {
val tracks = getTracks(player, C.TRACK_TYPE_AUDIO)
navigator.navigate(VideoPlayerTrackSelectorDialogDestination(tracks))
},
)
VideoPlayerMediaButton(
icon = painterResource(id = R.drawable.ic_closed_caption),
state = state,
isPlaying = isPlaying,
onClick = {
val tracks = getTracks(player, C.TRACK_TYPE_TEXT)
navigator.navigate(VideoPlayerTrackSelectorDialogDestination(tracks))
},
)
}
},
)
}
private fun Modifier.dPadEvents(
exoPlayer: Player,
videoPlayerState: VideoPlayerState,
): Modifier = this.handleDPadKeyEvents(
onLeft = {},
onRight = {},
onUp = {},
onDown = {},
onEnter = {
exoPlayer.pause()
videoPlayerState.showControls()
},
)
@androidx.annotation.OptIn(UnstableApi::class)
private fun getTracks(player: Player, type: Int): ArrayList<Track> {
val tracks = arrayListOf<Track>()
for (groupIndex in 0 until player.currentTracks.groups.count()) {
val group = player.currentTracks.groups[groupIndex]
if (group.type == type) {
val format = group.mediaTrackGroup.getFormat(0)
val label = format.label
val language = Locale(format.language.toString()).displayLanguage
val codec = format.codecs
val selected = group.isSelected
val track = Track(
id = groupIndex,
label = label,
language = language,
codec = codec,
selected = selected,
)
tracks.add(track)
}
}
return tracks
}

View file

@ -0,0 +1,170 @@
package dev.jdtech.jellyfin.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.foundation.lazy.list.TvLazyColumn
import androidx.tv.foundation.lazy.list.items
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import dev.jdtech.jellyfin.destinations.PlayerActivityDestination
import dev.jdtech.jellyfin.models.EpisodeItem
import dev.jdtech.jellyfin.models.FindroidEpisode
import dev.jdtech.jellyfin.ui.components.EpisodeCard
import dev.jdtech.jellyfin.ui.dummy.dummyEpisodeItems
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
import dev.jdtech.jellyfin.utils.ObserveAsEvents
import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import dev.jdtech.jellyfin.viewmodels.SeasonViewModel
import java.util.UUID
@Destination
@Composable
fun SeasonScreen(
navigator: DestinationsNavigator,
seriesId: UUID,
seasonId: UUID,
seriesName: String,
seasonName: String,
seasonViewModel: SeasonViewModel = hiltViewModel(),
playerViewModel: PlayerViewModel = hiltViewModel(),
) {
LaunchedEffect(true) {
seasonViewModel.loadEpisodes(
seriesId = seriesId,
seasonId = seasonId,
offline = false,
)
}
ObserveAsEvents(playerViewModel.eventsChannelFlow) { event ->
when (event) {
is PlayerItemsEvent.PlayerItemsReady -> {
navigator.navigate(PlayerActivityDestination(items = ArrayList(event.items)))
}
is PlayerItemsEvent.PlayerItemsError -> Unit
}
}
val delegatedUiState by seasonViewModel.uiState.collectAsState()
SeasonScreenLayout(
seriesName = seriesName,
seasonName = seasonName,
uiState = delegatedUiState,
onClick = { episode ->
playerViewModel.loadPlayerItems(item = episode)
},
)
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun SeasonScreenLayout(
seriesName: String,
seasonName: String,
uiState: SeasonViewModel.UiState,
onClick: (FindroidEpisode) -> Unit,
) {
val focusRequester = remember { FocusRequester() }
when (uiState) {
is SeasonViewModel.UiState.Loading -> Text(text = "LOADING")
is SeasonViewModel.UiState.Normal -> {
val episodes = uiState.episodes
Box(
modifier = Modifier
.fillMaxSize()
.background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))),
) {
Row(
modifier = Modifier.fillMaxSize(),
) {
Column(
modifier = Modifier
.weight(1f)
.padding(
start = MaterialTheme.spacings.extraLarge,
top = MaterialTheme.spacings.large,
end = MaterialTheme.spacings.large,
),
) {
Text(
text = seasonName,
style = MaterialTheme.typography.displayMedium,
)
Text(
text = seriesName,
style = MaterialTheme.typography.headlineMedium,
)
}
TvLazyColumn(
contentPadding = PaddingValues(
top = MaterialTheme.spacings.large,
bottom = MaterialTheme.spacings.large,
),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium),
modifier = Modifier
.weight(2f)
.padding(end = MaterialTheme.spacings.extraLarge)
.focusRequester(focusRequester),
) {
items(episodes) { episodeItem ->
when (episodeItem) {
is EpisodeItem.Episode -> {
EpisodeCard(episode = episodeItem.episode, onClick = { onClick(episodeItem.episode) })
}
else -> Unit
}
}
}
LaunchedEffect(true) {
focusRequester.requestFocus()
}
}
}
}
is SeasonViewModel.UiState.Error -> Text(text = uiState.error.toString())
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview(widthDp = 960, heightDp = 540)
@Composable
private fun SeasonScreenLayoutPreview() {
FindroidTheme {
Surface {
SeasonScreenLayout(
seriesName = "86 EIGHTY-SIX",
seasonName = "Season 1",
uiState = SeasonViewModel.UiState.Normal(dummyEpisodeItems),
onClick = {},
)
}
}
}

View file

@ -0,0 +1,350 @@
package dev.jdtech.jellyfin.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.width
import androidx.compose.foundation.shape.RoundedCornerShape
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.foundation.lazy.list.TvLazyRow
import androidx.tv.foundation.lazy.list.items
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.OutlinedButton
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.popUpTo
import dev.jdtech.jellyfin.NavGraphs
import dev.jdtech.jellyfin.destinations.AddServerScreenDestination
import dev.jdtech.jellyfin.destinations.MainScreenDestination
import dev.jdtech.jellyfin.destinations.UserSelectScreenDestination
import dev.jdtech.jellyfin.models.DiscoveredServer
import dev.jdtech.jellyfin.models.Server
import dev.jdtech.jellyfin.ui.dummy.dummyDiscoveredServer
import dev.jdtech.jellyfin.ui.dummy.dummyDiscoveredServers
import dev.jdtech.jellyfin.ui.dummy.dummyServers
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
import dev.jdtech.jellyfin.utils.ObserveAsEvents
import dev.jdtech.jellyfin.viewmodels.ServerSelectEvent
import dev.jdtech.jellyfin.viewmodels.ServerSelectViewModel
import dev.jdtech.jellyfin.core.R as CoreR
@Destination
@Composable
fun ServerSelectScreen(
navigator: DestinationsNavigator,
serverSelectViewModel: ServerSelectViewModel = hiltViewModel(),
) {
val delegatedUiState by serverSelectViewModel.uiState.collectAsState()
val delegatedDiscoveredServersState by serverSelectViewModel.discoveredServersState.collectAsState()
ObserveAsEvents(serverSelectViewModel.eventsChannelFlow) { event ->
when (event) {
ServerSelectEvent.NavigateToLogin -> {
navigator.navigate(UserSelectScreenDestination)
}
ServerSelectEvent.NavigateToHome -> {
navigator.navigate(MainScreenDestination) {
popUpTo(NavGraphs.root) {
inclusive = true
}
}
}
}
}
ServerSelectScreenLayout(
uiState = delegatedUiState,
discoveredServersState = delegatedDiscoveredServersState,
onServerClick = { server ->
serverSelectViewModel.connectToServer(
Server(
id = server.id,
name = server.name,
currentUserId = null,
currentServerAddressId = null,
),
)
},
onAddServerClick = {
navigator.navigate(AddServerScreenDestination)
},
)
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun ServerSelectScreenLayout(
uiState: ServerSelectViewModel.UiState,
discoveredServersState: ServerSelectViewModel.DiscoveredServersState,
onServerClick: (DiscoveredServer) -> Unit,
onAddServerClick: () -> Unit,
) {
var servers = emptyList<DiscoveredServer>()
var discoveredServers = emptyList<DiscoveredServer>()
when (uiState) {
is ServerSelectViewModel.UiState.Normal -> {
servers =
uiState.servers.map { DiscoveredServer(id = it.id, name = it.name, address = "") }
}
else -> Unit
}
when (discoveredServersState) {
is ServerSelectViewModel.DiscoveredServersState.Servers -> {
discoveredServers = discoveredServersState.servers
}
else -> Unit
}
val focusRequester = remember { FocusRequester() }
Box(
modifier = Modifier
.fillMaxSize()
.background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center),
) {
Text(
text = stringResource(id = CoreR.string.select_server),
style = MaterialTheme.typography.displayMedium,
)
if (discoveredServers.isNotEmpty()) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(id = CoreR.drawable.ic_sparkles),
contentDescription = null,
tint = Color(0xFFBDBDBD),
)
Spacer(modifier = Modifier.width(MaterialTheme.spacings.extraSmall))
Text(
text = pluralStringResource(
id = CoreR.plurals.discovered_servers,
count = discoveredServers.count(),
discoveredServers.count(),
),
style = MaterialTheme.typography.titleMedium,
color = Color(0xFFBDBDBD),
)
}
}
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
if (servers.isEmpty() && discoveredServers.isEmpty()) {
Text(
text = stringResource(id = CoreR.string.no_servers_found),
style = MaterialTheme.typography.bodyMedium,
)
} else {
TvLazyRow(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large),
contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.default),
modifier = Modifier.focusRequester(focusRequester),
) {
items(servers) { server ->
ServerComponent(server) { onServerClick(it) }
}
items(discoveredServers) {
ServerComponent(it, discovered = true)
}
}
LaunchedEffect(true) {
focusRequester.requestFocus()
}
}
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
OutlinedButton(
onClick = { onAddServerClick() },
) {
Text(text = stringResource(id = CoreR.string.add_server))
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview(widthDp = 960, heightDp = 540)
@Composable
private fun ServerSelectScreenLayoutPreview() {
FindroidTheme {
Surface {
ServerSelectScreenLayout(
uiState = ServerSelectViewModel.UiState.Normal(dummyServers),
discoveredServersState = ServerSelectViewModel.DiscoveredServersState.Servers(
dummyDiscoveredServers,
),
onServerClick = {},
onAddServerClick = {},
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview(widthDp = 960, heightDp = 540)
@Composable
private fun ServerSelectScreenLayoutPreviewNoDiscovered() {
FindroidTheme {
Surface {
ServerSelectScreenLayout(
uiState = ServerSelectViewModel.UiState.Normal(dummyServers),
discoveredServersState = ServerSelectViewModel.DiscoveredServersState.Servers(
emptyList(),
),
onServerClick = {},
onAddServerClick = {},
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview(widthDp = 960, heightDp = 540)
@Composable
private fun ServerSelectScreenLayoutPreviewNoServers() {
FindroidTheme {
Surface {
ServerSelectScreenLayout(
uiState = ServerSelectViewModel.UiState.Normal(emptyList()),
discoveredServersState = ServerSelectViewModel.DiscoveredServersState.Servers(
emptyList(),
),
onServerClick = {},
onAddServerClick = {},
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun ServerComponent(
server: DiscoveredServer,
discovered: Boolean = false,
onClick: (DiscoveredServer) -> Unit = {},
) {
Surface(
onClick = {
onClick(server)
},
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF132026),
focusedContainerColor = Color(0xFF132026),
),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(16.dp)),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
BorderStroke(
4.dp,
Color.White,
),
shape = RoundedCornerShape(16.dp),
),
),
modifier = Modifier
.width(270.dp)
.height(115.dp),
) {
Box(modifier = Modifier.fillMaxSize()) {
if (discovered) {
Icon(
painter = painterResource(id = CoreR.drawable.ic_sparkles),
contentDescription = null,
tint = Color.White,
modifier = Modifier.padding(start = MaterialTheme.spacings.default / 2, top = MaterialTheme.spacings.default / 2),
)
}
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxHeight()
.align(Alignment.Center)
.padding(
vertical = MaterialTheme.spacings.default,
horizontal = MaterialTheme.spacings.medium,
),
) {
Text(
text = server.name,
style = MaterialTheme.typography.headlineMedium,
color = Color.White,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
Text(
text = server.address,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFFBDBDBD),
overflow = TextOverflow.Ellipsis,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview
@Composable
private fun ServerComponentPreview() {
FindroidTheme {
Surface {
ServerComponent(dummyDiscoveredServer)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview
@Composable
private fun ServerComponentPreviewDiscovered() {
FindroidTheme {
Surface {
ServerComponent(
server = dummyDiscoveredServer,
discovered = true,
)
}
}
}

View file

@ -0,0 +1,169 @@
package dev.jdtech.jellyfin.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.foundation.lazy.grid.TvGridCells
import androidx.tv.foundation.lazy.grid.TvGridItemSpan
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
import androidx.tv.foundation.lazy.grid.items
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import dev.jdtech.jellyfin.destinations.ServerSelectScreenDestination
import dev.jdtech.jellyfin.destinations.SettingsSubScreenDestination
import dev.jdtech.jellyfin.destinations.UserSelectScreenDestination
import dev.jdtech.jellyfin.models.Preference
import dev.jdtech.jellyfin.models.PreferenceCategory
import dev.jdtech.jellyfin.models.PreferenceSelect
import dev.jdtech.jellyfin.models.PreferenceSwitch
import dev.jdtech.jellyfin.ui.components.SettingsCategoryCard
import dev.jdtech.jellyfin.ui.components.SettingsSelectCard
import dev.jdtech.jellyfin.ui.components.SettingsSwitchCard
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
import dev.jdtech.jellyfin.utils.ObserveAsEvents
import dev.jdtech.jellyfin.viewmodels.SettingsEvent
import dev.jdtech.jellyfin.viewmodels.SettingsViewModel
import dev.jdtech.jellyfin.core.R as CoreR
@Destination
@Composable
fun SettingsScreen(
navigator: DestinationsNavigator,
settingsViewModel: SettingsViewModel = hiltViewModel(),
) {
LaunchedEffect(true) {
settingsViewModel.loadPreferences(intArrayOf())
}
ObserveAsEvents(settingsViewModel.eventsChannelFlow) { event ->
when (event) {
is SettingsEvent.NavigateToSettings -> {
navigator.navigate(SettingsSubScreenDestination(event.indexes, event.title))
}
is SettingsEvent.NavigateToUsers -> {
navigator.navigate(UserSelectScreenDestination)
}
is SettingsEvent.NavigateToServers -> {
navigator.navigate(ServerSelectScreenDestination)
}
}
}
val delegatedUiState by settingsViewModel.uiState.collectAsState()
SettingsScreenLayout(delegatedUiState) { preference ->
when (preference) {
is PreferenceSwitch -> {
settingsViewModel.setBoolean(preference.backendName, preference.value)
}
is PreferenceSelect -> {
settingsViewModel.setString(preference.backendName, preference.value)
}
}
settingsViewModel.loadPreferences(intArrayOf())
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun SettingsScreenLayout(
uiState: SettingsViewModel.UiState,
onUpdate: (Preference) -> Unit,
) {
val focusRequester = remember { FocusRequester() }
when (uiState) {
is SettingsViewModel.UiState.Normal -> {
TvLazyVerticalGrid(
columns = TvGridCells.Fixed(3),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.default * 2, vertical = MaterialTheme.spacings.large),
modifier = Modifier
.fillMaxSize()
.background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721))))
.focusRequester(focusRequester),
) {
item(span = { TvGridItemSpan(this.maxLineSpan) }) {
Text(
text = stringResource(id = CoreR.string.title_settings),
style = MaterialTheme.typography.displayMedium,
)
}
items(uiState.preferences) { preference ->
when (preference) {
is PreferenceCategory -> SettingsCategoryCard(preference = preference)
is PreferenceSwitch -> {
SettingsSwitchCard(preference = preference) {
onUpdate(preference.copy(value = !preference.value))
}
}
is PreferenceSelect -> {
val options = stringArrayResource(id = preference.options)
SettingsSelectCard(preference = preference) {
val currentIndex = options.indexOf(preference.value)
val newIndex = if (currentIndex == options.count() - 1) {
0
} else {
currentIndex + 1
}
onUpdate(preference.copy(value = options[newIndex]))
}
}
}
}
}
LaunchedEffect(true) {
focusRequester.requestFocus()
}
}
is SettingsViewModel.UiState.Loading -> {
Text(text = "LOADING")
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview(widthDp = 960, heightDp = 540)
@Composable
private fun SettingsScreenLayoutPreview() {
FindroidTheme {
Surface {
SettingsScreenLayout(
uiState = SettingsViewModel.UiState.Normal(
listOf(
PreferenceCategory(
nameStringResource = CoreR.string.settings_category_language,
iconDrawableId = CoreR.drawable.ic_languages,
),
PreferenceCategory(
nameStringResource = CoreR.string.settings_category_appearance,
iconDrawableId = CoreR.drawable.ic_palette,
),
),
),
onUpdate = {},
)
}
}
}

View file

@ -0,0 +1,249 @@
package dev.jdtech.jellyfin.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.foundation.lazy.list.TvLazyColumn
import androidx.tv.foundation.lazy.list.items
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import dev.jdtech.jellyfin.Constants
import dev.jdtech.jellyfin.destinations.ServerSelectScreenDestination
import dev.jdtech.jellyfin.destinations.SettingsScreenDestination
import dev.jdtech.jellyfin.destinations.UserSelectScreenDestination
import dev.jdtech.jellyfin.models.Preference
import dev.jdtech.jellyfin.models.PreferenceCategory
import dev.jdtech.jellyfin.models.PreferenceSelect
import dev.jdtech.jellyfin.models.PreferenceSwitch
import dev.jdtech.jellyfin.ui.components.SettingsCategoryCard
import dev.jdtech.jellyfin.ui.components.SettingsDetailsCard
import dev.jdtech.jellyfin.ui.components.SettingsSelectCard
import dev.jdtech.jellyfin.ui.components.SettingsSwitchCard
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
import dev.jdtech.jellyfin.utils.ObserveAsEvents
import dev.jdtech.jellyfin.viewmodels.SettingsEvent
import dev.jdtech.jellyfin.viewmodels.SettingsViewModel
import dev.jdtech.jellyfin.core.R as CoreR
@Destination
@Composable
fun SettingsSubScreen(
indexes: IntArray = intArrayOf(),
@StringRes title: Int,
navigator: DestinationsNavigator,
settingsViewModel: SettingsViewModel = hiltViewModel(),
) {
LaunchedEffect(true) {
settingsViewModel.loadPreferences(indexes)
}
ObserveAsEvents(settingsViewModel.eventsChannelFlow) { event ->
when (event) {
is SettingsEvent.NavigateToSettings -> {
navigator.navigate(SettingsScreenDestination)
}
is SettingsEvent.NavigateToUsers -> {
navigator.navigate(UserSelectScreenDestination)
}
is SettingsEvent.NavigateToServers -> {
navigator.navigate(ServerSelectScreenDestination)
}
}
}
val delegatedUiState by settingsViewModel.uiState.collectAsState()
SettingsSubScreenLayout(delegatedUiState, title) { preference ->
when (preference) {
is PreferenceSwitch -> {
settingsViewModel.setBoolean(preference.backendName, preference.value)
}
is PreferenceSelect -> {
settingsViewModel.setString(preference.backendName, preference.value)
}
}
settingsViewModel.loadPreferences(indexes)
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun SettingsSubScreenLayout(
uiState: SettingsViewModel.UiState,
@StringRes title: Int? = null,
onUpdate: (Preference) -> Unit,
) {
val focusRequester = remember { FocusRequester() }
when (uiState) {
is SettingsViewModel.UiState.Normal -> {
var focusedPreference by remember {
mutableStateOf(uiState.preferences.first())
}
Column(
modifier = Modifier
.fillMaxSize()
.background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721))))
.padding(
start = MaterialTheme.spacings.large,
top = MaterialTheme.spacings.default * 2,
end = MaterialTheme.spacings.large,
),
) {
if (title != null) {
Column {
Text(
text = stringResource(id = title),
style = MaterialTheme.typography.displayMedium,
)
Text(
text = stringResource(id = CoreR.string.title_settings),
style = MaterialTheme.typography.headlineMedium,
)
}
} else {
Text(
text = stringResource(id = CoreR.string.title_settings),
style = MaterialTheme.typography.displayMedium,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large),
) {
TvLazyColumn(
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
contentPadding = PaddingValues(vertical = MaterialTheme.spacings.large),
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester),
) {
items(uiState.preferences) { preference ->
when (preference) {
is PreferenceCategory -> SettingsCategoryCard(
preference = preference,
modifier = Modifier.onFocusChanged {
if (it.isFocused) {
focusedPreference = preference
}
},
)
is PreferenceSwitch -> {
SettingsSwitchCard(
preference = preference,
modifier = Modifier.onFocusChanged {
if (it.isFocused) {
focusedPreference = preference
}
},
) {
onUpdate(preference.copy(value = !preference.value))
}
}
is PreferenceSelect -> {
val optionValues = stringArrayResource(id = preference.optionValues)
SettingsSelectCard(
preference = preference,
modifier = Modifier.onFocusChanged {
if (it.isFocused) {
focusedPreference = preference
}
},
) {
val currentIndex = optionValues.indexOf(preference.value)
val newIndex = if (currentIndex == optionValues.count() - 1) {
0
} else {
currentIndex + 1
}
val newPreference = preference.copy(value = optionValues[newIndex])
onUpdate(newPreference)
if (focusedPreference == preference) {
focusedPreference = newPreference
}
}
}
}
}
}
Box(
modifier = Modifier.weight(2f),
) {
(focusedPreference as? PreferenceSelect)?.let {
SettingsDetailsCard(
preference = it,
modifier = Modifier
.fillMaxSize()
.padding(bottom = MaterialTheme.spacings.large),
onOptionSelected = { value ->
println(value)
val newPreference = it.copy(value = value)
onUpdate(newPreference)
focusedPreference = newPreference
},
)
}
}
}
}
LaunchedEffect(true) {
focusRequester.requestFocus()
}
}
is SettingsViewModel.UiState.Loading -> {
Text(text = "LOADING")
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview(widthDp = 960, heightDp = 540)
@Composable
private fun SettingsSubScreenLayoutPreview() {
FindroidTheme {
Surface {
SettingsSubScreenLayout(
uiState = SettingsViewModel.UiState.Normal(
listOf(
PreferenceSelect(
nameStringResource = CoreR.string.pref_player_mpv_hwdec,
backendName = Constants.PREF_PLAYER_MPV_HWDEC,
backendDefaultValue = "mediacodec",
options = CoreR.array.mpv_hwdec,
optionValues = CoreR.array.mpv_hwdec,
),
),
),
title = CoreR.string.settings_category_player,
onUpdate = {},
)
}
}
}

View file

@ -0,0 +1,424 @@
package dev.jdtech.jellyfin.ui
import android.content.Intent
import android.net.Uri
import android.view.KeyEvent
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.nativeKeyCode
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.foundation.lazy.list.TvLazyColumn
import androidx.tv.foundation.lazy.list.TvLazyRow
import androidx.tv.foundation.lazy.list.items
import androidx.tv.foundation.lazy.list.rememberTvLazyListState
import androidx.tv.material3.Button
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import dev.jdtech.jellyfin.destinations.PlayerActivityDestination
import dev.jdtech.jellyfin.destinations.SeasonScreenDestination
import dev.jdtech.jellyfin.models.FindroidSeason
import dev.jdtech.jellyfin.ui.components.Direction
import dev.jdtech.jellyfin.ui.components.ItemCard
import dev.jdtech.jellyfin.ui.dummy.dummyShow
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.Yellow
import dev.jdtech.jellyfin.ui.theme.spacings
import dev.jdtech.jellyfin.utils.ObserveAsEvents
import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
import dev.jdtech.jellyfin.viewmodels.ShowViewModel
import java.util.UUID
import dev.jdtech.jellyfin.core.R as CoreR
@Destination
@Composable
fun ShowScreen(
navigator: DestinationsNavigator,
itemId: UUID,
showViewModel: ShowViewModel = hiltViewModel(),
playerViewModel: PlayerViewModel = hiltViewModel(),
) {
val context = LocalContext.current
LaunchedEffect(key1 = true) {
showViewModel.loadData(itemId, false)
}
ObserveAsEvents(playerViewModel.eventsChannelFlow) { event ->
when (event) {
is PlayerItemsEvent.PlayerItemsReady -> {
navigator.navigate(PlayerActivityDestination(items = ArrayList(event.items)))
}
is PlayerItemsEvent.PlayerItemsError -> Unit
}
}
val delegatedUiState by showViewModel.uiState.collectAsState()
ShowScreenLayout(
uiState = delegatedUiState,
onPlayClick = {
playerViewModel.loadPlayerItems(showViewModel.item)
},
onTrailerClick = { trailerUri ->
try {
Intent(
Intent.ACTION_VIEW,
Uri.parse(trailerUri),
).also {
context.startActivity(it)
}
} catch (e: Exception) {
Toast.makeText(context, e.localizedMessage, Toast.LENGTH_SHORT).show()
}
},
onPlayedClick = {
showViewModel.togglePlayed()
},
onFavoriteClick = {
showViewModel.toggleFavorite()
},
onSeasonClick = { season ->
navigator.navigate(SeasonScreenDestination(seriesId = season.seriesId, seasonId = season.id, seriesName = season.seriesName, seasonName = season.name))
},
)
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun ShowScreenLayout(
uiState: ShowViewModel.UiState,
onPlayClick: () -> Unit,
onTrailerClick: (String) -> Unit,
onPlayedClick: () -> Unit,
onFavoriteClick: () -> Unit,
onSeasonClick: (FindroidSeason) -> Unit,
) {
val focusRequester = remember { FocusRequester() }
val listState = rememberTvLazyListState()
val listSize = remember { mutableIntStateOf(2) }
var currentIndex by remember { mutableIntStateOf(0) }
LaunchedEffect(currentIndex) {
listState.animateScrollToItem(currentIndex)
}
when (uiState) {
is ShowViewModel.UiState.Loading -> Text(text = "LOADING")
is ShowViewModel.UiState.Normal -> {
val item = uiState.item
val seasons = uiState.seasons
var size by remember {
mutableStateOf(Size.Zero)
}
Box(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { coordinates ->
size = coordinates.size.toSize()
},
) {
AsyncImage(
model = item.images.backdrop,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize(),
)
if (size != Size.Zero) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.radialGradient(
listOf(Color.Black.copy(alpha = .2f), Color.Black),
center = Offset(size.width, 0f),
radius = size.width * .8f,
),
),
)
}
TvLazyColumn(
state = listState,
contentPadding = PaddingValues(top = 112.dp, bottom = MaterialTheme.spacings.large),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium),
userScrollEnabled = false,
modifier = Modifier.onPreviewKeyEvent { keyEvent ->
when (keyEvent.key.nativeKeyCode) {
KeyEvent.KEYCODE_DPAD_DOWN -> {
currentIndex = (++currentIndex).coerceIn(0, listSize.intValue - 1)
}
KeyEvent.KEYCODE_DPAD_UP -> {
currentIndex = (--currentIndex).coerceIn(0, listSize.intValue - 1)
}
}
false
},
) {
item {
Column(
modifier = Modifier
.padding(
start = MaterialTheme.spacings.default * 2,
end = MaterialTheme.spacings.default * 2,
),
) {
Text(
text = item.name,
style = MaterialTheme.typography.displayMedium,
)
if (item.originalTitle != item.name) {
item.originalTitle?.let { originalTitle ->
Text(
text = originalTitle,
style = MaterialTheme.typography.bodyMedium,
)
}
}
Spacer(modifier = Modifier.height(MaterialTheme.spacings.small))
Row(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.small),
) {
Text(
text = uiState.dateString,
style = MaterialTheme.typography.labelMedium,
)
Text(
text = uiState.runTime,
style = MaterialTheme.typography.labelMedium,
)
item.officialRating?.let {
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
)
}
item.communityRating?.let {
Row {
Icon(
painter = painterResource(id = CoreR.drawable.ic_star),
contentDescription = null,
tint = Yellow,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(MaterialTheme.spacings.extraSmall))
Text(
text = String.format("%.1f", item.communityRating),
style = MaterialTheme.typography.labelMedium,
)
}
}
}
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
Text(
text = item.overview,
style = MaterialTheme.typography.bodyMedium,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(640.dp),
)
Spacer(modifier = Modifier.height(MaterialTheme.spacings.default))
Row(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium),
) {
Button(
onClick = {
onPlayClick()
},
modifier = Modifier.focusRequester(focusRequester),
) {
Icon(
painter = painterResource(id = CoreR.drawable.ic_play),
contentDescription = null,
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = stringResource(id = CoreR.string.play))
}
item.trailer?.let { trailerUri ->
Button(
onClick = {
onTrailerClick(trailerUri)
},
) {
Icon(
painter = painterResource(id = CoreR.drawable.ic_film),
contentDescription = null,
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = stringResource(id = CoreR.string.watch_trailer))
}
}
Button(
onClick = {
onPlayedClick()
},
) {
Icon(
painter = painterResource(id = CoreR.drawable.ic_check),
contentDescription = null,
tint = if (item.played) Color.Red else LocalContentColor.current,
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = stringResource(id = if (item.played) CoreR.string.unmark_as_played else CoreR.string.mark_as_played))
}
Button(
onClick = {
onFavoriteClick()
},
) {
Icon(
painter = painterResource(id = if (item.favorite) CoreR.drawable.ic_heart_filled else CoreR.drawable.ic_heart),
contentDescription = null,
tint = if (item.favorite) Color.Red else LocalContentColor.current,
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = stringResource(id = if (item.favorite) CoreR.string.remove_from_favorites else CoreR.string.add_to_favorites))
}
}
Spacer(modifier = Modifier.height(MaterialTheme.spacings.default))
Row(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large),
) {
Column {
Text(
text = stringResource(id = CoreR.string.genres),
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = .5f),
)
Text(
text = uiState.genresString,
style = MaterialTheme.typography.bodyMedium,
)
}
uiState.director?.let { director ->
Column {
Text(
text = stringResource(id = CoreR.string.director),
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = .5f),
)
Text(
text = director.name ?: "Unknown",
style = MaterialTheme.typography.bodyMedium,
)
}
}
Column {
Text(
text = stringResource(id = CoreR.string.writers),
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = .5f),
)
Text(
text = uiState.writersString,
style = MaterialTheme.typography.bodyMedium,
)
}
}
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
Text(
text = stringResource(id = CoreR.string.seasons),
style = MaterialTheme.typography.headlineMedium,
)
}
}
item {
TvLazyRow(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.default * 2),
) {
items(seasons) { season ->
ItemCard(
item = season,
direction = Direction.VERTICAL,
onClick = {
onSeasonClick(season)
},
)
}
}
}
}
}
LaunchedEffect(true) {
focusRequester.requestFocus()
}
}
is ShowViewModel.UiState.Error -> Text(text = uiState.error.toString())
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview(widthDp = 960, heightDp = 540)
@Composable
private fun ShowScreenLayoutPreview() {
FindroidTheme {
Surface {
ShowScreenLayout(
uiState = ShowViewModel.UiState.Normal(
item = dummyShow,
actors = emptyList(),
director = null,
writers = emptyList(),
writersString = "Hiroshi Seko, Hajime Isayama",
genresString = "Action, Science Fiction, Adventure",
runTime = "0 min",
dateString = "2013 - 2023",
nextUp = null,
seasons = emptyList(),
),
onPlayClick = {},
onTrailerClick = {},
onPlayedClick = {},
onFavoriteClick = {},
onSeasonClick = {},
)
}
}
}

View file

@ -0,0 +1,288 @@
package dev.jdtech.jellyfin.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.foundation.lazy.list.TvLazyRow
import androidx.tv.foundation.lazy.list.items
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.OutlinedButton
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.popUpTo
import dev.jdtech.jellyfin.NavGraphs
import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.destinations.LoginScreenDestination
import dev.jdtech.jellyfin.destinations.MainScreenDestination
import dev.jdtech.jellyfin.models.Server
import dev.jdtech.jellyfin.models.User
import dev.jdtech.jellyfin.ui.dummy.dummyServer
import dev.jdtech.jellyfin.ui.dummy.dummyUser
import dev.jdtech.jellyfin.ui.dummy.dummyUsers
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
import dev.jdtech.jellyfin.utils.ObserveAsEvents
import dev.jdtech.jellyfin.viewmodels.UserSelectEvent
import dev.jdtech.jellyfin.viewmodels.UserSelectViewModel
import org.jellyfin.sdk.model.api.ImageType
import dev.jdtech.jellyfin.core.R as CoreR
@Destination
@Composable
fun UserSelectScreen(
navigator: DestinationsNavigator,
userSelectViewModel: UserSelectViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val api = JellyfinApi.getInstance(context)
val delegatedUiState by userSelectViewModel.uiState.collectAsState()
ObserveAsEvents(userSelectViewModel.eventsChannelFlow) { event ->
when (event) {
is UserSelectEvent.NavigateToMain -> {
navigator.navigate(MainScreenDestination) {
popUpTo(NavGraphs.root) {
inclusive = true
}
}
}
}
}
LaunchedEffect(key1 = true) {
userSelectViewModel.loadUsers()
}
UserSelectScreenLayout(
uiState = delegatedUiState,
baseUrl = api.api.baseUrl ?: "",
onUserClick = { user ->
userSelectViewModel.loginAsUser(user)
},
onAddUserClick = {
navigator.navigate(LoginScreenDestination)
},
)
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun UserSelectScreenLayout(
uiState: UserSelectViewModel.UiState,
baseUrl: String,
onUserClick: (User) -> Unit,
onAddUserClick: () -> Unit,
) {
var server: Server? = null
var users: List<User> = emptyList()
when (uiState) {
is UserSelectViewModel.UiState.Normal -> {
server = uiState.server
users = uiState.users
}
else -> Unit
}
val focusRequester = remember { FocusRequester() }
Box(
modifier = Modifier
.fillMaxSize()
.background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center),
) {
Text(
text = stringResource(id = CoreR.string.select_user),
style = MaterialTheme.typography.displayMedium,
)
server?.let {
Text(
text = "Server: ${it.name}",
style = MaterialTheme.typography.titleMedium,
color = Color(0xFFBDBDBD),
)
}
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
if (users.isEmpty()) {
Text(
text = stringResource(id = CoreR.string.no_users_found),
style = MaterialTheme.typography.bodyMedium,
)
} else {
TvLazyRow(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
contentPadding = PaddingValues(MaterialTheme.spacings.default),
modifier = Modifier.focusRequester(focusRequester),
) {
items(users) {
UserComponent(
user = it,
baseUrl = baseUrl,
) { user ->
onUserClick(user)
}
}
}
LaunchedEffect(true) {
focusRequester.requestFocus()
}
}
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
OutlinedButton(
onClick = {
onAddUserClick()
},
) {
Text(text = stringResource(id = CoreR.string.add_user))
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview(widthDp = 960, heightDp = 540)
@Composable
private fun UserSelectScreenLayoutPreview() {
FindroidTheme {
Surface {
UserSelectScreenLayout(
uiState = UserSelectViewModel.UiState.Normal(dummyServer, dummyUsers),
baseUrl = "https://demo.jellyfin.org/stable",
onUserClick = {},
onAddUserClick = {},
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview(widthDp = 960, heightDp = 540)
@Composable
private fun UserSelectScreenLayoutPreviewNoUsers() {
FindroidTheme {
Surface {
UserSelectScreenLayout(
uiState = UserSelectViewModel.UiState.Normal(dummyServer, emptyList()),
baseUrl = "https://demo.jellyfin.org/stable",
onUserClick = {},
onAddUserClick = {},
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun UserComponent(
user: User,
baseUrl: String,
onClick: (User) -> Unit = {},
) {
val context = LocalContext.current
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.width(120.dp),
) {
Surface(
onClick = {
onClick(user)
},
colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
),
border = ClickableSurfaceDefaults.border(
border = Border(BorderStroke(1.dp, Color.White), shape = CircleShape),
focusedBorder = Border(BorderStroke(4.dp, Color.White), shape = CircleShape),
),
shape = ClickableSurfaceDefaults.shape(
shape = CircleShape,
focusedShape = CircleShape,
),
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
) {
Icon(
painter = painterResource(id = CoreR.drawable.ic_user),
contentDescription = null,
tint = Color.White,
modifier = Modifier
.width(48.dp)
.height(48.dp)
.align(Alignment.Center),
)
AsyncImage(
model = ImageRequest.Builder(context)
.data("$baseUrl/users/${user.id}/Images/${ImageType.PRIMARY}")
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
Text(
text = user.name,
style = MaterialTheme.typography.titleMedium,
)
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview
@Composable
private fun UserComponentPreview() {
FindroidTheme {
Surface {
UserComponent(
user = dummyUser,
baseUrl = "https://demo.jellyfin.org/stable",
)
}
}
}

View file

@ -0,0 +1,24 @@
package dev.jdtech.jellyfin.ui.components
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon
import dev.jdtech.jellyfin.R
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview
@Composable
fun Banner() {
Icon(
painter = painterResource(id = R.drawable.ic_banner),
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.width(320.dp),
)
}

View file

@ -0,0 +1,113 @@
package dev.jdtech.jellyfin.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ClickableSurfaceScale
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import dev.jdtech.jellyfin.models.FindroidEpisode
import dev.jdtech.jellyfin.ui.dummy.dummyEpisode
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun EpisodeCard(
episode: FindroidEpisode,
onClick: (FindroidEpisode) -> Unit,
) {
Surface(
onClick = { onClick(episode) },
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
BorderStroke(
4.dp,
Color.White,
),
shape = RoundedCornerShape(10.dp),
),
),
scale = ClickableSurfaceScale.None,
modifier = Modifier
.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(MaterialTheme.spacings.small),
) {
Box(modifier = Modifier.width(160.dp)) {
ItemPoster(
item = episode,
direction = Direction.HORIZONTAL,
modifier = Modifier.clip(RoundedCornerShape(10.dp)),
)
ProgressBadge(
item = episode,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(PaddingValues(MaterialTheme.spacings.small)),
)
}
Spacer(modifier = Modifier.width(MaterialTheme.spacings.medium))
Column {
Text(
text = stringResource(
id = dev.jdtech.jellyfin.core.R.string.episode_name,
episode.indexNumber,
episode.name,
),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(MaterialTheme.spacings.extraSmall))
Text(
text = episode.overview,
style = MaterialTheme.typography.bodyMedium,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview
@Composable
private fun ItemCardPreviewEpisode() {
FindroidTheme {
Surface {
EpisodeCard(
episode = dummyEpisode,
onClick = {},
)
}
}
}

View file

@ -0,0 +1,173 @@
package dev.jdtech.jellyfin.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ClickableSurfaceScale
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import dev.jdtech.jellyfin.core.R
import dev.jdtech.jellyfin.models.FindroidEpisode
import dev.jdtech.jellyfin.models.FindroidItem
import dev.jdtech.jellyfin.ui.dummy.dummyEpisode
import dev.jdtech.jellyfin.ui.dummy.dummyMovie
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun ItemCard(
item: FindroidItem,
direction: Direction,
onClick: (FindroidItem) -> Unit,
modifier: Modifier = Modifier,
) {
val width = when (direction) {
Direction.HORIZONTAL -> 260
Direction.VERTICAL -> 150
}
Column(
modifier = modifier
.width(width.dp),
) {
Surface(
onClick = { onClick(item) },
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
BorderStroke(
4.dp,
Color.White,
),
shape = RoundedCornerShape(10.dp),
),
),
scale = ClickableSurfaceScale.None,
) {
Box {
ItemPoster(
item = item,
direction = direction,
)
ProgressBadge(
item = item,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(MaterialTheme.spacings.small),
)
if (direction == Direction.HORIZONTAL) {
Column(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(MaterialTheme.spacings.small),
) {
Box(
modifier = Modifier
.height(4.dp)
.width(
item.playbackPositionTicks
.div(
item.runtimeTicks.toFloat(),
)
.times(
width - 16,
).dp,
)
.clip(
MaterialTheme.shapes.extraSmall,
)
.background(
MaterialTheme.colorScheme.primary,
),
)
}
}
}
}
Spacer(modifier = Modifier.height(MaterialTheme.spacings.small))
Text(
text = if (item is FindroidEpisode) item.seriesName else item.name,
style = MaterialTheme.typography.titleMedium,
maxLines = if (direction == Direction.HORIZONTAL) 1 else 2,
overflow = TextOverflow.Ellipsis,
)
if (item is FindroidEpisode) {
Text(
text = stringResource(
id = R.string.episode_name_extended,
item.parentIndexNumber,
item.indexNumber,
item.name,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview
@Composable
private fun ItemCardPreviewMovie() {
FindroidTheme {
Surface {
ItemCard(
item = dummyMovie,
direction = Direction.HORIZONTAL,
onClick = {},
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview
@Composable
private fun ItemCardPreviewMovieVertical() {
FindroidTheme {
Surface {
ItemCard(
item = dummyMovie,
direction = Direction.VERTICAL,
onClick = {},
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview
@Composable
private fun ItemCardPreviewEpisode() {
FindroidTheme {
Surface {
ItemCard(
item = dummyEpisode,
direction = Direction.HORIZONTAL,
onClick = {},
)
}
}
}

View file

@ -0,0 +1,51 @@
package dev.jdtech.jellyfin.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import coil.compose.AsyncImage
import dev.jdtech.jellyfin.models.FindroidEpisode
import dev.jdtech.jellyfin.models.FindroidItem
import dev.jdtech.jellyfin.models.FindroidMovie
enum class Direction {
HORIZONTAL, VERTICAL
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun ItemPoster(
item: FindroidItem,
direction: Direction,
modifier: Modifier = Modifier,
) {
var imageUri = item.images.primary
when (direction) {
Direction.HORIZONTAL -> {
if (item is FindroidMovie) imageUri = item.images.backdrop
}
Direction.VERTICAL -> {
when (item) {
is FindroidEpisode -> imageUri = item.images.showPrimary
}
}
}
AsyncImage(
model = imageUri,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = modifier
.fillMaxWidth()
.aspectRatio(if (direction == Direction.HORIZONTAL) 1.77f else 0.66f)
.background(
MaterialTheme.colorScheme.surface,
),
)
}

View file

@ -0,0 +1,18 @@
package dev.jdtech.jellyfin.ui.components
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun LoadingIndicator() {
CircularProgressIndicator(
color = Color.White,
strokeWidth = 2.dp,
trackColor = Color.Transparent,
modifier = Modifier.size(32.dp),
)
}

View file

@ -0,0 +1,74 @@
package dev.jdtech.jellyfin.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpRect
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.height
import androidx.compose.ui.unit.width
import androidx.compose.ui.zIndex
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.TabRow
/**
* Adds a pill shaped border indicator behind the tab
*
* @param currentTabPosition position of the current selected tab
* @param doesTabRowHaveFocus whether any tab in TabRow is focused
* @param modifier modifier to be applied to the indicator
* @param activeBorderColor color of border when [TabRow] is active
* @param inactiveBorderColor color of border when [TabRow] is inactive
*
* This component is adapted from androidx.tv.material3.TabRowDefaults.PillIndicator
*/
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun PillBorderIndicator(
currentTabPosition: DpRect,
doesTabRowHaveFocus: Boolean,
modifier: Modifier = Modifier,
activeBorderColor: Color = MaterialTheme.colorScheme.onSurface,
inactiveBorderColor: Color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.4f),
) {
val width by animateDpAsState(
targetValue = currentTabPosition.width,
label = "PillIndicator.width",
)
val height = currentTabPosition.height
val leftOffset by animateDpAsState(
targetValue = currentTabPosition.left,
label = "PillIndicator.leftOffset",
)
val topOffset = currentTabPosition.top
val borderColor by
animateColorAsState(
targetValue = if (doesTabRowHaveFocus) activeBorderColor else inactiveBorderColor,
label = "PillIndicator.pillColor",
)
Box(
modifier
.fillMaxWidth()
.wrapContentSize(Alignment.BottomStart)
.offset(x = leftOffset, y = topOffset)
.width(width)
.height(height)
.border(width = 4.dp, color = borderColor, shape = RoundedCornerShape(50))
.zIndex(-1f),
)
}

View file

@ -0,0 +1,96 @@
package dev.jdtech.jellyfin.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon
import androidx.tv.material3.Surface
import coil.compose.AsyncImage
import coil.request.ImageRequest
import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.core.R
import dev.jdtech.jellyfin.models.User
import dev.jdtech.jellyfin.ui.dummy.dummyUser
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import org.jellyfin.sdk.model.api.ImageType
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun ProfileButton(
user: User?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val baseUrl = JellyfinApi.getInstance(context).api.baseUrl
Surface(
onClick = {
onClick()
},
colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
),
border = ClickableSurfaceDefaults.border(
border = Border(BorderStroke(1.dp, Color.White), shape = CircleShape),
focusedBorder = Border(BorderStroke(4.dp, Color.White), shape = CircleShape),
),
shape = ClickableSurfaceDefaults.shape(
shape = CircleShape,
focusedShape = CircleShape,
),
modifier = modifier
.width(32.dp)
.aspectRatio(1f),
) {
Icon(
painter = painterResource(id = R.drawable.ic_user),
contentDescription = null,
tint = Color.White,
modifier = Modifier
.width(16.dp)
.height(16.dp)
.align(Alignment.Center),
)
user?.let {
AsyncImage(
model = ImageRequest.Builder(context)
.data("$baseUrl/users/${user.id}/Images/${ImageType.PRIMARY}")
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview
@Composable
private fun ProfileButtonPreview() {
FindroidTheme {
Surface {
ProfileButton(
user = dummyUser,
onClick = {},
)
}
}
}

View file

@ -0,0 +1,94 @@
package dev.jdtech.jellyfin.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import dev.jdtech.jellyfin.models.FindroidItem
import dev.jdtech.jellyfin.ui.dummy.dummyEpisode
import dev.jdtech.jellyfin.ui.dummy.dummyShow
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
import dev.jdtech.jellyfin.core.R as CoreR
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun ProgressBadge(
item: FindroidItem,
modifier: Modifier = Modifier,
) {
if (!(!item.played && item.unplayedItemCount == null)) {
Box(
modifier = modifier
.height(24.dp)
.defaultMinSize(24.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.primary),
) {
when (item.played) {
true -> {
Icon(
painter = painterResource(id = CoreR.drawable.ic_check),
contentDescription = "",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier
.size(16.dp)
.align(Alignment.Center),
)
}
false -> {
Text(
text = item.unplayedItemCount.toString(),
color = MaterialTheme.colorScheme.onPrimary,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier
.align(Alignment.Center)
.padding(horizontal = MaterialTheme.spacings.extraSmall),
)
}
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview
@Composable
private fun ProgressBadgePreviewWatched() {
FindroidTheme {
Surface {
ProgressBadge(
item = dummyEpisode,
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview
@Composable
private fun ProgressBadgePreviewItemRemaining() {
FindroidTheme {
Surface {
ProgressBadge(
item = dummyShow,
)
}
}
}

View file

@ -0,0 +1,110 @@
package dev.jdtech.jellyfin.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ClickableSurfaceScale
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import dev.jdtech.jellyfin.models.PreferenceCategory
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
import dev.jdtech.jellyfin.core.R as CoreR
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun SettingsCategoryCard(
preference: PreferenceCategory,
modifier: Modifier = Modifier,
) {
Surface(
onClick = {
preference.onClick(preference)
},
enabled = preference.enabled,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = MaterialTheme.colorScheme.surface,
focusedContainerColor = MaterialTheme.colorScheme.surface,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
BorderStroke(
4.dp,
Color.White,
),
shape = RoundedCornerShape(10.dp),
),
),
scale = ClickableSurfaceScale.None,
modifier = modifier
.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(MaterialTheme.spacings.default),
verticalAlignment = Alignment.CenterVertically,
) {
if (preference.iconDrawableId != null) {
Icon(
painter = painterResource(id = preference.iconDrawableId!!),
contentDescription = null,
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.width(24.dp))
Column(
modifier = Modifier.weight(1f),
) {
Text(
text = stringResource(id = preference.nameStringResource),
style = MaterialTheme.typography.titleMedium,
)
preference.descriptionStringRes?.let {
Spacer(modifier = Modifier.height(MaterialTheme.spacings.extraSmall))
Text(
text = stringResource(id = it),
style = MaterialTheme.typography.labelMedium,
)
}
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview
@Composable
private fun SettingsCategoryCardPreview() {
FindroidTheme {
Surface {
SettingsCategoryCard(
preference = PreferenceCategory(
nameStringResource = CoreR.string.settings_category_player,
iconDrawableId = CoreR.drawable.ic_play,
),
)
}
}
}

View file

@ -0,0 +1,118 @@
package dev.jdtech.jellyfin.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.foundation.lazy.list.TvLazyColumn
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ClickableSurfaceScale
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.RadioButton
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import dev.jdtech.jellyfin.Constants
import dev.jdtech.jellyfin.models.PreferenceSelect
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
import dev.jdtech.jellyfin.core.R as CoreR
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun SettingsDetailsCard(
preference: PreferenceSelect,
modifier: Modifier = Modifier,
onOptionSelected: (String) -> Unit,
) {
val options = stringArrayResource(id = preference.options)
val optionValues = stringArrayResource(id = preference.optionValues)
Surface(
modifier = modifier,
) {
Column(
modifier = Modifier.padding(
horizontal = MaterialTheme.spacings.default,
vertical = MaterialTheme.spacings.medium,
),
) {
Text(text = stringResource(id = preference.nameStringResource), style = MaterialTheme.typography.headlineMedium)
preference.descriptionStringRes?.let {
Spacer(modifier = Modifier.height(MaterialTheme.spacings.small))
Text(text = stringResource(id = it), style = MaterialTheme.typography.bodyMedium)
}
Spacer(modifier = Modifier.height(MaterialTheme.spacings.default))
TvLazyColumn(
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium - MaterialTheme.spacings.extraSmall),
contentPadding = PaddingValues(vertical = MaterialTheme.spacings.extraSmall),
) {
items(optionValues.count()) { optionIndex ->
Surface(
onClick = { onOptionSelected(optionValues[optionIndex]) },
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
BorderStroke(
4.dp,
Color.White,
),
shape = RoundedCornerShape(10.dp),
),
),
scale = ClickableSurfaceScale.None,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(MaterialTheme.spacings.extraSmall),
) {
RadioButton(
selected = preference.value == optionValues[optionIndex],
onClick = null,
enabled = preference.enabled,
)
Spacer(modifier = Modifier.width(MaterialTheme.spacings.medium))
Text(text = options[optionIndex], style = MaterialTheme.typography.bodyLarge)
}
}
}
}
}
}
}
@Preview
@Composable
private fun SettingsDetailCardPreview() {
FindroidTheme {
SettingsDetailsCard(
preference = PreferenceSelect(
nameStringResource = CoreR.string.settings_preferred_audio_language,
backendName = Constants.PREF_AUDIO_LANGUAGE,
backendDefaultValue = null,
options = CoreR.array.languages,
optionValues = CoreR.array.languages_values,
),
onOptionSelected = {},
)
}
}

View file

@ -0,0 +1,124 @@
package dev.jdtech.jellyfin.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ClickableSurfaceScale
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import dev.jdtech.jellyfin.Constants
import dev.jdtech.jellyfin.models.PreferenceSelect
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
import dev.jdtech.jellyfin.core.R as CoreR
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun SettingsSelectCard(
preference: PreferenceSelect,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val options = stringArrayResource(id = preference.options)
val optionValues = stringArrayResource(id = preference.optionValues)
Surface(
onClick = onClick,
enabled = preference.enabled,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = MaterialTheme.colorScheme.surface,
focusedContainerColor = MaterialTheme.colorScheme.surface,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
BorderStroke(
4.dp,
Color.White,
),
shape = RoundedCornerShape(10.dp),
),
),
scale = ClickableSurfaceScale.None,
modifier = modifier
.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(MaterialTheme.spacings.default),
verticalAlignment = Alignment.CenterVertically,
) {
if (preference.iconDrawableId != null) {
Icon(
painter = painterResource(id = preference.iconDrawableId!!),
contentDescription = null,
)
Spacer(modifier = Modifier.width(24.dp))
}
Column(
modifier = Modifier.weight(1f),
) {
Text(
text = stringResource(id = preference.nameStringResource),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(MaterialTheme.spacings.extraSmall))
Text(
text = if (preference.value != null) {
val index = optionValues.indexOf(preference.value)
if (index == -1) {
"Unknown"
} else {
options[index]
}
} else {
"Not set"
},
style = MaterialTheme.typography.labelMedium,
)
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview
@Composable
private fun SettingsSelectCardPreview() {
FindroidTheme {
Surface {
SettingsSelectCard(
preference = PreferenceSelect(
nameStringResource = CoreR.string.settings_preferred_audio_language,
iconDrawableId = CoreR.drawable.ic_speaker,
backendName = Constants.PREF_AUDIO_LANGUAGE,
backendDefaultValue = null,
options = CoreR.array.languages,
optionValues = CoreR.array.languages_values,
),
onClick = {},
)
}
}
}

View file

@ -0,0 +1,159 @@
package dev.jdtech.jellyfin.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ClickableSurfaceScale
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Switch
import androidx.tv.material3.Text
import dev.jdtech.jellyfin.core.R
import dev.jdtech.jellyfin.models.PreferenceSwitch
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun SettingsSwitchCard(
preference: PreferenceSwitch,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
enabled = preference.enabled,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = MaterialTheme.colorScheme.surface,
focusedContainerColor = MaterialTheme.colorScheme.surface,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
BorderStroke(
4.dp,
Color.White,
),
shape = RoundedCornerShape(10.dp),
),
),
scale = ClickableSurfaceScale.None,
modifier = modifier
.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(MaterialTheme.spacings.default),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.small),
) {
if (preference.iconDrawableId != null) {
Icon(
painter = painterResource(id = preference.iconDrawableId!!),
contentDescription = null,
)
Spacer(modifier = Modifier.width(16.dp))
}
Column(
modifier = Modifier.weight(1f),
) {
Text(
text = stringResource(id = preference.nameStringResource),
style = MaterialTheme.typography.titleMedium,
)
preference.descriptionStringRes?.let {
Spacer(modifier = Modifier.height(MaterialTheme.spacings.extraSmall))
Text(
text = stringResource(id = it),
style = MaterialTheme.typography.labelMedium,
)
}
}
Switch(
checked = preference.value,
onCheckedChange = null,
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview
@Composable
private fun SettingsSwitchCardPreview() {
FindroidTheme {
Surface {
SettingsSwitchCard(
preference = PreferenceSwitch(
nameStringResource = R.string.settings_use_cache_title,
iconDrawableId = null,
backendName = "image-cache",
backendDefaultValue = false,
value = false,
),
onClick = {},
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview
@Composable
private fun SettingsSwitchCardDisabledPreview() {
FindroidTheme {
Surface {
SettingsSwitchCard(
preference = PreferenceSwitch(
nameStringResource = R.string.settings_use_cache_title,
iconDrawableId = null,
enabled = false,
backendName = "image-cache",
backendDefaultValue = false,
value = false,
),
onClick = {},
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Preview
@Composable
private fun SettingsSwitchCardDescriptionPreview() {
FindroidTheme {
Surface {
SettingsSwitchCard(
preference = PreferenceSwitch(
nameStringResource = R.string.settings_use_cache_title,
descriptionStringRes = R.string.settings_use_cache_summary,
iconDrawableId = null,
backendName = "image-cache",
backendDefaultValue = true,
value = true,
),
onClick = {},
)
}
}
}

View file

@ -0,0 +1,82 @@
package dev.jdtech.jellyfin.ui.components.player
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun VideoPlayerControlsLayout(
mediaTitle: @Composable () -> Unit,
seeker: @Composable () -> Unit,
mediaActions: @Composable () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
) {
Box(modifier = Modifier.weight(1f)) {
mediaTitle()
}
mediaActions()
}
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
seeker()
}
}
@Preview
@Composable
private fun VideoPlayerControlsLayoutPreview() {
FindroidTheme {
VideoPlayerControlsLayout(
mediaTitle = {
Box(
Modifier
.border(2.dp, Color.Red)
.background(Color.LightGray)
.fillMaxWidth()
.height(96.dp),
)
},
seeker = {
Box(
Modifier
.border(2.dp, Color.Red)
.background(Color.LightGray)
.fillMaxWidth()
.height(48.dp),
)
},
mediaActions = {
Box(
Modifier
.border(2.dp, Color.Red)
.background(Color.LightGray)
.fillMaxWidth()
.height(48.dp),
)
},
)
}
}

View file

@ -0,0 +1,34 @@
package dev.jdtech.jellyfin.ui.components.player
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.painter.Painter
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon
import androidx.tv.material3.IconButton
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun VideoPlayerMediaButton(
icon: Painter,
state: VideoPlayerState,
isPlaying: Boolean,
onClick: () -> Unit = {},
) {
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(isFocused && isPlaying) {
if (isFocused && isPlaying) {
state.showControls()
}
}
IconButton(onClick = onClick, interactionSource = interactionSource) {
Icon(painter = icon, contentDescription = null)
}
}

View file

@ -0,0 +1,43 @@
package dev.jdtech.jellyfin.ui.components.player
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun VideoPlayerMediaTitle(
title: String,
subtitle: String?,
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
color = Color.White,
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.titleMedium,
color = Color.White.copy(alpha = .75f),
)
}
}
}
@Preview
@Composable
private fun VideoPlayerMediaTitlePreview() {
FindroidTheme {
VideoPlayerMediaTitle(
title = "S1:E23 - Handler One",
subtitle = "86 EIGHTY-SIX",
)
}
}

View file

@ -0,0 +1,102 @@
package dev.jdtech.jellyfin.ui.components.player
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun VideoPlayerOverlay(
isPlaying: Boolean,
modifier: Modifier = Modifier,
state: VideoPlayerState = rememberVideoPlayerState(),
focusRequester: FocusRequester = remember { FocusRequester() },
controls: @Composable () -> Unit = {},
) {
LaunchedEffect(state.controlsVisible) {
if (state.controlsVisible) {
focusRequester.requestFocus()
}
}
LaunchedEffect(isPlaying) {
if (!isPlaying) {
state.showControls(seconds = Int.MAX_VALUE)
} else {
state.showControls()
}
}
AnimatedVisibility(
visible = state.controlsVisible,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.BottomCenter,
) {
Spacer(
modifier = modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(
Color.Black.copy(alpha = 0.4f),
Color.Black.copy(alpha = 0.8f),
),
),
),
)
Column(
Modifier.padding(MaterialTheme.spacings.default * 2),
) {
controls()
}
}
}
}
@Preview(device = "id:tv_4k")
@Composable
private fun VideoPlayerOverlayPreview() {
FindroidTheme {
Box(Modifier.fillMaxSize()) {
VideoPlayerOverlay(
modifier = Modifier.align(Alignment.BottomCenter),
isPlaying = true,
controls = {
Box(
Modifier
.fillMaxWidth()
.height(120.dp)
.background(Color.Blue),
)
},
)
}
}
}

View file

@ -0,0 +1,133 @@
package dev.jdtech.jellyfin.ui.components.player
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.utils.handleDPadKeyEvents
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun VideoPlayerSeekBar(
progress: Float,
onSeek: (seekProgress: Float) -> Unit,
state: VideoPlayerState,
) {
val interactionSource = remember { MutableInteractionSource() }
var isSelected by remember { mutableStateOf(false) }
val isFocused by interactionSource.collectIsFocusedAsState()
val color by rememberUpdatedState(
newValue = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
val animatedHeight by animateDpAsState(
targetValue = 8.dp.times(if (isFocused) 2f else 1f),
)
var seekProgress by remember { mutableFloatStateOf(0f) }
val focusManager = LocalFocusManager.current
LaunchedEffect(isSelected) {
if (isSelected) {
state.showControls(seconds = Int.MAX_VALUE)
}
}
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(animatedHeight)
.padding(horizontal = 4.dp)
.handleDPadKeyEvents(
onEnter = {
if (isSelected) {
onSeek(seekProgress)
focusManager.moveFocus(FocusDirection.Exit)
} else {
seekProgress = progress
}
isSelected = !isSelected
},
onLeft = {
if (isSelected) {
seekProgress = (seekProgress - 0.05f).coerceAtLeast(0f)
} else {
focusManager.moveFocus(FocusDirection.Left)
}
},
onRight = {
if (isSelected) {
seekProgress = (seekProgress + 0.05f).coerceAtMost(1f)
} else {
focusManager.moveFocus(FocusDirection.Right)
}
},
)
.focusable(interactionSource = interactionSource),
) {
val yOffset = size.height.div(2)
drawLine(
color = color.copy(alpha = 0.24f),
start = Offset(x = 0f, y = yOffset),
end = Offset(x = size.width, y = yOffset),
strokeWidth = size.height.div(2),
cap = StrokeCap.Round,
)
drawLine(
color = color,
start = Offset(x = 0f, y = yOffset),
end = Offset(
x = size.width.times(if (isSelected) seekProgress else progress),
y = yOffset,
),
strokeWidth = size.height.div(2),
cap = StrokeCap.Round,
)
drawCircle(
color = Color.White,
radius = size.height.div(2),
center = Offset(
x = size.width.times(if (isSelected) seekProgress else progress),
y = yOffset,
),
)
}
}
@Preview
@Composable
fun VideoPlayerSeekBarPreview() {
FindroidTheme {
VideoPlayerSeekBar(
progress = 0.4f,
onSeek = {},
state = rememberVideoPlayerState(),
)
}
}

View file

@ -0,0 +1,120 @@
package dev.jdtech.jellyfin.ui.components.player
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
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.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon
import androidx.tv.material3.IconButton
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
import kotlin.time.Duration
import dev.jdtech.jellyfin.core.R as CoreR
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun VideoPlayerSeeker(
focusRequester: FocusRequester,
state: VideoPlayerState,
isPlaying: Boolean,
onPlayPauseToggle: (Boolean) -> Unit,
onSeek: (Float) -> Unit,
contentProgress: Duration,
contentDuration: Duration,
) {
val contentProgressString =
contentProgress.toComponents { h, m, s, _ ->
if (h > 0) {
"$h:${m.padStartWith0()}:${s.padStartWith0()}"
} else {
"${m.padStartWith0()}:${s.padStartWith0()}"
}
}
val contentDurationString =
contentDuration.toComponents { h, m, s, _ ->
if (h > 0) {
"$h:${m.padStartWith0()}:${s.padStartWith0()}"
} else {
"${m.padStartWith0()}:${s.padStartWith0()}"
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = {
onPlayPauseToggle(!isPlaying)
},
modifier = Modifier.focusRequester(focusRequester),
) {
if (!isPlaying) {
Icon(
painter = painterResource(id = CoreR.drawable.ic_play),
contentDescription = null,
)
} else {
Icon(
painter = painterResource(id = CoreR.drawable.ic_pause),
contentDescription = null,
)
}
}
Spacer(modifier = Modifier.width(MaterialTheme.spacings.medium))
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = contentProgressString,
style = MaterialTheme.typography.bodyMedium,
color = Color.White,
)
Text(
text = contentDurationString,
style = MaterialTheme.typography.bodyMedium,
color = Color.White,
)
}
Spacer(modifier = Modifier.height(MaterialTheme.spacings.small))
VideoPlayerSeekBar(
progress = (contentProgress / contentDuration).toFloat(),
onSeek = onSeek,
state = state,
)
}
}
}
@Preview
@Composable
private fun VideoPlayerSeekerPreview() {
FindroidTheme {
VideoPlayerSeeker(
focusRequester = FocusRequester(),
state = rememberVideoPlayerState(),
isPlaying = false,
onPlayPauseToggle = {},
onSeek = {},
contentProgress = Duration.parse("7m 51s"),
contentDuration = Duration.parse("23m 40s"),
)
}
}
private fun Number.padStartWith0() = this.toString().padStart(2, '0')

View file

@ -0,0 +1,45 @@
package dev.jdtech.jellyfin.ui.components.player
import androidx.annotation.IntRange
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 kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.debounce
class VideoPlayerState internal constructor(
@IntRange(from = 0)
private val hideSeconds: Int,
) {
private var _controlsVisible by mutableStateOf(true)
val controlsVisible get() = _controlsVisible
fun showControls(seconds: Int = hideSeconds) {
_controlsVisible = true
channel.trySend(seconds)
}
private val channel = Channel<Int>(CONFLATED)
@OptIn(FlowPreview::class)
suspend fun observe() {
channel.consumeAsFlow()
.debounce { it.toLong() * 1000 }
.collect { _controlsVisible = false }
}
}
@Composable
fun rememberVideoPlayerState(@IntRange(from = 0) hideSeconds: Int = 2) =
remember {
VideoPlayerState(hideSeconds = hideSeconds)
}
.also {
LaunchedEffect(it) { it.observe() }
}

View file

@ -0,0 +1,12 @@
package dev.jdtech.jellyfin.ui.dialogs
import androidx.compose.ui.window.DialogProperties
import com.ramcosta.composedestinations.spec.DestinationStyle
object BaseDialogStyle : DestinationStyle.Dialog {
override val properties = DialogProperties(
dismissOnClickOutside = false,
dismissOnBackPress = true,
usePlatformDefaultWidth = false,
)
}

View file

@ -0,0 +1,120 @@
package dev.jdtech.jellyfin.ui.dialogs
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.width
import androidx.compose.foundation.shape.RoundedCornerShape
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.foundation.lazy.list.TvLazyColumn
import androidx.tv.foundation.lazy.list.items
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ClickableSurfaceScale
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.RadioButton
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.result.EmptyResultBackNavigator
import com.ramcosta.composedestinations.result.ResultBackNavigator
import dev.jdtech.jellyfin.models.Track
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
@OptIn(ExperimentalTvMaterial3Api::class)
@Destination(style = BaseDialogStyle::class)
@Composable
fun VideoPlayerTrackSelectorDialog(
tracks: ArrayList<Track>,
resultNavigator: ResultBackNavigator<Int>,
) {
Surface {
Column(
modifier = Modifier.padding(MaterialTheme.spacings.medium),
) {
Text(
text = "Select track",
style = MaterialTheme.typography.headlineMedium,
)
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
TvLazyColumn(
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium - MaterialTheme.spacings.extraSmall),
contentPadding = PaddingValues(vertical = MaterialTheme.spacings.extraSmall),
) {
items(tracks) { track ->
Surface(
onClick = {
resultNavigator.navigateBack(result = track.id)
},
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
BorderStroke(
4.dp,
Color.White,
),
shape = RoundedCornerShape(10.dp),
),
),
scale = ClickableSurfaceScale.None,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(MaterialTheme.spacings.extraSmall),
) {
RadioButton(
selected = track.selected,
onClick = null,
enabled = true,
)
Spacer(modifier = Modifier.width(MaterialTheme.spacings.medium))
Text(text = listOf(track.label, track.language, track.codec).mapNotNull { it }.joinToString(" - "), style = MaterialTheme.typography.bodyLarge)
}
}
}
}
}
}
}
@Preview
@Composable
private fun VideoPlayerTrackSelectorDialogPreview() {
FindroidTheme {
VideoPlayerTrackSelectorDialog(
tracks = arrayListOf(
Track(
id = 0,
label = null,
language = "English",
codec = "flac",
selected = true,
),
Track(
id = 0,
label = null,
language = "Japanese",
codec = "flac",
selected = false,
),
),
resultNavigator = EmptyResultBackNavigator(),
)
}
}

View file

@ -0,0 +1,25 @@
package dev.jdtech.jellyfin.ui.dummy
import dev.jdtech.jellyfin.models.CollectionType
import dev.jdtech.jellyfin.models.FindroidCollection
import dev.jdtech.jellyfin.models.FindroidImages
import java.util.UUID
private val dummyMoviesCollection = FindroidCollection(
id = UUID.randomUUID(),
name = "Movies",
type = CollectionType.Movies,
images = FindroidImages(),
)
private val dummyShowsCollection = FindroidCollection(
id = UUID.randomUUID(),
name = "Shows",
type = CollectionType.TvShows,
images = FindroidImages(),
)
val dummyCollections = listOf(
dummyMoviesCollection,
dummyShowsCollection,
)

View file

@ -0,0 +1,66 @@
package dev.jdtech.jellyfin.ui.dummy
import dev.jdtech.jellyfin.models.EpisodeItem
import dev.jdtech.jellyfin.models.FindroidEpisode
import dev.jdtech.jellyfin.models.FindroidImages
import dev.jdtech.jellyfin.models.FindroidMediaStream
import dev.jdtech.jellyfin.models.FindroidSource
import dev.jdtech.jellyfin.models.FindroidSourceType
import org.jellyfin.sdk.model.api.MediaStreamType
import java.time.LocalDateTime
import java.util.UUID
val dummyEpisode = FindroidEpisode(
id = UUID.randomUUID(),
name = "Mother and Children",
originalTitle = null,
overview = "Stories are lies meant to entertain, and idols lie to fans eager to believe. This is Ais story. It is a lie, but it is also true.",
indexNumber = 1,
indexNumberEnd = null,
parentIndexNumber = 1,
sources = listOf(
FindroidSource(
id = "",
name = "",
type = FindroidSourceType.REMOTE,
path = "",
size = 0L,
mediaStreams = listOf(
FindroidMediaStream(
title = "",
displayTitle = "",
language = "en",
type = MediaStreamType.VIDEO,
codec = "hevc",
isExternal = false,
path = "",
channelLayout = null,
videoRangeType = null,
height = 1080,
width = 1920,
videoDoViTitle = null,
),
),
),
),
played = true,
favorite = true,
canPlay = true,
canDownload = true,
runtimeTicks = 20L,
playbackPositionTicks = 0L,
premiereDate = LocalDateTime.parse("2019-02-14T00:00:00"),
seriesName = "Oshi no Ko",
seriesId = UUID.randomUUID(),
seasonId = UUID.randomUUID(),
communityRating = 9.2f,
images = FindroidImages(),
)
val dummyEpisodes = listOf(
dummyEpisode,
)
val dummyEpisodeItems = listOf(
EpisodeItem.Episode(dummyEpisode),
)

View file

@ -0,0 +1,26 @@
package dev.jdtech.jellyfin.ui.dummy
import dev.jdtech.jellyfin.models.CollectionType
import dev.jdtech.jellyfin.models.HomeItem
import dev.jdtech.jellyfin.models.HomeSection
import dev.jdtech.jellyfin.models.UiText
import dev.jdtech.jellyfin.models.View
import java.util.UUID
val dummyHomeItems = listOf(
HomeItem.Section(
HomeSection(
id = UUID.randomUUID(),
name = UiText.DynamicString("Continue watching"),
items = dummyMovies + dummyEpisodes,
),
),
HomeItem.ViewItem(
View(
id = UUID.randomUUID(),
name = "Movies",
items = dummyMovies,
type = CollectionType.Movies,
),
),
)

View file

@ -0,0 +1,62 @@
package dev.jdtech.jellyfin.ui.dummy
import dev.jdtech.jellyfin.models.FindroidImages
import dev.jdtech.jellyfin.models.FindroidMediaStream
import dev.jdtech.jellyfin.models.FindroidMovie
import dev.jdtech.jellyfin.models.FindroidSource
import dev.jdtech.jellyfin.models.FindroidSourceType
import org.jellyfin.sdk.model.api.MediaStreamType
import java.time.LocalDateTime
import java.util.UUID
val dummyMovie = FindroidMovie(
id = UUID.randomUUID(),
name = "Alita: Battle Angel",
originalTitle = null,
overview = "When Alita awakens with no memory of who she is in a future world she does not recognize, she is taken in by Ido, a compassionate doctor who realizes that somewhere in this abandoned cyborg shell is the heart and soul of a young woman with an extraordinary past.",
sources = listOf(
FindroidSource(
id = "",
name = "",
type = FindroidSourceType.REMOTE,
path = "",
size = 0L,
mediaStreams = listOf(
FindroidMediaStream(
title = "",
displayTitle = "",
language = "en",
type = MediaStreamType.VIDEO,
codec = "hevc",
isExternal = false,
path = "",
channelLayout = null,
videoRangeType = null,
height = 1080,
width = 1920,
videoDoViTitle = null,
),
),
),
),
played = false,
favorite = true,
canPlay = true,
canDownload = true,
runtimeTicks = 20L,
playbackPositionTicks = 15L,
premiereDate = LocalDateTime.parse("2019-02-14T00:00:00"),
people = emptyList(),
genres = listOf("Action", "Sience Fiction", "Adventure"),
communityRating = 7.2f,
officialRating = "PG-13",
status = "Ended",
productionYear = 2019,
endDate = null,
trailer = "https://www.youtube.com/watch?v=puKWa8hrvA8",
images = FindroidImages(),
)
val dummyMovies = listOf(
dummyMovie,
)

View file

@ -0,0 +1,22 @@
package dev.jdtech.jellyfin.ui.dummy
import dev.jdtech.jellyfin.models.DiscoveredServer
import dev.jdtech.jellyfin.models.Server
import java.util.UUID
val dummyDiscoveredServer = DiscoveredServer(
id = "",
name = "Demo server",
address = "https://demo.jellyfin.org/stable",
)
val dummyDiscoveredServers = listOf(dummyDiscoveredServer)
val dummyServer = Server(
id = "",
name = "Demo server",
currentServerAddressId = UUID.randomUUID(),
currentUserId = UUID.randomUUID(),
)
val dummyServers = listOf(dummyServer)

View file

@ -0,0 +1,30 @@
package dev.jdtech.jellyfin.ui.dummy
import dev.jdtech.jellyfin.models.FindroidImages
import dev.jdtech.jellyfin.models.FindroidShow
import java.time.LocalDateTime
import java.util.UUID
val dummyShow = FindroidShow(
id = UUID.randomUUID(),
name = "Attack on Titan",
originalTitle = null,
overview = "After his hometown is destroyed and his mother is killed, young Eren Yeager vows to cleanse the earth of the giant humanoid Titans that have brought humanity to the brink of extinction.",
sources = emptyList(),
played = false,
favorite = false,
canPlay = true,
canDownload = false,
runtimeTicks = 0L,
communityRating = 8.8f,
endDate = LocalDateTime.parse("2023-11-04T00:00:00"),
genres = listOf("Action", "Sience Fiction", "Adventure"),
images = FindroidImages(),
officialRating = "TV-MA",
people = emptyList(),
productionYear = 2013,
seasons = emptyList(),
status = "Ended",
trailer = null,
unplayedItemCount = 20,
)

View file

@ -0,0 +1,12 @@
package dev.jdtech.jellyfin.ui.dummy
import dev.jdtech.jellyfin.models.User
import java.util.UUID
val dummyUser = User(
id = UUID.randomUUID(),
name = "Username",
serverId = "",
)
val dummyUsers = listOf(dummyUser)

View file

@ -0,0 +1,34 @@
package dev.jdtech.jellyfin.ui.theme
import androidx.compose.material3.darkColorScheme
import androidx.compose.ui.graphics.Color
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.darkColorScheme as darkColorSchemeTv
val PrimaryDark = Color(0xffa1c9ff)
val OnPrimaryDark = Color(0xff00315e)
val PrimaryContainerDark = Color(0xff004884)
val OnPrimaryContainerDark = Color(0xffd3e4ff)
val Neutral900 = Color(0xff121A21)
val Neutral1000 = Color(0xff000000)
val Yellow = Color(0xFFF2C94C)
val ColorScheme = darkColorScheme(
primary = PrimaryDark,
onPrimary = OnPrimaryDark,
primaryContainer = PrimaryContainerDark,
onPrimaryContainer = OnPrimaryContainerDark,
surface = Neutral900,
background = Neutral1000,
)
@OptIn(ExperimentalTvMaterial3Api::class)
val ColorSchemeTv = darkColorSchemeTv(
primary = ColorScheme.primary,
onPrimary = ColorScheme.onPrimary,
primaryContainer = ColorScheme.primaryContainer,
onPrimaryContainer = ColorScheme.onPrimaryContainer,
surface = ColorScheme.surface,
background = ColorScheme.background,
)

View file

@ -0,0 +1,18 @@
package dev.jdtech.jellyfin.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Shapes as ShapesTv
val shapes = Shapes(
extraSmall = RoundedCornerShape(10.dp),
small = RoundedCornerShape(10.dp),
)
@OptIn(ExperimentalTvMaterial3Api::class)
val shapesTv = ShapesTv(
extraSmall = shapes.extraSmall,
small = shapes.small,
)

View file

@ -0,0 +1,25 @@
package dev.jdtech.jellyfin.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
@Immutable
data class Spacings(
val default: Dp = 24.dp,
val extraSmall: Dp = 4.dp,
val small: Dp = 8.dp,
val medium: Dp = 16.dp,
val large: Dp = 32.dp,
val extraLarge: Dp = 64.dp,
)
@OptIn(ExperimentalTvMaterial3Api::class)
val MaterialTheme.spacings
get() = Spacings()
@OptIn(ExperimentalTvMaterial3Api::class)
val LocalSpacings = compositionLocalOf { MaterialTheme.spacings }

View file

@ -0,0 +1,30 @@
package dev.jdtech.jellyfin.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme as MaterialThemeTv
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun FindroidTheme(
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = ColorScheme,
typography = Typography,
shapes = shapes,
) {
CompositionLocalProvider(
LocalSpacings provides Spacings(),
) {
MaterialThemeTv(
colorScheme = ColorSchemeTv,
typography = TypographyTv,
shapes = shapesTv,
content = content,
)
}
}
}

View file

@ -0,0 +1,45 @@
package dev.jdtech.jellyfin.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Typography as TypographyTv
val Typography = Typography(
displayMedium = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 48.sp,
),
headlineMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 24.sp,
),
titleMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
),
titleSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
),
labelMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
),
)
@OptIn(ExperimentalTvMaterial3Api::class)
val TypographyTv = TypographyTv(
displayMedium = Typography.displayMedium,
headlineMedium = Typography.headlineMedium,
titleMedium = Typography.titleMedium,
titleSmall = Typography.titleSmall,
bodyMedium = Typography.bodyMedium,
labelMedium = Typography.labelMedium,
)

View file

@ -0,0 +1,76 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="1536dp"
android:height="512dp"
android:viewportWidth="1536"
android:viewportHeight="512">
<path
android:pathData="m307.94,275.03 l15.78,-27.34c2.19,-3.79 -3.5,-7.08 -5.69,-3.29l-15.98,27.69c-12.22,-5.58 -25.95,-8.69 -40.6,-8.69 -14.65,0 -28.38,3.11 -40.6,8.69l-15.98,-27.69c-2.19,-3.8 -7.88,-0.51 -5.69,3.28l15.79,27.34c-27.11,14.74 -45.65,42.19 -48.36,74.61h189.69c-2.71,-32.42 -21.25,-59.86 -48.36,-74.61m-46.46,-252.33c-61.81,0 -260.7,360.64 -230.39,421.55 30.31,60.91 430.79,60.21 460.79,0 30,-60.21 -168.58,-421.55 -230.39,-421.55zM412.5,391.47c-19.67,39.44 -282.07,39.94 -301.94,0 -19.87,-39.94 110.48,-276.25 150.92,-276.25s170.69,236.72 151.02,276.25z"
android:strokeWidth=".14885">
<aapt:attr name="android:fillColor">
<gradient
android:startY="236.15994"
android:startX="169.06345"
android:endY="374.69077"
android:endX="409.00986"
android:type="linear">
<item android:offset="0" android:color="#FF3DDC84"/>
<item android:offset="1" android:color="#FF00A4DC"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="m569.69,357.11q-3.73,0 -6.13,-2.4 -2.13,-2.4 -2.13,-5.6v-170.67q0,-3.2 2.4,-5.6t5.6,-2.4h97.07q3.47,0 5.6,2.4 2.4,2.13 2.4,5.6 0,3.2 -2.4,5.6 -2.13,2.13 -5.6,2.13h-89.87l1.07,-1.6v70.93l-1.33,-2.4h78.13q3.47,0 5.6,2.4 2.4,2.13 2.4,5.33 0,3.47 -2.4,5.6 -2.13,2.13 -5.6,2.13h-78.67l1.87,-2.13v82.67q0,3.2 -2.4,5.6 -2.13,2.4 -5.6,2.4z"
android:fillColor="#fff"/>
<path
android:pathData="m723.83,349.11q0,3.2 -2.4,5.6t-5.6,2.4q-3.47,0 -5.87,-2.4 -2.13,-2.4 -2.13,-5.6v-122.67q0,-3.2 2.13,-5.6 2.4,-2.4 5.87,-2.4t5.6,2.4q2.4,2.4 2.4,5.6zM715.83,200.58q-5.6,0 -8.53,-2.4 -2.67,-2.67 -2.67,-7.47v-2.67q0,-4.8 2.93,-7.2 3.2,-2.67 8.53,-2.67 5.07,0 7.73,2.67 2.93,2.4 2.93,7.2v2.67q0,4.8 -2.93,7.47 -2.67,2.4 -8,2.4z"
android:fillColor="#fff"/>
<path
android:pathData="m829.69,217.65q17.6,0 28,7.2 10.67,6.93 15.2,19.2 4.8,12 4.8,26.67v78.4q0,3.2 -2.4,5.6t-5.6,2.4q-3.73,0 -5.87,-2.4t-2.13,-5.6v-77.6q0,-10.67 -3.47,-19.47t-11.47,-14.13q-7.73,-5.33 -20.53,-5.33 -11.47,0 -21.87,5.33 -10.13,5.33 -16.53,14.13 -6.4,8.8 -6.4,19.47v77.6q0,3.2 -2.4,5.6 -2.4,2.4 -5.6,2.4 -3.73,0 -5.87,-2.4 -2.13,-2.4 -2.13,-5.6v-119.47q0,-3.2 2.13,-5.6 2.4,-2.4 5.87,-2.4t5.6,2.4q2.4,2.4 2.4,5.6v22.4l-6.13,9.6q0.53,-8.53 5.33,-16.27 5.07,-8 12.8,-14.13 7.73,-6.4 17.07,-9.87 9.6,-3.73 19.2,-3.73z"
android:fillColor="#fff"/>
<path
android:pathData="m1030.2,159.78q3.47,0 5.6,2.4 2.4,2.13 2.4,5.6v181.33q0,3.2 -2.4,5.6t-5.6,2.4q-3.73,0 -5.87,-2.4 -2.13,-2.4 -2.13,-5.6v-31.73l4.53,-3.73q0,7.47 -4,15.73 -4,8 -11.47,14.93 -7.2,6.93 -17.07,11.2 -9.6,4.27 -21.07,4.27 -17.6,0 -32,-9.33 -14.13,-9.33 -22.4,-25.33 -8.27,-16 -8.27,-36.53 0,-20.27 8.27,-36.27 8.27,-16.27 22.4,-25.33 14.13,-9.33 31.73,-9.33 11.2,0 21.07,4 9.87,4 17.33,10.93 7.73,6.93 12,16 4.53,8.8 4.53,18.4l-5.6,-4v-95.2q0,-3.2 2.13,-5.6 2.13,-2.4 5.87,-2.4zM974.73,344.85q14.13,0 25.07,-7.2 10.93,-7.47 17.07,-20 6.4,-12.8 6.4,-29.07 0,-16 -6.4,-28.53 -6.13,-12.8 -17.07,-20 -10.93,-7.47 -25.07,-7.47 -13.87,0 -25.07,7.47 -10.93,7.2 -17.33,20 -6.13,12.53 -6.13,28.53t6.13,28.8q6.4,12.8 17.33,20.27 11.2,7.2 25.07,7.2z"
android:fillColor="#fff"/>
<path
android:pathData="m1088.1,357.11q-3.73,0 -5.87,-2.4 -2.13,-2.4 -2.13,-5.6v-119.47q0,-3.2 2.13,-5.6 2.4,-2.4 5.87,-2.4t5.6,2.4q2.4,2.4 2.4,5.6v40l-4,0.8q0.8,-9.33 4.53,-18.4 4,-9.33 10.67,-17.07t15.73,-12.53q9.33,-4.8 20.8,-4.8 4.8,0 9.33,2.13 4.53,1.87 4.53,6.4 0,4 -2.13,6.13 -2.13,2.13 -5.07,2.13 -2.4,0 -5.33,-1.33 -2.67,-1.33 -7.2,-1.33 -7.47,0 -14.93,4.53 -7.47,4.27 -13.6,11.73 -6.13,7.47 -9.87,16.8 -3.47,9.07 -3.47,18.4v65.87q0,3.2 -2.4,5.6t-5.6,2.4z"
android:fillColor="#fff"/>
<path
android:pathData="m1302.8,288.85q0,20.27 -9.07,36.53 -8.8,16 -24,25.33 -15.2,9.07 -34.4,9.07 -18.93,0 -34.4,-9.07 -15.2,-9.33 -24.27,-25.33 -8.8,-16.27 -8.8,-36.53 0,-20.53 8.8,-36.53 9.07,-16 24.27,-25.33 15.47,-9.33 34.4,-9.33 19.2,0 34.4,9.33 15.2,9.33 24,25.33 9.07,16 9.07,36.53zM1286.8,288.85q0,-16.27 -6.67,-28.8 -6.67,-12.8 -18.4,-20 -11.47,-7.47 -26.4,-7.47 -14.67,0 -26.4,7.47 -11.47,7.2 -18.4,20 -6.67,12.53 -6.67,28.8 0,16.27 6.67,28.8 6.93,12.53 18.4,20 11.73,7.2 26.4,7.2 14.93,0 26.4,-7.2 11.73,-7.47 18.4,-20 6.67,-12.53 6.67,-28.8z"
android:fillColor="#fff"/>
<path
android:pathData="m1351,349.11q0,3.2 -2.4,5.6t-5.6,2.4q-3.47,0 -5.87,-2.4 -2.13,-2.4 -2.13,-5.6v-122.67q0,-3.2 2.13,-5.6 2.4,-2.4 5.87,-2.4 3.47,0 5.6,2.4 2.4,2.4 2.4,5.6zM1343,200.58q-5.6,0 -8.53,-2.4 -2.67,-2.67 -2.67,-7.47v-2.67q0,-4.8 2.93,-7.2 3.2,-2.67 8.53,-2.67 5.07,0 7.73,2.67 2.93,2.4 2.93,7.2v2.67q0,4.8 -2.93,7.47 -2.67,2.4 -8,2.4z"
android:fillColor="#fff"/>
<path
android:pathData="m1503.3,159.78q3.47,0 5.6,2.4 2.4,2.13 2.4,5.6v181.33q0,3.2 -2.4,5.6t-5.6,2.4q-3.73,0 -5.87,-2.4 -2.13,-2.4 -2.13,-5.6v-31.73l4.53,-3.73q0,7.47 -4,15.73 -4,8 -11.47,14.93 -7.2,6.93 -17.07,11.2 -9.6,4.27 -21.07,4.27 -17.6,0 -32,-9.33 -14.13,-9.33 -22.4,-25.33 -8.27,-16 -8.27,-36.53 0,-20.27 8.27,-36.27 8.27,-16.27 22.4,-25.33 14.13,-9.33 31.73,-9.33 11.2,0 21.07,4 9.87,4 17.33,10.93 7.73,6.93 12,16 4.53,8.8 4.53,18.4l-5.6,-4v-95.2q0,-3.2 2.13,-5.6 2.13,-2.4 5.87,-2.4zM1447.83,344.85q14.13,0 25.07,-7.2 10.93,-7.47 17.07,-20 6.4,-12.8 6.4,-29.07 0,-16 -6.4,-28.53 -6.13,-12.8 -17.07,-20 -10.93,-7.47 -25.07,-7.47 -13.87,0 -25.07,7.47 -10.93,7.2 -17.33,20 -6.13,12.53 -6.13,28.53t6.13,28.8q6.4,12.8 17.33,20.27 11.2,7.2 25.07,7.2z"
android:fillColor="#fff"/>
<path
android:pathData="m1093.4,453.74q-1.19,0 -1.96,-0.77 -0.68,-0.77 -0.68,-1.79v-54.61q0,-1.02 0.77,-1.79t1.79,-0.77h31.06q1.11,0 1.79,0.77 0.77,0.68 0.77,1.79 0,1.02 -0.77,1.79 -0.68,0.68 -1.79,0.68h-28.76l0.34,-0.51v22.7l-0.43,-0.77h25q1.11,0 1.79,0.77 0.77,0.68 0.77,1.71 0,1.11 -0.77,1.79 -0.68,0.68 -1.79,0.68h-25.17l0.6,-0.68v26.45q0,1.02 -0.77,1.79 -0.68,0.77 -1.79,0.77z"
android:fillColor="#fff"/>
<path
android:pathData="m1176.8,431.9q0,6.49 -2.9,11.69 -2.82,5.12 -7.68,8.11 -4.86,2.9 -11.01,2.9 -6.06,0 -11.01,-2.9 -4.86,-2.99 -7.77,-8.11 -2.82,-5.21 -2.82,-11.69 0,-6.57 2.82,-11.69 2.9,-5.12 7.77,-8.11 4.95,-2.99 11.01,-2.99 6.14,0 11.01,2.99 4.86,2.99 7.68,8.11 2.9,5.12 2.9,11.69zM1171.68,431.9q0,-5.21 -2.13,-9.22 -2.13,-4.1 -5.89,-6.4 -3.67,-2.39 -8.45,-2.39 -4.69,0 -8.45,2.39 -3.67,2.3 -5.89,6.4 -2.13,4.01 -2.13,9.22 0,5.21 2.13,9.22 2.22,4.01 5.89,6.4 3.75,2.3 8.45,2.3 4.78,0 8.45,-2.3 3.75,-2.39 5.89,-6.4 2.13,-4.01 2.13,-9.22z"
android:fillColor="#fff"/>
<path
android:pathData="m1189.9,453.74q-1.19,0 -1.88,-0.77 -0.68,-0.77 -0.68,-1.79v-38.23q0,-1.02 0.68,-1.79 0.77,-0.77 1.88,-0.77 1.11,0 1.79,0.77 0.77,0.77 0.77,1.79v12.8l-1.28,0.26q0.26,-2.99 1.45,-5.89 1.28,-2.99 3.41,-5.46 2.13,-2.47 5.03,-4.01 2.99,-1.54 6.66,-1.54 1.54,0 2.99,0.68 1.45,0.6 1.45,2.05 0,1.28 -0.68,1.96 -0.68,0.68 -1.62,0.68 -0.77,0 -1.71,-0.43 -0.85,-0.43 -2.3,-0.43 -2.39,0 -4.78,1.45 -2.39,1.37 -4.35,3.75 -1.96,2.39 -3.16,5.38 -1.11,2.9 -1.11,5.89v21.08q0,1.02 -0.77,1.79t-1.79,0.77z"
android:fillColor="#fff"/>
<path
android:pathData="m1258.9,454.59q-5.38,0 -9.64,-2.99 -4.27,-2.99 -6.49,-7.85 -0.43,-0.77 -0.43,-1.37 0,-1.11 0.85,-1.71 0.85,-0.68 1.71,-0.68t1.37,0.43q0.6,0.43 1.02,1.02 1.62,3.58 4.69,5.8 3.07,2.22 6.91,2.22 3.93,0 6.91,-1.62 2.99,-1.71 4.61,-4.69 1.71,-2.99 1.71,-6.83v-39.76q0,-1.02 0.77,-1.79 0.85,-0.77 1.96,-0.77 1.19,0 1.88,0.77 0.77,0.77 0.77,1.79v39.76q0,5.29 -2.39,9.47 -2.39,4.1 -6.57,6.49 -4.18,2.3 -9.64,2.3z"
android:fillColor="#fff"/>
<path
android:pathData="m1311.6,454.59q-6.57,0 -11.6,-2.82t-7.85,-7.85q-2.82,-5.03 -2.82,-11.78 0,-7.25 2.82,-12.37 2.9,-5.12 7.42,-7.85 4.61,-2.82 9.73,-2.82 3.75,0 7.25,1.37 3.58,1.28 6.31,3.93 2.73,2.56 4.44,6.31 1.71,3.75 1.79,8.7 0,1.02 -0.77,1.79 -0.77,0.68 -1.79,0.68h-34.22l-1.02,-4.61h33.62l-1.11,1.02v-1.71q-0.43,-4.01 -2.65,-6.83 -2.22,-2.82 -5.38,-4.27 -3.07,-1.45 -6.49,-1.45 -2.56,0 -5.29,1.02 -2.65,1.02 -4.86,3.24 -2.13,2.13 -3.5,5.55 -1.37,3.33 -1.37,7.94 0,5.03 2.05,9.13 2.05,4.1 5.89,6.49 3.84,2.39 9.3,2.39 2.9,0 5.29,-0.85 2.39,-0.85 4.18,-2.22 1.88,-1.45 3.07,-2.99 0.94,-0.77 1.79,-0.77 0.94,0 1.54,0.68 0.68,0.68 0.68,1.54 0,1.02 -0.85,1.79 -2.56,3.07 -6.66,5.38 -4.1,2.22 -8.96,2.22z"
android:fillColor="#fff"/>
<path
android:pathData="m1345.4,451.18q0,1.02 -0.77,1.79t-1.79,0.77q-1.11,0 -1.88,-0.77 -0.68,-0.77 -0.68,-1.79v-58.03q0,-1.02 0.77,-1.79t1.79,-0.77q1.11,0 1.79,0.77 0.77,0.77 0.77,1.79z"
android:fillColor="#fff"/>
<path
android:pathData="m1365,451.18q0,1.02 -0.77,1.79t-1.79,0.77q-1.11,0 -1.88,-0.77 -0.68,-0.77 -0.68,-1.79v-58.03q0,-1.02 0.77,-1.79t1.79,-0.77q1.11,0 1.79,0.77 0.77,0.77 0.77,1.79z"
android:fillColor="#fff"/>
<path
android:pathData="m1410.2,409.37q1.11,0 1.79,0.77 0.77,0.77 0.77,1.79v37.63q0,6.91 -2.73,11.6 -2.73,4.78 -7.34,7.17 -4.61,2.47 -10.5,2.47 -3.67,0 -6.83,-0.85 -3.07,-0.77 -5.03,-2.05 -1.02,-0.6 -1.54,-1.45t-0.09,-1.79q0.43,-1.19 1.28,-1.62 0.94,-0.34 1.88,0.09 1.45,0.77 4.18,1.88 2.73,1.11 6.23,1.11 4.69,0 8.11,-1.96 3.5,-1.96 5.38,-5.72 1.88,-3.67 1.88,-8.79v-6.14l0.6,2.05q-1.28,2.65 -3.67,4.69 -2.3,2.05 -5.38,3.24 -2.99,1.11 -6.4,1.11 -5.12,0 -8.53,-2.05 -3.33,-2.13 -4.95,-5.8t-1.62,-8.62v-26.2q0,-1.02 0.68,-1.79 0.68,-0.77 1.88,-0.77 1.11,0 1.79,0.77 0.77,0.77 0.77,1.79v25.43q0,5.97 2.56,9.22 2.65,3.24 8.53,3.24 3.67,0 6.74,-1.71 3.07,-1.79 5.03,-4.61 1.96,-2.9 1.96,-6.14v-25.43q0,-1.02 0.68,-1.79 0.77,-0.77 1.88,-0.77z"
android:fillColor="#fff"/>
<path
android:pathData="m1441.9,390.94q1.28,0 2.73,0.26 1.54,0.26 2.65,0.94 1.11,0.6 1.11,1.88 0,0.94 -0.68,1.71 -0.68,0.68 -1.54,0.68 -0.85,0 -2.13,-0.43 -1.28,-0.51 -2.73,-0.51 -1.79,0 -3.07,0.85 -1.28,0.77 -1.96,2.3 -0.68,1.45 -0.68,3.58v48.98q0,1.02 -0.77,1.79 -0.68,0.77 -1.79,0.77t-1.88,-0.77q-0.68,-0.77 -0.68,-1.79v-48.98q0,-5.46 3.16,-8.36 3.24,-2.9 8.28,-2.9zM1445.14,410.99q1.02,0 1.71,0.68 0.68,0.68 0.68,1.71t-0.68,1.71q-0.68,0.68 -1.71,0.68h-20.91q-0.94,0 -1.71,-0.68 -0.68,-0.77 -0.68,-1.71 0,-1.11 0.68,-1.71 0.77,-0.68 1.71,-0.68zM1461.19,451.18q0,1.02 -0.77,1.79t-1.79,0.77q-1.11,0 -1.88,-0.77 -0.68,-0.77 -0.68,-1.79v-39.25q0,-1.02 0.68,-1.79 0.77,-0.77 1.88,-0.77t1.79,0.77q0.77,0.77 0.77,1.79zM1458.63,403.65q-1.79,0 -2.73,-0.77 -0.85,-0.85 -0.85,-2.39v-0.85q0,-1.54 0.94,-2.3 1.02,-0.85 2.73,-0.85 1.62,0 2.47,0.85 0.94,0.77 0.94,2.3v0.85q0,1.54 -0.94,2.39 -0.85,0.77 -2.56,0.77z"
android:fillColor="#fff"/>
<path
android:pathData="m1495.1,409.11q5.63,0 8.96,2.3 3.41,2.22 4.86,6.14 1.54,3.84 1.54,8.53v25.09q0,1.02 -0.77,1.79 -0.77,0.77 -1.79,0.77 -1.19,0 -1.88,-0.77 -0.68,-0.77 -0.68,-1.79v-24.83q0,-3.41 -1.11,-6.23 -1.11,-2.82 -3.67,-4.52 -2.47,-1.71 -6.57,-1.71 -3.67,0 -7,1.71 -3.24,1.71 -5.29,4.52t-2.05,6.23v24.83q0,1.02 -0.77,1.79t-1.79,0.77q-1.19,0 -1.88,-0.77t-0.68,-1.79v-38.23q0,-1.02 0.68,-1.79 0.77,-0.77 1.88,-0.77 1.11,0 1.79,0.77 0.77,0.77 0.77,1.79v7.17l-1.96,3.07q0.17,-2.73 1.71,-5.21 1.62,-2.56 4.1,-4.52 2.47,-2.05 5.46,-3.16 3.07,-1.19 6.14,-1.19z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Findroid" parent="Theme.Material3.Dark.NoActionBar" />
</resources>

View file

@ -11,5 +11,6 @@ object Versions {
val java = JavaVersion.VERSION_17
const val composeCompiler = "1.5.7"
const val ktlint = "0.50.0"
}

View file

@ -36,6 +36,14 @@ android {
sourceCompatibility = Versions.java
targetCompatibility = Versions.java
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = Versions.composeCompiler
}
}
ktlint {
@ -50,6 +58,7 @@ dependencies {
implementation(project(":player:core"))
implementation(libs.androidx.activity)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.core)
implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler)

View file

@ -19,7 +19,7 @@ object ApiModule {
fun provideJellyfinApi(
@ApplicationContext application: Context,
appPreferences: AppPreferences,
serverDatabase: ServerDatabaseDao,
database: ServerDatabaseDao,
): JellyfinApi {
val jellyfinApi = JellyfinApi.getInstance(
context = application,
@ -30,10 +30,10 @@ object ApiModule {
val serverId = appPreferences.currentServer ?: return jellyfinApi
val serverWithAddressesAndUsers = serverDatabase.getServerWithAddressesAndUsers(serverId) ?: return jellyfinApi
val server = serverWithAddressesAndUsers.server
val serverAddress = serverWithAddressesAndUsers.addresses.firstOrNull { it.id == server.currentServerAddressId } ?: return jellyfinApi
val user = serverWithAddressesAndUsers.users.firstOrNull { it.id == server.currentUserId }
val serverWithAddressAndUser = database.getServerWithAddressAndUser(serverId) ?: return jellyfinApi
val serverAddress = serverWithAddressAndUser.address ?: return jellyfinApi
val user = serverWithAddressAndUser.user
jellyfinApi.apply {
api.baseUrl = serverAddress.address
api.accessToken = user?.accessToken

View file

@ -7,10 +7,6 @@ sealed class HomeItem {
override val id: UUID = UUID.fromString("dbfef8a9-7ff0-4c36-9e36-81dfd65fdd46")
}
data class Libraries(val section: HomeSection) : HomeItem() {
override val id = section.id
}
data class Section(val homeSection: HomeSection) : HomeItem() {
override val id = homeSection.id
}

View file

@ -2,12 +2,14 @@ package dev.jdtech.jellyfin.models
import android.content.res.Resources
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
sealed class UiText {
data class DynamicString(val value: String) : UiText()
class StringResource(
@StringRes val resId: Int,
vararg val args: Any?,
vararg val args: Any,
) : UiText()
fun asString(resources: Resources): String {
@ -16,4 +18,12 @@ sealed class UiText {
is StringResource -> resources.getString(resId, *args)
}
}
@Composable
fun asString(): String {
return when (this) {
is DynamicString -> value
is StringResource -> stringResource(resId, *args)
}
}
}

View file

@ -0,0 +1,106 @@
package dev.jdtech.jellyfin.utils
import android.view.KeyEvent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.flow.Flow
@Composable
fun <T> ObserveAsEvents(flow: Flow<T>, onEvent: (T) -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(flow, lifecycleOwner.lifecycle) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
flow.collect(onEvent)
}
}
}
private val DPadEventsKeyCodes = listOf(
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT,
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN,
KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_NUMPAD_ENTER,
)
/**
* Handles horizontal (Left & Right) D-Pad Keys and consumes the event(s) so that the focus doesn't
* accidentally move to another element.
* */
fun Modifier.handleDPadKeyEvents(
onLeft: (() -> Unit)? = null,
onRight: (() -> Unit)? = null,
onEnter: (() -> Unit)? = null,
) = onPreviewKeyEvent {
fun onActionUp(block: () -> Unit) {
if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) block()
}
if (DPadEventsKeyCodes.contains(it.nativeKeyEvent.keyCode)) {
when (it.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> {
onLeft?.apply {
onActionUp(::invoke)
return@onPreviewKeyEvent true
}
}
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> {
onRight?.apply {
onActionUp(::invoke)
return@onPreviewKeyEvent true
}
}
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
onEnter?.apply {
onActionUp(::invoke)
return@onPreviewKeyEvent true
}
}
}
}
false
}
/**
* Handles all D-Pad Keys
* */
fun Modifier.handleDPadKeyEvents(
onLeft: (() -> Unit)? = null,
onRight: (() -> Unit)? = null,
onUp: (() -> Unit)? = null,
onDown: (() -> Unit)? = null,
onEnter: (() -> Unit)? = null,
) = onKeyEvent {
if (DPadEventsKeyCodes.contains(it.nativeKeyEvent.keyCode) && it.nativeKeyEvent.action == KeyEvent.ACTION_UP) {
when (it.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> {
onLeft?.invoke().also { return@onKeyEvent true }
}
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> {
onRight?.invoke().also { return@onKeyEvent true }
}
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP -> {
onUp?.invoke().also { return@onKeyEvent true }
}
KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN -> {
onDown?.invoke().also { return@onKeyEvent true }
}
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
onEnter?.invoke().also { return@onKeyEvent true }
}
}
}
false
}

View file

@ -223,7 +223,7 @@ constructor(
UiText.StringResource(R.string.add_server_error_outdated, it.version)
}
is RecommendedServerIssue.InvalidProductName -> {
UiText.StringResource(R.string.add_server_error_not_jellyfin, it.productName)
UiText.StringResource(R.string.add_server_error_not_jellyfin, it.productName ?: "")
}
is RecommendedServerIssue.UnsupportedServerVersion -> {
UiText.StringResource(R.string.add_server_error_version, it.version)

View file

@ -52,24 +52,19 @@ constructor(
}
lateinit var item: FindroidEpisode
private var played: Boolean = false
private var favorite: Boolean = false
private var currentUiState: UiState = UiState.Loading
fun loadEpisode(episodeId: UUID) {
viewModelScope.launch {
_uiState.emit(UiState.Loading)
try {
item = repository.getEpisode(episodeId)
played = item.played
favorite = item.favorite
if (item.isDownloading()) {
pollDownloadProgress()
}
_uiState.emit(
UiState.Normal(
item,
),
)
currentUiState = UiState.Normal(item)
_uiState.emit(currentUiState)
} catch (_: NullPointerException) {
// Navigate back because item does not exist (probably because it's been deleted)
eventsChannel.send(EpisodeBottomSheetEvent.NavigateBack)
@ -79,48 +74,76 @@ constructor(
}
}
fun togglePlayed(): Boolean {
when (played) {
false -> {
played = true
viewModelScope.launch {
try {
repository.markAsPlayed(item.id)
} catch (_: Exception) {}
fun togglePlayed() {
suspend fun updateUiPlayedState(played: Boolean) {
item = item.copy(played = played)
when (currentUiState) {
is UiState.Normal -> {
currentUiState = (currentUiState as UiState.Normal).copy(episode = item)
_uiState.emit(currentUiState)
}
else -> {}
}
true -> {
played = false
viewModelScope.launch {
}
viewModelScope.launch {
val originalPlayedState = item.played
updateUiPlayedState(!item.played)
when (item.played) {
false -> {
try {
repository.markAsUnplayed(item.id)
} catch (_: Exception) {}
} catch (_: Exception) {
updateUiPlayedState(originalPlayedState)
}
}
true -> {
try {
repository.markAsPlayed(item.id)
} catch (_: Exception) {
updateUiPlayedState(originalPlayedState)
}
}
}
}
return played
}
fun toggleFavorite(): Boolean {
when (favorite) {
false -> {
favorite = true
viewModelScope.launch {
try {
repository.markAsFavorite(item.id)
} catch (_: Exception) {}
fun toggleFavorite() {
suspend fun updateUiFavoriteState(isFavorite: Boolean) {
item = item.copy(favorite = isFavorite)
when (currentUiState) {
is UiState.Normal -> {
currentUiState = (currentUiState as UiState.Normal).copy(episode = item)
_uiState.emit(currentUiState)
}
else -> {}
}
true -> {
favorite = false
viewModelScope.launch {
}
viewModelScope.launch {
val originalFavoriteState = item.favorite
updateUiFavoriteState(!item.favorite)
when (item.favorite) {
false -> {
try {
repository.unmarkAsFavorite(item.id)
} catch (_: Exception) {}
} catch (_: Exception) {
updateUiFavoriteState(originalFavoriteState)
}
}
true -> {
try {
repository.markAsFavorite(item.id)
} catch (_: Exception) {
updateUiFavoriteState(originalFavoriteState)
}
}
}
}
return favorite
}
fun download(sourceIndex: Int = 0, storageIndex: Int = 0) {

View file

@ -31,11 +31,9 @@ class HomeViewModel @Inject internal constructor(
data class Error(val error: Exception) : UiState()
}
private val uuidLibraries = UUID(4104409383667715086, -6276889634004763134) // 38f5ca96-9e4b-4c0e-a8e4-02225ed07e02
private val uuidContinueWatching = UUID(4937169328197226115, -4704919157662094443) // 44845958-8326-4e83-beb4-c4f42e9eeb95
private val uuidNextUp = UUID(1783371395749072194, -6164625418200444295) // 18bfced5-f237-4d42-aa72-d9d7fed19279
private val uiTextLibraries = UiText.StringResource(R.string.libraries)
private val uiTextContinueWatching = UiText.StringResource(R.string.continue_watching)
private val uiTextNextUp = UiText.StringResource(R.string.next_up)
@ -48,7 +46,7 @@ class HomeViewModel @Inject internal constructor(
}
}
fun loadData(includeLibraries: Boolean = false) {
fun loadData() {
viewModelScope.launch {
_uiState.emit(UiState.Loading)
try {
@ -56,10 +54,6 @@ class HomeViewModel @Inject internal constructor(
if (appPreferences.offlineMode) items.add(HomeItem.OfflineCard)
if (includeLibraries) {
items.add(loadLibraries())
}
val updated = items + loadDynamicItems() + loadViews()
_uiState.emit(UiState.Normal(updated))
@ -69,19 +63,6 @@ class HomeViewModel @Inject internal constructor(
}
}
private suspend fun loadLibraries(): HomeItem {
val items = repository.getLibraries()
val collections =
items.filter { collection -> collection.type in CollectionType.supported }
return HomeItem.Libraries(
HomeSection(
uuidLibraries,
uiTextLibraries,
collections,
),
)
}
private suspend fun loadDynamicItems(): List<HomeItem.Section> {
val resumeItems = repository.getResumeItems()
val nextUpItems = repository.getNextUp()

View file

@ -1,7 +1,48 @@
package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.AppPreferences
import dev.jdtech.jellyfin.database.ServerDatabaseDao
import dev.jdtech.jellyfin.models.Server
import dev.jdtech.jellyfin.models.User
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
class MainViewModel : ViewModel() {
@HiltViewModel
class MainViewModel
@Inject
constructor(
private val appPreferences: AppPreferences,
private val database: ServerDatabaseDao,
) : ViewModel() {
var startDestinationChanged = false
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState = _uiState.asStateFlow()
sealed class UiState {
data class Normal(val server: Server?, val user: User?) : UiState()
data object Loading : UiState()
}
init {
loadServerAndUser()
}
private fun loadServerAndUser() {
viewModelScope.launch {
val serverId = appPreferences.currentServer
serverId?.let { id ->
database.getServerWithAddressAndUser(id)?.let { data ->
_uiState.emit(
UiState.Normal(data.server, data.user),
)
}
}
}
}
}

View file

@ -75,41 +75,38 @@ constructor(
}
lateinit var item: FindroidMovie
private var played: Boolean = false
private var favorite: Boolean = false
private var writers: List<BaseItemPerson> = emptyList()
private var writersString: String = ""
private var runTime: String = ""
private var currentUiState: UiState = UiState.Loading
fun loadData(itemId: UUID) {
viewModelScope.launch {
_uiState.emit(UiState.Loading)
try {
item = repository.getMovie(itemId)
played = item.played
favorite = item.favorite
writers = getWriters(item)
writersString = writers.joinToString(separator = ", ") { it.name.toString() }
runTime = "${item.runtimeTicks.div(600000000)} min"
if (item.isDownloading()) {
pollDownloadProgress()
}
_uiState.emit(
UiState.Normal(
item,
getActors(item),
getDirector(item),
writers,
parseVideoMetadata(item),
writersString,
item.genres.joinToString(separator = ", "),
getMediaString(item, MediaStreamType.VIDEO),
getMediaString(item, MediaStreamType.AUDIO),
getMediaString(item, MediaStreamType.SUBTITLE),
runTime,
getDateString(item),
),
currentUiState = UiState.Normal(
item,
getActors(item),
getDirector(item),
writers,
parseVideoMetadata(item),
writersString,
item.genres.joinToString(separator = ", "),
getMediaString(item, MediaStreamType.VIDEO),
getMediaString(item, MediaStreamType.AUDIO),
getMediaString(item, MediaStreamType.SUBTITLE),
runTime,
getDateString(item),
)
_uiState.emit(currentUiState)
} catch (_: NullPointerException) {
// Navigate back because item does not exist (probably because it's been deleted)
eventsChannel.send(MovieEvent.NavigateBack)
@ -259,48 +256,76 @@ constructor(
)
}
fun togglePlayed(): Boolean {
when (played) {
false -> {
played = true
viewModelScope.launch {
try {
repository.markAsPlayed(item.id)
} catch (_: Exception) {}
fun togglePlayed() {
suspend fun updateUiPlayedState(played: Boolean) {
item = item.copy(played = played)
when (currentUiState) {
is UiState.Normal -> {
currentUiState = (currentUiState as UiState.Normal).copy(item = item)
_uiState.emit(currentUiState)
}
else -> {}
}
true -> {
played = false
viewModelScope.launch {
}
viewModelScope.launch {
val originalPlayedState = item.played
updateUiPlayedState(!item.played)
when (item.played) {
false -> {
try {
repository.markAsUnplayed(item.id)
} catch (_: Exception) {}
} catch (_: Exception) {
updateUiPlayedState(originalPlayedState)
}
}
true -> {
try {
repository.markAsPlayed(item.id)
} catch (_: Exception) {
updateUiPlayedState(originalPlayedState)
}
}
}
}
return played
}
fun toggleFavorite(): Boolean {
when (favorite) {
false -> {
favorite = true
viewModelScope.launch {
try {
repository.markAsFavorite(item.id)
} catch (_: Exception) {}
fun toggleFavorite() {
suspend fun updateUiFavoriteState(isFavorite: Boolean) {
item = item.copy(favorite = isFavorite)
when (currentUiState) {
is UiState.Normal -> {
currentUiState = (currentUiState as UiState.Normal).copy(item = item)
_uiState.emit(currentUiState)
}
else -> {}
}
true -> {
favorite = false
viewModelScope.launch {
}
viewModelScope.launch {
val originalFavoriteState = item.favorite
updateUiFavoriteState(!item.favorite)
when (item.favorite) {
false -> {
try {
repository.unmarkAsFavorite(item.id)
} catch (_: Exception) {}
} catch (_: Exception) {
updateUiFavoriteState(originalFavoriteState)
}
}
true -> {
try {
repository.markAsFavorite(item.id)
} catch (_: Exception) {
updateUiFavoriteState(originalFavoriteState)
}
}
}
}
return favorite
}
private fun getDateString(item: FindroidMovie): String {

View file

@ -6,7 +6,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.AppPreferences
import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.ServerDatabaseDao
import dev.jdtech.jellyfin.models.DiscoveredServer
import dev.jdtech.jellyfin.models.Server
import dev.jdtech.jellyfin.models.UiText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
@ -25,27 +27,59 @@ constructor(
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState = _uiState.asStateFlow()
private val _discoveredServersState = MutableStateFlow<DiscoveredServersState>(DiscoveredServersState.Loading)
val discoveredServersState = _discoveredServersState.asStateFlow()
var currentServerId: String? = appPreferences.currentServer
private val eventsChannel = Channel<ServerSelectEvent>()
val eventsChannelFlow = eventsChannel.receiveAsFlow()
// TODO states may need to be merged / cleaned up
sealed class UiState {
data class Normal(val servers: List<Server>) : UiState()
data object Loading : UiState()
data class Error(val error: Exception) : UiState()
data class Error(val message: Collection<UiText>) : UiState()
}
sealed class DiscoveredServersState {
data object Loading : DiscoveredServersState()
data class Servers(val servers: List<DiscoveredServer>) : DiscoveredServersState()
}
private val discoveredServers = mutableListOf<DiscoveredServer>()
init {
viewModelScope.launch {
viewModelScope.launch(Dispatchers.IO) {
loadServers()
discoverServers()
}
}
/**
* Get Jellyfin servers stored in the database and emit them
*/
private suspend fun loadServers() {
val servers = database.getAllServersSync()
_uiState.emit(UiState.Normal(servers))
}
/**
* Discover Jellyfin servers and emit them
*/
private suspend fun discoverServers() {
val servers = jellyfinApi.jellyfin.discovery.discoverLocalServers()
servers.collect { serverDiscoveryInfo ->
discoveredServers.add(
DiscoveredServer(
serverDiscoveryInfo.id,
serverDiscoveryInfo.name,
serverDiscoveryInfo.address,
),
)
_discoveredServersState.emit(DiscoveredServersState.Servers(ArrayList(discoveredServers)))
}
}
/**
* Delete server from database
*
@ -60,9 +94,9 @@ constructor(
fun connectToServer(server: Server) {
viewModelScope.launch {
val serverWithAddressesAndUsers = database.getServerWithAddressesAndUsers(server.id) ?: return@launch
val serverAddress = serverWithAddressesAndUsers.addresses.firstOrNull { it.id == server.currentServerAddressId } ?: return@launch
val user = serverWithAddressesAndUsers.users.firstOrNull { it.id == server.currentUserId }
val serverWithAddressAndUser = database.getServerWithAddressAndUser(server.id) ?: return@launch
val serverAddress = serverWithAddressAndUser.address ?: return@launch
val user = serverWithAddressAndUser.user
// If server has no selected user, navigate to login fragment
if (user == null) {
@ -83,6 +117,7 @@ constructor(
}
appPreferences.currentServer = server.id
currentServerId = server.id
eventsChannel.send(ServerSelectEvent.NavigateToHome)
}

View file

@ -0,0 +1,223 @@
package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.AppPreferences
import dev.jdtech.jellyfin.Constants
import dev.jdtech.jellyfin.core.R
import dev.jdtech.jellyfin.models.Preference
import dev.jdtech.jellyfin.models.PreferenceCategory
import dev.jdtech.jellyfin.models.PreferenceSelect
import dev.jdtech.jellyfin.models.PreferenceSwitch
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel
@Inject
constructor(
private val appPreferences: AppPreferences,
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState = _uiState.asStateFlow()
private val eventsChannel = Channel<SettingsEvent>()
val eventsChannelFlow = eventsChannel.receiveAsFlow()
sealed class UiState {
data class Normal(
val preferences: List<Preference>,
) : UiState()
data object Loading : UiState()
}
private val topLevelPreferences = listOf<Preference>(
PreferenceCategory(
nameStringResource = R.string.settings_category_language,
iconDrawableId = R.drawable.ic_languages,
onClick = {
viewModelScope.launch {
eventsChannel.send(SettingsEvent.NavigateToSettings(intArrayOf(0), it.nameStringResource))
}
},
nestedPreferences = listOf(
PreferenceSelect(
nameStringResource = R.string.settings_preferred_audio_language,
iconDrawableId = R.drawable.ic_speaker,
backendName = Constants.PREF_AUDIO_LANGUAGE,
backendDefaultValue = null,
options = R.array.languages,
optionValues = R.array.languages_values,
),
PreferenceSelect(
nameStringResource = R.string.settings_preferred_subtitle_language,
iconDrawableId = R.drawable.ic_closed_caption,
backendName = Constants.PREF_SUBTITLE_LANGUAGE,
backendDefaultValue = null,
options = R.array.languages,
optionValues = R.array.languages_values,
),
),
),
PreferenceCategory(
nameStringResource = R.string.settings_category_appearance,
iconDrawableId = R.drawable.ic_palette,
enabled = false,
),
PreferenceCategory(
nameStringResource = R.string.settings_category_player,
iconDrawableId = R.drawable.ic_play,
onClick = {
viewModelScope.launch {
eventsChannel.send(SettingsEvent.NavigateToSettings(intArrayOf(2), it.nameStringResource))
}
},
nestedPreferences = listOf(
PreferenceSwitch(
nameStringResource = R.string.mpv_player,
descriptionStringRes = R.string.mpv_player_summary,
backendName = Constants.PREF_PLAYER_MPV,
backendDefaultValue = false,
),
PreferenceSelect(
nameStringResource = R.string.pref_player_mpv_hwdec,
dependencies = listOf(Constants.PREF_PLAYER_MPV),
backendName = Constants.PREF_PLAYER_MPV_HWDEC,
backendDefaultValue = "mediacodec",
options = R.array.mpv_hwdec,
optionValues = R.array.mpv_hwdec,
),
PreferenceSelect(
nameStringResource = R.string.pref_player_mpv_vo,
dependencies = listOf(Constants.PREF_PLAYER_MPV),
backendName = Constants.PREF_PLAYER_MPV_VO,
backendDefaultValue = "gpu",
options = R.array.mpv_vos,
optionValues = R.array.mpv_vos,
),
PreferenceSelect(
nameStringResource = R.string.pref_player_mpv_ao,
dependencies = listOf(Constants.PREF_PLAYER_MPV),
backendName = Constants.PREF_PLAYER_MPV_AO,
backendDefaultValue = "audiotrack",
options = R.array.mpv_aos,
optionValues = R.array.mpv_aos,
),
),
),
PreferenceCategory(
nameStringResource = R.string.users,
iconDrawableId = R.drawable.ic_user,
onClick = {
viewModelScope.launch {
eventsChannel.send(SettingsEvent.NavigateToUsers)
}
},
),
PreferenceCategory(
nameStringResource = R.string.settings_category_servers,
iconDrawableId = R.drawable.ic_server,
onClick = {
viewModelScope.launch {
eventsChannel.send(SettingsEvent.NavigateToServers)
}
},
),
PreferenceCategory(
nameStringResource = R.string.settings_category_device,
iconDrawableId = R.drawable.ic_smartphone,
enabled = false,
),
PreferenceCategory(
nameStringResource = R.string.settings_category_network,
iconDrawableId = R.drawable.ic_network,
enabled = false,
),
PreferenceCategory(
nameStringResource = R.string.settings_category_cache,
iconDrawableId = R.drawable.ic_hard_drive,
onClick = {
viewModelScope.launch {
eventsChannel.send(SettingsEvent.NavigateToSettings(intArrayOf(7), it.nameStringResource))
}
},
nestedPreferences = listOf(
PreferenceSwitch(
nameStringResource = R.string.settings_use_cache_title,
descriptionStringRes = R.string.settings_use_cache_summary,
backendName = Constants.PREF_IMAGE_CACHE,
backendDefaultValue = true,
),
),
),
PreferenceCategory(
nameStringResource = R.string.about,
iconDrawableId = R.drawable.ic_info,
enabled = false,
),
)
fun loadPreferences(indexes: IntArray = intArrayOf()) {
viewModelScope.launch {
var preferences = topLevelPreferences
// Show preferences based on index (depth)
for (index in indexes) {
val preference = preferences[index]
if (preference is PreferenceCategory) {
preferences = preference.nestedPreferences
}
}
// Update all (visible) preferences with there current values
preferences = preferences.map { preference ->
when (preference) {
is PreferenceSwitch -> {
preference.copy(
enabled = preference.dependencies.all { getBoolean(it, false) },
value = getBoolean(preference.backendName, preference.backendDefaultValue),
)
}
is PreferenceSelect -> {
preference.copy(
enabled = preference.dependencies.all { getBoolean(it, false) },
value = getString(preference.backendName, preference.backendDefaultValue),
)
}
else -> preference
}
}
_uiState.emit(UiState.Normal(preferences))
}
}
private fun getBoolean(key: String, default: Boolean): Boolean {
return appPreferences.getBoolean(key, default)
}
fun setBoolean(key: String, value: Boolean) {
appPreferences.setBoolean(key, value)
}
private fun getString(key: String, default: String?): String? {
return appPreferences.getString(key, default)
}
fun setString(key: String, value: String?) {
appPreferences.setString(key, value)
}
}
sealed interface SettingsEvent {
data object NavigateToUsers : SettingsEvent
data object NavigateToServers : SettingsEvent
data class NavigateToSettings(val indexes: IntArray, val title: Int) : SettingsEvent
}

View file

@ -49,8 +49,6 @@ constructor(
}
lateinit var item: FindroidShow
private var played: Boolean = false
private var favorite: Boolean = false
private var actors: List<BaseItemPerson> = emptyList()
private var director: BaseItemPerson? = null
private var writers: List<BaseItemPerson> = emptyList()
@ -61,13 +59,13 @@ constructor(
var nextUp: FindroidEpisode? = null
var seasons: List<FindroidSeason> = emptyList()
private var currentUiState: UiState = UiState.Loading
fun loadData(itemId: UUID, offline: Boolean) {
viewModelScope.launch {
_uiState.emit(UiState.Loading)
try {
item = jellyfinRepository.getShow(itemId)
played = item.played
favorite = item.favorite
actors = getActors(item)
director = getDirector(item)
writers = getWriters(item)
@ -77,20 +75,19 @@ constructor(
dateString = getDateString(item)
nextUp = getNextUp(itemId)
seasons = jellyfinRepository.getSeasons(itemId, offline)
_uiState.emit(
UiState.Normal(
item,
actors,
director,
writers,
writersString,
genresString,
runTime,
dateString,
nextUp,
seasons,
),
currentUiState = UiState.Normal(
item,
actors,
director,
writers,
writersString,
genresString,
runTime,
dateString,
nextUp,
seasons,
)
_uiState.emit(currentUiState)
} catch (_: NullPointerException) {
// Navigate back because item does not exist (probably because it's been deleted)
eventsChannel.send(ShowEvent.NavigateBack)
@ -129,48 +126,76 @@ constructor(
return nextUpItems.getOrNull(0)
}
fun togglePlayed(): Boolean {
when (played) {
false -> {
played = true
viewModelScope.launch {
try {
jellyfinRepository.markAsPlayed(item.id)
} catch (_: Exception) {}
fun togglePlayed() {
suspend fun updateUiPlayedState(played: Boolean) {
item = item.copy(played = played)
when (currentUiState) {
is UiState.Normal -> {
currentUiState = (currentUiState as UiState.Normal).copy(item = item)
_uiState.emit(currentUiState)
}
else -> {}
}
true -> {
played = false
viewModelScope.launch {
}
viewModelScope.launch {
val originalPlayedState = item.played
updateUiPlayedState(!item.played)
when (item.played) {
false -> {
try {
jellyfinRepository.markAsUnplayed(item.id)
} catch (_: Exception) {}
} catch (_: Exception) {
updateUiPlayedState(originalPlayedState)
}
}
true -> {
try {
jellyfinRepository.markAsPlayed(item.id)
} catch (_: Exception) {
updateUiPlayedState(originalPlayedState)
}
}
}
}
return played
}
fun toggleFavorite(): Boolean {
when (favorite) {
false -> {
favorite = true
viewModelScope.launch {
try {
jellyfinRepository.markAsFavorite(item.id)
} catch (_: Exception) {}
fun toggleFavorite() {
suspend fun updateUiFavoriteState(isFavorite: Boolean) {
item = item.copy(favorite = isFavorite)
when (currentUiState) {
is UiState.Normal -> {
currentUiState = (currentUiState as UiState.Normal).copy(item = item)
_uiState.emit(currentUiState)
}
else -> {}
}
true -> {
favorite = false
viewModelScope.launch {
}
viewModelScope.launch {
val originalFavoriteState = item.favorite
updateUiFavoriteState(!item.favorite)
when (item.favorite) {
false -> {
try {
jellyfinRepository.unmarkAsFavorite(item.id)
} catch (_: Exception) {}
} catch (_: Exception) {
updateUiFavoriteState(originalFavoriteState)
}
}
true -> {
try {
jellyfinRepository.markAsFavorite(item.id)
} catch (_: Exception) {
updateUiFavoriteState(originalFavoriteState)
}
}
}
}
return favorite
}
private fun getDateString(item: FindroidShow): String {

View file

@ -0,0 +1,85 @@
package dev.jdtech.jellyfin.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.jdtech.jellyfin.AppPreferences
import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.ServerDatabaseDao
import dev.jdtech.jellyfin.models.Server
import dev.jdtech.jellyfin.models.User
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class UserSelectViewModel
@Inject
constructor(
private val appPreferences: AppPreferences,
private val jellyfinApi: JellyfinApi,
private val database: ServerDatabaseDao,
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState = _uiState.asStateFlow()
private val eventsChannel = Channel<UserSelectEvent>()
val eventsChannelFlow = eventsChannel.receiveAsFlow()
sealed class UiState {
data class Normal(val server: Server, val users: List<User>) : UiState()
data object Loading : UiState()
data class Error(val error: Exception) : UiState()
}
private val currentServerId: String? = appPreferences.currentServer
/**
* Load users from the database and emit them
*/
fun loadUsers() {
viewModelScope.launch {
_uiState.emit(UiState.Loading)
if (currentServerId == null) {
_uiState.emit(UiState.Error(Exception("No server in use")))
return@launch
}
try {
val serverWithUser = database.getServerWithUsers(currentServerId)
_uiState.emit(UiState.Normal(serverWithUser.server, serverWithUser.users))
} catch (e: Exception) {
_uiState.emit(UiState.Error(e))
}
}
}
/**
* Log in as user and navigate to home screen
*
* @param user The user
*/
fun loginAsUser(user: User) {
viewModelScope.launch {
if (currentServerId == null) {
return@launch
}
val server = database.get(currentServerId) ?: return@launch
server.currentUserId = user.id
database.update(server)
jellyfinApi.apply {
api.accessToken = user.accessToken
userId = user.id
}
eventsChannel.send(UserSelectEvent.NavigateToMain)
}
}
}
sealed interface UserSelectEvent {
data object NavigateToMain : UserSelectEvent
}

View file

@ -0,0 +1,31 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:pathData="M22,12L2,12"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M5.45,5.11 L2,12v6a2,2 0,0 0,2 2h16a2,2 0,0 0,2 -2v-6l-3.45,-6.89A2,2 0,0 0,16.76 4H7.24a2,2 0,0 0,-1.79 1.11z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M6,16L6.01,16"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M10,16L10.01,16"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,25 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:pathData="M12,12m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M12,16v-4"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M12,8h0.01"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="100dp"
android:height="100dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="m59.96,54.04 l3.38,-5.85c0.47,-0.81 -0.75,-1.52 -1.22,-0.7l-3.42,5.93c-2.62,-1.19 -5.56,-1.86 -8.69,-1.86 -3.14,0 -6.08,0.67 -8.69,1.86l-3.42,-5.93c-0.47,-0.81 -1.69,-0.11 -1.22,0.7l3.38,5.86c-5.8,3.16 -9.78,9.03 -10.36,15.98l40.62,0c-0.58,-6.94 -4.55,-12.82 -10.36,-15.98m-9.95,-54.04c-13.24,0 -55.83,77.23 -49.34,90.28 6.49,13.04 92.25,12.89 98.68,0 6.43,-12.89 -36.1,-90.28 -49.34,-90.28zM82.35,78.97c-4.21,8.45 -60.41,8.55 -64.66,0 -4.25,-8.55 23.66,-59.16 32.32,-59.16 8.66,0 36.55,50.69 32.34,59.16z"
android:strokeWidth="0.03">
<aapt:attr name="android:fillColor">
<gradient
android:startX="30.22"
android:startY="45.71"
android:endX="81.6"
android:endY="75.38"
android:type="linear">
<item android:offset="0" android:color="@color/logo_primary"/>
<item android:offset="1" android:color="@color/logo_secondary"/>
</gradient>
</aapt:attr>
</path>
</vector>

View file

@ -0,0 +1,42 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:pathData="m12,3 l-1.912,5.813a2,2 0,0 1,-1.275 1.275L3,12l5.813,1.912a2,2 0,0 1,1.275 1.275L12,21l1.912,-5.813a2,2 0,0 1,1.275 -1.275L21,12l-5.813,-1.912a2,2 0,0 1,-1.275 -1.275L12,3Z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M5,3v4"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M19,17v4"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M3,5h4"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M17,19h4"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,21 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:pathData="M4,7L20,7A2,2 0,0 1,22 9L22,20A2,2 0,0 1,20 22L4,22A2,2 0,0 1,2 20L2,9A2,2 0,0 1,4 7z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
<path
android:pathData="M17,2l-5,5l-5,-5"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_banner_background"/>
<foreground android:drawable="@drawable/ic_banner_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="discovered_servers">
<item quantity="one">%d server discovered</item>
<item quantity="other">%d servers discovered</item>
</plurals>
</resources>

View file

@ -177,4 +177,14 @@
<string name="cancel_download_message">Are you sure you want to cancel the download?</string>
<string name="stop_download">Stop download</string>
<string name="privacy_policy_notice">By using Findroid you agree with the <a href='https://raw.githubusercontent.com/jarnedemeulemeester/findroid/main/PRIVACY'>Privacy Policy</a> which states that we do not collect any data</string>
<string name="no_servers_found">No servers found</string>
<string name="no_users_found">No users found</string>
<string name="select_user">Select user</string>
<string name="live_tv">Live TV</string>
<string name="play">Play</string>
<string name="watch_trailer">Watch trailer</string>
<string name="mark_as_played">Mark as played</string>
<string name="unmark_as_played">Unmark as played</string>
<string name="add_to_favorites">Add to favorites</string>
<string name="remove_from_favorites">Remove from favorites</string>
</resources>

View file

@ -38,6 +38,10 @@ android {
sourceCompatibility = Versions.java
targetCompatibility = Versions.java
}
buildFeatures {
buildConfig = true
}
}
ktlint {

View file

@ -16,6 +16,7 @@ import dev.jdtech.jellyfin.models.FindroidUserDataDto
import dev.jdtech.jellyfin.models.IntroDto
import dev.jdtech.jellyfin.models.Server
import dev.jdtech.jellyfin.models.ServerAddress
import dev.jdtech.jellyfin.models.ServerWithAddressAndUser
import dev.jdtech.jellyfin.models.ServerWithAddresses
import dev.jdtech.jellyfin.models.ServerWithAddressesAndUsers
import dev.jdtech.jellyfin.models.ServerWithUsers
@ -55,6 +56,10 @@ interface ServerDatabaseDao {
@Query("SELECT * FROM servers WHERE id = :id")
fun getServerWithAddressesAndUsers(id: String): ServerWithAddressesAndUsers?
@Transaction
@Query("SELECT * FROM servers WHERE id = :id")
fun getServerWithAddressAndUser(id: String): ServerWithAddressAndUser?
@Query("DELETE FROM servers")
fun clear()

View file

@ -1,5 +1,6 @@
package dev.jdtech.jellyfin.models
import dev.jdtech.jellyfin.repository.JellyfinRepository
import org.jellyfin.sdk.model.api.BaseItemDto
import java.util.UUID
@ -16,11 +17,15 @@ data class FindroidBoxSet(
override val runtimeTicks: Long = 0L,
override val playbackPositionTicks: Long = 0L,
override val unplayedItemCount: Int? = null,
override val images: FindroidImages,
) : FindroidItem
fun BaseItemDto.toFindroidBoxSet(): FindroidBoxSet {
fun BaseItemDto.toFindroidBoxSet(
jellyfinRepository: JellyfinRepository,
): FindroidBoxSet {
return FindroidBoxSet(
id = id,
name = name.orEmpty(),
images = toFindroidImages(jellyfinRepository),
)
}

View file

@ -1,5 +1,6 @@
package dev.jdtech.jellyfin.models
import dev.jdtech.jellyfin.repository.JellyfinRepository
import org.jellyfin.sdk.model.api.BaseItemDto
import java.util.UUID
@ -17,9 +18,12 @@ data class FindroidCollection(
override val playbackPositionTicks: Long = 0L,
override val unplayedItemCount: Int? = null,
val type: CollectionType,
override val images: FindroidImages,
) : FindroidItem
fun BaseItemDto.toFindroidCollection(): FindroidCollection? {
fun BaseItemDto.toFindroidCollection(
jellyfinRepository: JellyfinRepository,
): FindroidCollection? {
val type = CollectionType.fromString(collectionType)
if (type !in CollectionType.supported) {
@ -30,5 +34,6 @@ fun BaseItemDto.toFindroidCollection(): FindroidCollection? {
id = id,
name = name.orEmpty(),
type = type,
images = toFindroidImages(jellyfinRepository),
)
}

View file

@ -30,6 +30,7 @@ data class FindroidEpisode(
val communityRating: Float?,
override val unplayedItemCount: Int? = null,
val missing: Boolean = false,
override val images: FindroidImages,
) : FindroidItem, FindroidSources
suspend fun BaseItemDto.toFindroidEpisode(
@ -63,6 +64,7 @@ suspend fun BaseItemDto.toFindroidEpisode(
seasonId = seasonId!!,
communityRating = communityRating,
missing = locationType == LocationType.VIRTUAL,
images = toFindroidImages(jellyfinRepository),
)
} catch (_: NullPointerException) {
null
@ -91,5 +93,6 @@ fun FindroidEpisodeDto.toFindroidEpisode(database: ServerDatabaseDao, userId: UU
seriesId = seriesId,
seasonId = seasonId,
communityRating = communityRating,
images = FindroidImages(),
)
}

View file

@ -0,0 +1,50 @@
package dev.jdtech.jellyfin.models
import android.net.Uri
import dev.jdtech.jellyfin.repository.JellyfinRepository
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
data class FindroidImages(
val primary: Uri? = null,
val backdrop: Uri? = null,
val logo: Uri? = null,
val showPrimary: Uri? = null,
)
fun BaseItemDto.toFindroidImages(
jellyfinRepository: JellyfinRepository,
): FindroidImages {
val baseUrl = Uri.parse(jellyfinRepository.getBaseUrl())
val primary = imageTags?.get(ImageType.PRIMARY)?.let { tag ->
baseUrl.buildUpon()
.appendEncodedPath("items/$id/Images/${ImageType.PRIMARY}")
.appendQueryParameter("tag", tag)
.build()
}
val backdrop = backdropImageTags?.firstOrNull()?.let { tag ->
baseUrl.buildUpon()
.appendEncodedPath("items/$id/Images/${ImageType.BACKDROP}/0")
.appendQueryParameter("tag", tag)
.build()
}
val logo = imageTags?.get(ImageType.LOGO)?.let { tag ->
baseUrl.buildUpon()
.appendEncodedPath("items/$id/Images/${ImageType.LOGO}")
.appendQueryParameter("tag", tag)
.build()
}
val showPrimary = seriesPrimaryImageTag?.let { tag ->
baseUrl.buildUpon()
.appendEncodedPath("items/$seriesId/Images/${ImageType.PRIMARY}")
.appendQueryParameter("tag", tag)
.build()
}
return FindroidImages(
primary = primary,
backdrop = backdrop,
logo = logo,
showPrimary = showPrimary,
)
}

View file

@ -19,6 +19,7 @@ interface FindroidItem {
val runtimeTicks: Long
val playbackPositionTicks: Long
val unplayedItemCount: Int?
val images: FindroidImages
}
suspend fun BaseItemDto.toFindroidItem(
@ -28,9 +29,9 @@ suspend fun BaseItemDto.toFindroidItem(
return when (type) {
BaseItemKind.MOVIE -> toFindroidMovie(jellyfinRepository, serverDatabase)
BaseItemKind.EPISODE -> toFindroidEpisode(jellyfinRepository)
BaseItemKind.SEASON -> toFindroidSeason()
BaseItemKind.SERIES -> toFindroidShow()
BaseItemKind.BOX_SET -> toFindroidBoxSet()
BaseItemKind.SEASON -> toFindroidSeason(jellyfinRepository)
BaseItemKind.SERIES -> toFindroidShow(jellyfinRepository)
BaseItemKind.BOX_SET -> toFindroidBoxSet(jellyfinRepository)
else -> null
}
}

View file

@ -2,10 +2,10 @@ package dev.jdtech.jellyfin.models
import dev.jdtech.jellyfin.database.ServerDatabaseDao
import dev.jdtech.jellyfin.repository.JellyfinRepository
import org.jellyfin.sdk.model.DateTime
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.PlayAccess
import java.time.LocalDateTime
import java.util.UUID
data class FindroidMovie(
@ -20,16 +20,17 @@ data class FindroidMovie(
override val canDownload: Boolean,
override val runtimeTicks: Long,
override val playbackPositionTicks: Long,
val premiereDate: DateTime?,
val premiereDate: LocalDateTime?,
val people: List<BaseItemPerson>,
val genres: List<String>,
val communityRating: Float?,
val officialRating: String?,
val status: String,
val productionYear: Int?,
val endDate: DateTime?,
val endDate: LocalDateTime?,
val trailer: String?,
override val unplayedItemCount: Int? = null,
override val images: FindroidImages,
) : FindroidItem, FindroidSources
suspend fun BaseItemDto.toFindroidMovie(
@ -62,6 +63,7 @@ suspend fun BaseItemDto.toFindroidMovie(
productionYear = productionYear,
endDate = endDate,
trailer = remoteTrailers?.getOrNull(0)?.url,
images = toFindroidImages(jellyfinRepository),
)
}
@ -88,5 +90,6 @@ fun FindroidMovieDto.toFindroidMovie(database: ServerDatabaseDao, userId: UUID):
canPlay = true,
sources = database.getSources(id).map { it.toFindroidSource(database) },
trailer = null,
images = FindroidImages(),
)
}

View file

@ -1,6 +1,7 @@
package dev.jdtech.jellyfin.models
import dev.jdtech.jellyfin.database.ServerDatabaseDao
import dev.jdtech.jellyfin.repository.JellyfinRepository
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.PlayAccess
import java.util.UUID
@ -22,9 +23,12 @@ data class FindroidSeason(
override val runtimeTicks: Long = 0L,
override val playbackPositionTicks: Long = 0L,
override val unplayedItemCount: Int?,
override val images: FindroidImages,
) : FindroidItem
fun BaseItemDto.toFindroidSeason(): FindroidSeason {
fun BaseItemDto.toFindroidSeason(
jellyfinRepository: JellyfinRepository,
): FindroidSeason {
return FindroidSeason(
id = id,
name = name.orEmpty(),
@ -40,6 +44,7 @@ fun BaseItemDto.toFindroidSeason(): FindroidSeason {
episodes = emptyList(),
seriesId = seriesId!!,
seriesName = seriesName.orEmpty(),
images = toFindroidImages(jellyfinRepository),
)
}
@ -60,5 +65,6 @@ fun FindroidSeasonDto.toFindroidSeason(database: ServerDatabaseDao, userId: UUID
episodes = emptyList(),
seriesId = seriesId,
seriesName = seriesName,
images = FindroidImages(),
)
}

View file

@ -1,6 +1,7 @@
package dev.jdtech.jellyfin.models
import dev.jdtech.jellyfin.database.ServerDatabaseDao
import dev.jdtech.jellyfin.repository.JellyfinRepository
import org.jellyfin.sdk.model.DateTime
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemPerson
@ -29,9 +30,12 @@ data class FindroidShow(
val productionYear: Int?,
val endDate: DateTime?,
val trailer: String?,
override val images: FindroidImages,
) : FindroidItem
fun BaseItemDto.toFindroidShow(): FindroidShow {
fun BaseItemDto.toFindroidShow(
jellyfinRepository: JellyfinRepository,
): FindroidShow {
return FindroidShow(
id = id,
name = name.orEmpty(),
@ -53,6 +57,7 @@ fun BaseItemDto.toFindroidShow(): FindroidShow {
productionYear = productionYear,
endDate = endDate,
trailer = remoteTrailers?.getOrNull(0)?.url,
images = toFindroidImages(jellyfinRepository),
)
}
@ -79,5 +84,6 @@ fun FindroidShowDto.toFindroidShow(database: ServerDatabaseDao, userId: UUID): F
productionYear = productionYear,
endDate = endDate,
trailer = null,
images = FindroidImages(),
)
}

View file

@ -0,0 +1,19 @@
package dev.jdtech.jellyfin.models
import androidx.room.Embedded
import androidx.room.Relation
data class ServerWithAddressAndUser(
@Embedded
val server: Server,
@Relation(
parentColumn = "currentServerAddressId",
entityColumn = "id",
)
val address: ServerAddress?,
@Relation(
parentColumn = "currentUserId",
entityColumn = "id",
)
val user: User?,
)

View file

@ -90,7 +90,7 @@ class JellyfinRepositoryImpl(
jellyfinApi.userLibraryApi.getItem(
jellyfinApi.userId!!,
itemId,
).content.toFindroidShow()
).content.toFindroidShow(this@JellyfinRepositoryImpl)
}
override suspend fun getSeason(itemId: UUID): FindroidSeason =
@ -98,7 +98,7 @@ class JellyfinRepositoryImpl(
jellyfinApi.userLibraryApi.getItem(
jellyfinApi.userId!!,
itemId,
).content.toFindroidSeason()
).content.toFindroidSeason(this@JellyfinRepositoryImpl)
}
override suspend fun getLibraries(): List<FindroidCollection> =
@ -107,7 +107,7 @@ class JellyfinRepositoryImpl(
jellyfinApi.userId!!,
).content.items
.orEmpty()
.mapNotNull { it.toFindroidCollection() }
.mapNotNull { it.toFindroidCollection(this@JellyfinRepositoryImpl) }
}
override suspend fun getItems(
@ -240,7 +240,7 @@ class JellyfinRepositoryImpl(
if (!offline) {
jellyfinApi.showsApi.getSeasons(seriesId, jellyfinApi.userId!!).content.items
.orEmpty()
.map { it.toFindroidSeason() }
.map { it.toFindroidSeason(this@JellyfinRepositoryImpl) }
} else {
database.getSeasonsByShowId(seriesId).map { it.toFindroidSeason(database, jellyfinApi.userId!!) }
}

View file

@ -18,4 +18,3 @@ android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
android.injected.testOnly=false
android.defaults.buildfeatures.buildconfig=true

View file

@ -1,26 +1,30 @@
[versions]
aboutlibraries = "10.9.2"
android-plugin = "8.1.2"
androidx-activity = "1.8.0"
android-plugin = "8.2.0"
androidx-activity = "1.8.1"
androidx-appcompat = "1.6.1"
androidx-compose-material3 = "1.2.0-alpha09"
androidx-compose-ui = "1.6.0-alpha07"
androidx-constraintlayout = "2.1.4"
androidx-core = "1.12.0"
androidx-hilt = "1.1.0"
androidx-lifecycle = "2.6.2"
androidx-media3 = "1.1.1"
androidx-media3 = "1.2.0"
androidx-navigation = "2.7.5"
androidx-paging = "3.2.1"
androidx-preference = "1.2.1"
androidx-recyclerview = "1.3.2"
androidx-room = "2.6.0"
androidx-room = "2.6.1"
androidx-swiperefreshlayout = "1.1.0"
androidx-work = "2.8.1"
coil = "2.4.0"
androidx-tv = "1.0.0-alpha10"
androidx-work = "2.9.0"
coil = "2.5.0"
hilt = "2.48.1"
jellyfin = "1.4.5"
kotlin = "1.9.10"
kotlinx-serialization = "1.6.0"
ksp = "1.9.10-1.0.13"
compose-destinations = "1.9.54"
jellyfin = "1.4.6"
kotlin = "1.9.21"
kotlinx-serialization = "1.6.1"
ksp = "1.9.21-1.0.15"
ktlint = "11.6.1"
libmpv = "0.1.3"
material = "1.10.0"
@ -30,13 +34,20 @@ timber = "5.0.1"
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlibraries" }
aboutlibraries = { module = "com.mikepenz:aboutlibraries", version.ref = "aboutlibraries" }
androidx-activity = { module = "androidx.activity:activity", version.ref = "androidx-activity" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "androidx-compose-material3" }
androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidx-compose-ui" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "androidx-compose-ui" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "androidx-compose-ui" }
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" }
androidx-core = { module = "androidx.core:core", version.ref = "androidx-core" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt" }
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "androidx-hilt" }
androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidx-hilt" }
androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-media3-common = { module = "androidx.media3:media3-common", version.ref = "androidx-media3" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" }
androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" }
@ -45,14 +56,20 @@ androidx-media3-session = { module = "androidx.media3:media3-session", version.r
androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment", version.ref = "androidx-navigation" }
androidx-navigation-ui = { module = "androidx.navigation:navigation-ui", version.ref = "androidx-navigation" }
androidx-paging = { module = "androidx.paging:paging-runtime", version.ref = "androidx-paging" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" }
androidx-preference = { module = "androidx.preference:preference", version.ref = "androidx-preference" }
androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "androidx-recyclerview" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" }
androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "androidx-swiperefreshlayout" }
androidx-work = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" }
androidx-work = { module = "androidx.work:work-runtime", version.ref = "androidx-work" }
androidx-tv-foundation = { module = "androidx.tv:tv-foundation", version.ref = "androidx-tv" }
androidx-tv-material = { module = "androidx.tv:tv-material", version.ref = "androidx-tv" }
coil = { module = "io.coil-kt:coil", version.ref = "coil" }
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
coil-svg = { module = "io.coil-kt:coil-svg", version.ref = "coil" }
compose-destinations-core = { module = "io.github.raamcosta.compose-destinations:core", version.ref = "compose-destinations" }
compose-destinations-ksp = { module = "io.github.raamcosta.compose-destinations:ksp", version.ref = "compose-destinations" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
jellyfin-core = { module = "org.jellyfin.sdk:jellyfin-core", version.ref = "jellyfin" }

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