diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..11fc491e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d79253aa..a6a737a5 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -14,7 +14,7 @@ jobs: - name: Validate Gradle Wrapper uses: gradle/wrapper-validation-action@v1 - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: 17 distribution: temurin @@ -31,7 +31,7 @@ jobs: - name: Validate Gradle Wrapper uses: gradle/wrapper-validation-action@v1 - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: 17 distribution: temurin @@ -40,28 +40,43 @@ 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 + uses: actions/upload-artifact@v4 with: name: phone-libre-arm64-v8a-debug.apk path: ./app/phone/build/outputs/apk/libre/debug/phone-libre-arm64-v8a-debug.apk - name: Upload artifact phone-libre-armeabi-v7a-debug.apk - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: phone-libre-armeabi-v7a-debug.apk path: ./app/phone/build/outputs/apk/libre/debug/phone-libre-armeabi-v7a-debug.apk - name: Upload artifact phone-libre-x86_64-debug.apk - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: phone-libre-x86_64-debug.apk path: ./app/phone/build/outputs/apk/libre/debug/phone-libre-x86_64-debug.apk - name: Upload artifact phone-libre-x86-debug.apk - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 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@v4 + 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@v4 + 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@v4 + 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@v4 + with: + name: tv-libre-x86-debug.apk + path: ./app/tv/build/outputs/apk/libre/debug/tv-libre-x86-debug.apk diff --git a/app/phone/build.gradle.kts b/app/phone/build.gradle.kts index ae41347b..670bf9fc 100644 --- a/app/phone/build.gradle.kts +++ b/app/phone/build.gradle.kts @@ -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 } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt index 5096d4fc..7e3b5a32 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt @@ -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() diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt index 5d5c2a80..84982dbb 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt @@ -27,18 +27,15 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.media3.common.C -import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.PlayerView -import androidx.media3.ui.TrackSelectionDialogBuilder import androidx.navigation.navArgs import dagger.hilt.android.AndroidEntryPoint import dev.jdtech.jellyfin.databinding.ActivityPlayerBinding import dev.jdtech.jellyfin.dialogs.SpeedSelectionDialogFragment import dev.jdtech.jellyfin.dialogs.TrackSelectionDialogFragment import dev.jdtech.jellyfin.mpv.MPVPlayer -import dev.jdtech.jellyfin.mpv.TrackType import dev.jdtech.jellyfin.utils.PlayerGestureHelper import dev.jdtech.jellyfin.utils.PreviewScrubListener import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel @@ -46,7 +43,6 @@ import dev.jdtech.jellyfin.viewmodels.PlayerEvents import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject -import dev.jdtech.jellyfin.player.video.R as PlayerVideoR var isControlsLocked: Boolean = false @@ -201,36 +197,10 @@ class PlayerActivity : BasePlayerActivity() { } audioButton.setOnClickListener { - when (viewModel.player) { - is MPVPlayer -> { - TrackSelectionDialogFragment(TrackType.AUDIO, viewModel).show( - supportFragmentManager, - "trackselectiondialog", - ) - } - is ExoPlayer -> { - val mappedTrackInfo = - viewModel.trackSelector.currentMappedTrackInfo ?: return@setOnClickListener - - var audioRenderer: Int? = null - for (i in 0 until mappedTrackInfo.rendererCount) { - if (isRendererType(mappedTrackInfo, i, C.TRACK_TYPE_AUDIO)) { - audioRenderer = i - } - } - - if (audioRenderer == null) return@setOnClickListener - - val trackSelectionDialogBuilder = TrackSelectionDialogBuilder( - this, - resources.getString(PlayerVideoR.string.select_audio_track), - viewModel.player, - C.TRACK_TYPE_AUDIO, - ) - val trackSelectionDialog = trackSelectionDialogBuilder.build() - trackSelectionDialog.show() - } - } + TrackSelectionDialogFragment(C.TRACK_TYPE_AUDIO, viewModel).show( + supportFragmentManager, + "trackselectiondialog", + ) } val exoPlayerControlView = findViewById(R.id.player_controls) @@ -251,38 +221,10 @@ class PlayerActivity : BasePlayerActivity() { } subtitleButton.setOnClickListener { - when (viewModel.player) { - is MPVPlayer -> { - TrackSelectionDialogFragment(TrackType.SUBTITLE, viewModel).show( - supportFragmentManager, - "trackselectiondialog", - ) - } - is ExoPlayer -> { - val mappedTrackInfo = - viewModel.trackSelector.currentMappedTrackInfo ?: return@setOnClickListener - - var subtitleRenderer: Int? = null - for (i in 0 until mappedTrackInfo.rendererCount) { - if (isRendererType(mappedTrackInfo, i, C.TRACK_TYPE_TEXT)) { - subtitleRenderer = i - } - } - - if (subtitleRenderer == null) return@setOnClickListener - - val trackSelectionDialogBuilder = TrackSelectionDialogBuilder( - this, - resources.getString(PlayerVideoR.string.select_subtile_track), - viewModel.player, - C.TRACK_TYPE_TEXT, - ) - trackSelectionDialogBuilder.setShowDisableOption(true) - - val trackSelectionDialog = trackSelectionDialogBuilder.build() - trackSelectionDialog.show() - } - } + TrackSelectionDialogFragment(C.TRACK_TYPE_TEXT, viewModel).show( + supportFragmentManager, + "trackselectiondialog", + ) } speedButton.setOnClickListener { diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewListAdapter.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewListAdapter.kt index c85c7167..f5417015 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewListAdapter.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewListAdapter.kt @@ -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 } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt index 3ec17bb1..c9c1066a 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt @@ -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) { + 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) } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MovieFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MovieFragment.kt index 781e240d..be6d3d40 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MovieFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MovieFragment.kt @@ -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) { + 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) } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt index 66cff353..8bc559f8 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt @@ -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, - ) { - findNavController().navigate( - SeasonFragmentDirections.actionSeasonFragmentToPlayerActivity( - playerItems, - ), - ) - } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/ShowFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/ShowFragment.kt index 940a10e6..21be0c63 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/ShowFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/ShowFragment.kt @@ -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) { + 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) } } diff --git a/app/tv/build.gradle.kts b/app/tv/build.gradle.kts new file mode 100644 index 00000000..f76a759f --- /dev/null +++ b/app/tv/build.gradle.kts @@ -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) +} diff --git a/app/tv/proguard-rules.pro b/app/tv/proguard-rules.pro new file mode 100644 index 00000000..04ec1836 --- /dev/null +++ b/app/tv/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/app/tv/src/main/AndroidManifest.xml b/app/tv/src/main/AndroidManifest.xml new file mode 100644 index 00000000..c69c9010 --- /dev/null +++ b/app/tv/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt new file mode 100644 index 00000000..15dc6cea --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt @@ -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() + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/MainActivity.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/MainActivity.kt new file mode 100644 index 00000000..a81461b8 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/MainActivity.kt @@ -0,0 +1,70 @@ +package dev.jdtech.jellyfin + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +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.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 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + var startRoute = NavGraphs.root.startRoute + if (checkServersEmpty()) { + startRoute = AddServerScreenDestination + } else if (checkUser()) { + startRoute = LoginScreenDestination + } + + setContent { + FindroidTheme { + 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 + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt new file mode 100644 index 00000000..950cdd4a --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt @@ -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, +) + +@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(), + ) + } + } + } + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/AddServerScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/AddServerScreen.kt new file mode 100644 index 00000000..e1731b4c --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/AddServerScreen.kt @@ -0,0 +1,186 @@ +package dev.jdtech.jellyfin.ui + +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.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.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(), + ) { + 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() + } +} + +@Preview(device = "id:tv_1080p") +@Composable +private fun AddServerScreenLayoutPreview() { + FindroidTheme { + AddServerScreenLayout( + uiState = AddServerViewModel.UiState.Normal, + onConnectClick = {}, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/HomeScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/HomeScreen.kt new file mode 100644 index 00000000..a908e471 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/HomeScreen.kt @@ -0,0 +1,185 @@ +package dev.jdtech.jellyfin.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +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.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.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.TvLazyRow +import androidx.tv.foundation.lazy.list.items +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +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 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() + } +} + +@Preview(device = "id:tv_1080p") +@Composable +private fun HomeScreenLayoutPreview() { + FindroidTheme { + HomeScreenLayout( + uiState = HomeViewModel.UiState.Normal(dummyHomeItems), + isLoading = {}, + onClick = {}, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibrariesScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibrariesScreen.kt new file mode 100644 index 00000000..5fff7f97 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibrariesScreen.kt @@ -0,0 +1,114 @@ +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 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 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() + } +} + +@Preview(device = "id:tv_1080p") +@Composable +private fun LibrariesScreenLayoutPreview() { + FindroidTheme { + LibrariesScreenLayout( + uiState = MediaViewModel.UiState.Normal(dummyCollections), + isLoading = {}, + onClick = { _, _, _ -> }, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibraryScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibraryScreen.kt new file mode 100644 index 00000000..d58bf403 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibraryScreen.kt @@ -0,0 +1,135 @@ +package dev.jdtech.jellyfin.ui + +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.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.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() + .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()) + } +} + +@Preview(device = "id:tv_1080p") +@Composable +private fun LibraryScreenLayoutPreview() { + val data: Flow> = flowOf(PagingData.from(dummyMovies)) + FindroidTheme { + LibraryScreenLayout( + libraryName = "Movies", + uiState = LibraryViewModel.UiState.Normal(data), + onClick = {}, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/LoginScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/LoginScreen.kt new file mode 100644 index 00000000..629f1ced --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/LoginScreen.kt @@ -0,0 +1,276 @@ +package dev.jdtech.jellyfin.ui + +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.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.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(), + ) { + 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() + } +} + +@Preview(device = "id:tv_1080p") +@Composable +private fun LoginScreenLayoutPreview() { + FindroidTheme { + LoginScreenLayout( + uiState = LoginViewModel.UiState.Normal, + quickConnectUiState = LoginViewModel.QuickConnectUiState.Normal, + onLoginClick = { _, _ -> }, + onQuickConnectClick = {}, + ) + } +} + +@Preview(device = "id:tv_1080p") +@Composable +private fun LoginScreenLayoutPreviewError() { + FindroidTheme { + LoginScreenLayout( + uiState = LoginViewModel.UiState.Error(UiText.DynamicString("Invalid username or password")), + quickConnectUiState = LoginViewModel.QuickConnectUiState.Normal, + onLoginClick = { _, _ -> }, + onQuickConnectClick = {}, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/MainScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/MainScreen.kt new file mode 100644 index 00000000..39afd435 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/MainScreen.kt @@ -0,0 +1,203 @@ +package dev.jdtech.jellyfin.ui + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +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.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.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(), + ) { + 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 }) + } + } + } +} + +@Preview(device = "id:tv_1080p") +@Composable +private fun MainScreenLayoutPreview() { + FindroidTheme { + MainScreenLayout( + uiState = MainViewModel.UiState.Normal(server = dummyServer, user = dummyUser), + navigator = EmptyDestinationsNavigator, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/MovieScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/MovieScreen.kt new file mode 100644 index 00000000..ca1273be --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/MovieScreen.kt @@ -0,0 +1,371 @@ +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.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()) + } +} + +@Preview(device = "id:tv_1080p") +@Composable +private fun MovieScreenLayoutPreview() { + FindroidTheme { + 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 = {}, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/PlayerScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/PlayerScreen.kt new file mode 100644 index 00000000..e286e358 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/PlayerScreen.kt @@ -0,0 +1,315 @@ +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.dialogs.VideoPlayerTrackSelectorDialogResult +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, + resultRecipient: ResultRecipient, +) { + val viewModel = hiltViewModel() + + val uiState by viewModel.uiState.collectAsState() + + val context = LocalContext.current + + var lifecycle by remember { + mutableStateOf(Lifecycle.Event.ON_CREATE) + } + var mediaSession by remember { + mutableStateOf(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 -> { + val trackType = result.value.trackType + val index = result.value.index + + if (index == -1) { + viewModel.player.trackSelectionParameters = viewModel.player.trackSelectionParameters + .buildUpon() + .clearOverridesOfType(trackType) + .setTrackTypeDisabled(trackType, true) + .build() + } else { + viewModel.player.trackSelectionParameters = viewModel.player.trackSelectionParameters + .buildUpon() + .setOverrideForType( + TrackSelectionOverride(viewModel.player.currentTracks.groups[index].mediaTrackGroup, 0), + ) + .setTrackTypeDisabled(trackType, false) + .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(C.TRACK_TYPE_AUDIO, 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(C.TRACK_TYPE_TEXT, 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): Array { + val tracks = arrayListOf() + 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 track = Track( + id = groupIndex, + label = format.label, + language = Locale(format.language.toString()).displayLanguage, + codec = format.codecs, + selected = group.isSelected, + supported = group.isSupported, + ) + + tracks.add(track) + } + } + + val noneTrack = Track( + id = -1, + label = null, + language = null, + codec = null, + selected = !tracks.any { it.selected }, + supported = true, + ) + return arrayOf(noneTrack) + tracks +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/SeasonScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/SeasonScreen.kt new file mode 100644 index 00000000..b90b138c --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/SeasonScreen.kt @@ -0,0 +1,156 @@ +package dev.jdtech.jellyfin.ui + +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.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.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.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 + 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()) + } +} + +@Preview(device = "id:tv_1080p") +@Composable +private fun SeasonScreenLayoutPreview() { + FindroidTheme { + SeasonScreenLayout( + seriesName = "86 EIGHTY-SIX", + seasonName = "Season 1", + uiState = SeasonViewModel.UiState.Normal(dummyEpisodeItems), + onClick = {}, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/ServerSelectScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/ServerSelectScreen.kt new file mode 100644 index 00000000..85a7d3f3 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/ServerSelectScreen.kt @@ -0,0 +1,332 @@ +package dev.jdtech.jellyfin.ui + +import androidx.compose.foundation.BorderStroke +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.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() + var discoveredServers = emptyList() + + 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(), + ) { + 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)) + } + } + } +} + +@Preview(device = "id:tv_1080p") +@Composable +private fun ServerSelectScreenLayoutPreview() { + FindroidTheme { + ServerSelectScreenLayout( + uiState = ServerSelectViewModel.UiState.Normal(dummyServers), + discoveredServersState = ServerSelectViewModel.DiscoveredServersState.Servers( + dummyDiscoveredServers, + ), + onServerClick = {}, + onAddServerClick = {}, + ) + } +} + +@Preview(device = "id:tv_1080p") +@Composable +private fun ServerSelectScreenLayoutPreviewNoDiscovered() { + FindroidTheme { + ServerSelectScreenLayout( + uiState = ServerSelectViewModel.UiState.Normal(dummyServers), + discoveredServersState = ServerSelectViewModel.DiscoveredServersState.Servers( + emptyList(), + ), + onServerClick = {}, + onAddServerClick = {}, + ) + } +} + +@Preview(device = "id:tv_1080p") +@Composable +private fun ServerSelectScreenLayoutPreviewNoServers() { + FindroidTheme { + 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), + ) + } + } + } +} + +@Preview +@Composable +private fun ServerComponentPreview() { + FindroidTheme { + ServerComponent(dummyDiscoveredServer) + } +} + +@Preview +@Composable +private fun ServerComponentPreviewDiscovered() { + FindroidTheme { + ServerComponent( + server = dummyDiscoveredServer, + discovered = true, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsScreen.kt new file mode 100644 index 00000000..8fe27874 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsScreen.kt @@ -0,0 +1,161 @@ +package dev.jdtech.jellyfin.ui + +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.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.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() + .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") + } + } +} + +@Preview(device = "id:tv_1080p") +@Composable +private fun SettingsScreenLayoutPreview() { + FindroidTheme { + 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 = {}, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsSubScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsSubScreen.kt new file mode 100644 index 00000000..8bbfa872 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsSubScreen.kt @@ -0,0 +1,241 @@ +package dev.jdtech.jellyfin.ui + +import androidx.annotation.StringRes +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.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.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() + .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") + } + } +} + +@Preview(device = "id:tv_1080p") +@Composable +private fun SettingsSubScreenLayoutPreview() { + FindroidTheme { + 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 = {}, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/ShowScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/ShowScreen.kt new file mode 100644 index 00000000..c595461d --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/ShowScreen.kt @@ -0,0 +1,420 @@ +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.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()) + } +} + +@Preview(device = "id:tv_1080p") +@Composable +private fun ShowScreenLayoutPreview() { + FindroidTheme { + 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 = {}, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/UserSelectScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/UserSelectScreen.kt new file mode 100644 index 00000000..97305e9c --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/UserSelectScreen.kt @@ -0,0 +1,276 @@ +package dev.jdtech.jellyfin.ui + +import androidx.compose.foundation.BorderStroke +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.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 = emptyList() + + when (uiState) { + is UserSelectViewModel.UiState.Normal -> { + server = uiState.server + users = uiState.users + } + else -> Unit + } + + val focusRequester = remember { FocusRequester() } + + Box( + modifier = Modifier + .fillMaxSize(), + ) { + 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)) + } + } + } +} + +@Preview(device = "id:tv_1080p") +@Composable +private fun UserSelectScreenLayoutPreview() { + FindroidTheme { + UserSelectScreenLayout( + uiState = UserSelectViewModel.UiState.Normal(dummyServer, dummyUsers), + baseUrl = "https://demo.jellyfin.org/stable", + onUserClick = {}, + onAddUserClick = {}, + ) + } +} + +@Preview(device = "id:tv_1080p") +@Composable +private fun UserSelectScreenLayoutPreviewNoUsers() { + FindroidTheme { + 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, + ) + } +} + +@Preview +@Composable +private fun UserComponentPreview() { + FindroidTheme { + UserComponent( + user = dummyUser, + baseUrl = "https://demo.jellyfin.org/stable", + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/EpisodeCard.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/EpisodeCard.kt new file mode 100644 index 00000000..7d9f8f23 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/EpisodeCard.kt @@ -0,0 +1,110 @@ +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, + ) + } + } + } +} + +@Preview +@Composable +private fun ItemCardPreviewEpisode() { + FindroidTheme { + EpisodeCard( + episode = dummyEpisode, + onClick = {}, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ItemCard.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ItemCard.kt new file mode 100644 index 00000000..6d63c42e --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ItemCard.kt @@ -0,0 +1,164 @@ +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, + ) + } + } +} + +@Preview +@Composable +private fun ItemCardPreviewMovie() { + FindroidTheme { + ItemCard( + item = dummyMovie, + direction = Direction.HORIZONTAL, + onClick = {}, + ) + } +} + +@Preview +@Composable +private fun ItemCardPreviewMovieVertical() { + FindroidTheme { + ItemCard( + item = dummyMovie, + direction = Direction.VERTICAL, + onClick = {}, + ) + } +} + +@Preview +@Composable +private fun ItemCardPreviewEpisode() { + FindroidTheme { + ItemCard( + item = dummyEpisode, + direction = Direction.HORIZONTAL, + onClick = {}, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ItemPoster.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ItemPoster.kt new file mode 100644 index 00000000..623bed32 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ItemPoster.kt @@ -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, + ), + ) +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/LoadingIndicator.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/LoadingIndicator.kt new file mode 100644 index 00000000..1932c17c --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/LoadingIndicator.kt @@ -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), + ) +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/PillBorderIndicator.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/PillBorderIndicator.kt new file mode 100644 index 00000000..1cfc1958 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/PillBorderIndicator.kt @@ -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), + ) +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ProfileButton.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ProfileButton.kt new file mode 100644 index 00000000..f976abba --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ProfileButton.kt @@ -0,0 +1,93 @@ +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(), + ) + } + } +} + +@Preview +@Composable +private fun ProfileButtonPreview() { + FindroidTheme { + ProfileButton( + user = dummyUser, + onClick = {}, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ProgressBadge.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ProgressBadge.kt new file mode 100644 index 00000000..a525cda6 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ProgressBadge.kt @@ -0,0 +1,87 @@ +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.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), + ) + } + } + } + } +} + +@Preview +@Composable +private fun ProgressBadgePreviewWatched() { + FindroidTheme { + ProgressBadge( + item = dummyEpisode, + ) + } +} + +@Preview +@Composable +private fun ProgressBadgePreviewItemRemaining() { + FindroidTheme { + ProgressBadge( + item = dummyShow, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsCategoryCard.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsCategoryCard.kt new file mode 100644 index 00000000..ae040d93 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsCategoryCard.kt @@ -0,0 +1,107 @@ +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, + ) + } + } + } + } +} + +@Preview +@Composable +private fun SettingsCategoryCardPreview() { + FindroidTheme { + SettingsCategoryCard( + preference = PreferenceCategory( + nameStringResource = CoreR.string.settings_category_player, + iconDrawableId = CoreR.drawable.ic_play, + ), + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsDetailsSelectCard.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsDetailsSelectCard.kt new file mode 100644 index 00000000..94ff0ed3 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsDetailsSelectCard.kt @@ -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 = {}, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsSelectCard.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsSelectCard.kt new file mode 100644 index 00000000..46b9ce29 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsSelectCard.kt @@ -0,0 +1,121 @@ +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, + ) + } + } + } +} + +@Preview +@Composable +private fun SettingsSelectCardPreview() { + FindroidTheme { + 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 = {}, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsSwitchCard.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsSwitchCard.kt new file mode 100644 index 00000000..976cb4fd --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsSwitchCard.kt @@ -0,0 +1,150 @@ +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, + ) + } + } +} + +@Preview +@Composable +private fun SettingsSwitchCardPreview() { + FindroidTheme { + SettingsSwitchCard( + preference = PreferenceSwitch( + nameStringResource = R.string.settings_use_cache_title, + iconDrawableId = null, + backendName = "image-cache", + backendDefaultValue = false, + value = false, + ), + onClick = {}, + ) + } +} + +@Preview +@Composable +private fun SettingsSwitchCardDisabledPreview() { + FindroidTheme { + SettingsSwitchCard( + preference = PreferenceSwitch( + nameStringResource = R.string.settings_use_cache_title, + iconDrawableId = null, + enabled = false, + backendName = "image-cache", + backendDefaultValue = false, + value = false, + ), + onClick = {}, + ) + } +} + +@Preview +@Composable +private fun SettingsSwitchCardDescriptionPreview() { + FindroidTheme { + 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 = {}, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerControls.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerControls.kt new file mode 100644 index 00000000..e8717d28 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerControls.kt @@ -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), + ) + }, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerMediaButton.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerMediaButton.kt new file mode 100644 index 00000000..73139a13 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerMediaButton.kt @@ -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) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerMediaTitle.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerMediaTitle.kt new file mode 100644 index 00000000..e16bb849 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerMediaTitle.kt @@ -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", + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerOverlay.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerOverlay.kt new file mode 100644 index 00000000..86d536d2 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerOverlay.kt @@ -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), + ) + }, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerSeekBar.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerSeekBar.kt new file mode 100644 index 00000000..e98685f5 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerSeekBar.kt @@ -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(), + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerSeeker.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerSeeker.kt new file mode 100644 index 00000000..400196a1 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerSeeker.kt @@ -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') diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerState.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerState.kt new file mode 100644 index 00000000..332cb452 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerState.kt @@ -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(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() } + } diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dialogs/BaseDialogStyle.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dialogs/BaseDialogStyle.kt new file mode 100644 index 00000000..6c5f52fe --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dialogs/BaseDialogStyle.kt @@ -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, + ) +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dialogs/VideoPlayerTrackSelectorDialog.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dialogs/VideoPlayerTrackSelectorDialog.kt new file mode 100644 index 00000000..595c609a --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dialogs/VideoPlayerTrackSelectorDialog.kt @@ -0,0 +1,157 @@ +package dev.jdtech.jellyfin.ui.dialogs + +import android.os.Parcelable +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.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.media3.common.C +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 +import kotlinx.parcelize.Parcelize +import dev.jdtech.jellyfin.core.R as CoreR +import dev.jdtech.jellyfin.player.video.R as PlayerVideoR + +@Parcelize +data class VideoPlayerTrackSelectorDialogResult( + val trackType: @C.TrackType Int, + val index: Int, +) : Parcelable + +@OptIn(ExperimentalTvMaterial3Api::class) +@Destination(style = BaseDialogStyle::class) +@Composable +fun VideoPlayerTrackSelectorDialog( + trackType: @C.TrackType Int, + tracks: Array, + resultNavigator: ResultBackNavigator, +) { + val dialogTitle = when (trackType) { + C.TRACK_TYPE_AUDIO -> PlayerVideoR.string.select_audio_track + C.TRACK_TYPE_TEXT -> PlayerVideoR.string.select_subtile_track + else -> CoreR.string.unknown_error + } + Surface { + Column( + modifier = Modifier.padding(MaterialTheme.spacings.medium), + ) { + Text( + text = stringResource(id = dialogTitle), + 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 = VideoPlayerTrackSelectorDialogResult(trackType, track.id)) + }, + enabled = track.supported, + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + disabledContainerColor = 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(" - ") + .ifEmpty { stringResource(id = PlayerVideoR.string.none) }, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + } + } + } +} + +@Preview +@Composable +private fun VideoPlayerTrackSelectorDialogPreview() { + FindroidTheme { + VideoPlayerTrackSelectorDialog( + trackType = C.TRACK_TYPE_AUDIO, + tracks = arrayOf( + Track( + id = 0, + label = null, + language = "English", + codec = "flac", + selected = true, + supported = true, + ), + Track( + id = 0, + label = null, + language = "Japanese", + codec = "flac", + selected = false, + supported = true, + ), + Track( + id = 0, + label = null, + language = "English", + codec = "truehd", + selected = false, + supported = false, + ), + ), + resultNavigator = EmptyResultBackNavigator(), + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Collections.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Collections.kt new file mode 100644 index 00000000..fb7c3f35 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Collections.kt @@ -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, +) diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Episodes.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Episodes.kt new file mode 100644 index 00000000..f02a8a31 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Episodes.kt @@ -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 Ai’s 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), +) diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/HomeItems.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/HomeItems.kt new file mode 100644 index 00000000..9a335d4f --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/HomeItems.kt @@ -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, + ), + ), +) diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Movies.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Movies.kt new file mode 100644 index 00000000..8ba5506a --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Movies.kt @@ -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, +) diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Servers.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Servers.kt new file mode 100644 index 00000000..2c8995b2 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Servers.kt @@ -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) diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Show.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Show.kt new file mode 100644 index 00000000..00cfb073 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Show.kt @@ -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, +) diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Users.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Users.kt new file mode 100644 index 00000000..868d0d57 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Users.kt @@ -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) diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Color.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Color.kt new file mode 100644 index 00000000..e662f891 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Color.kt @@ -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, +) diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Shape.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Shape.kt new file mode 100644 index 00000000..5470e4c3 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Shape.kt @@ -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, +) diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Spacing.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Spacing.kt new file mode 100644 index 00000000..0d69d897 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Spacing.kt @@ -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 } diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Theme.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Theme.kt new file mode 100644 index 00000000..30475d26 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package dev.jdtech.jellyfin.ui.theme + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.NonInteractiveSurfaceDefaults +import androidx.tv.material3.Surface +import androidx.tv.material3.MaterialTheme as MaterialThemeTv + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun FindroidTheme( + content: @Composable BoxScope.() -> Unit, +) { + MaterialTheme( + colorScheme = ColorScheme, + typography = Typography, + shapes = shapes, + ) { + CompositionLocalProvider( + LocalSpacings provides Spacings(), + ) { + MaterialThemeTv( + colorScheme = ColorSchemeTv, + typography = TypographyTv, + shapes = shapesTv, + content = { + Surface( + colors = NonInteractiveSurfaceDefaults.colors( + containerColor = androidx.tv.material3.MaterialTheme.colorScheme.background, + ), + shape = RectangleShape, + ) { + Box( + modifier = Modifier.background( + Brush.linearGradient( + listOf( + Color.Black, + Color(0xFF001721), + ), + ), + ), + content = content, + ) + } + }, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Type.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Type.kt new file mode 100644 index 00000000..ff6732a2 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Type.kt @@ -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, +) diff --git a/app/tv/src/main/res/drawable/ic_banner.xml b/app/tv/src/main/res/drawable/ic_banner.xml new file mode 100644 index 00000000..b2adc4c4 --- /dev/null +++ b/app/tv/src/main/res/drawable/ic_banner.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/tv/src/main/res/values/themes.xml b/app/tv/src/main/res/values/themes.xml new file mode 100644 index 00000000..df0af6c3 --- /dev/null +++ b/app/tv/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +