Merge branch 'main' into main
This commit is contained in:
commit
a972832aae
146 changed files with 7571 additions and 733 deletions
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
@ -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.
|
37
.github/workflows/build.yaml
vendored
37
.github/workflows/build.yaml
vendored
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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<FrameLayout>(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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -35,6 +35,7 @@ import dev.jdtech.jellyfin.models.isDownloading
|
|||
import dev.jdtech.jellyfin.utils.setIconTintColorAttribute
|
||||
import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetEvent
|
||||
import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.DateTime
|
||||
|
@ -130,13 +131,15 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
|
||||
when (playerItems) {
|
||||
is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
|
||||
is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems)
|
||||
launch {
|
||||
playerViewModel.eventsChannelFlow.collect { event ->
|
||||
when (event) {
|
||||
is PlayerItemsEvent.PlayerItemsReady -> bindPlayerItems(event.items)
|
||||
is PlayerItemsEvent.PlayerItemsError -> bindPlayerItemsError(event.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -145,13 +148,11 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
}
|
||||
|
||||
binding.itemActions.checkButton.setOnClickListener {
|
||||
val played = viewModel.togglePlayed()
|
||||
bindCheckButtonState(played)
|
||||
viewModel.togglePlayed()
|
||||
}
|
||||
|
||||
binding.itemActions.favoriteButton.setOnClickListener {
|
||||
val favorite = viewModel.toggleFavorite()
|
||||
bindFavoriteButtonState(favorite)
|
||||
viewModel.toggleFavorite()
|
||||
}
|
||||
|
||||
binding.itemActions.downloadButton.setOnClickListener {
|
||||
|
@ -310,8 +311,8 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
binding.overview.text = uiState.error.message
|
||||
}
|
||||
|
||||
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
|
||||
navigateToPlayerActivity(items.items.toTypedArray())
|
||||
private fun bindPlayerItems(items: List<PlayerItem>) {
|
||||
navigateToPlayerActivity(items.toTypedArray())
|
||||
binding.itemActions.playButton.setIconResource(CoreR.drawable.ic_play)
|
||||
binding.itemActions.progressPlay.visibility = View.INVISIBLE
|
||||
}
|
||||
|
@ -347,12 +348,12 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
|
||||
Timber.e(error.error.message)
|
||||
private fun bindPlayerItemsError(error: Exception) {
|
||||
Timber.e(error.message)
|
||||
binding.playerItemsError.isVisible = true
|
||||
playButtonNormal()
|
||||
binding.playerItemsErrorDetails.setOnClickListener {
|
||||
ErrorDialogFragment.newInstance(error.error).show(parentFragmentManager, ErrorDialogFragment.TAG)
|
||||
ErrorDialogFragment.newInstance(error).show(parentFragmentManager, ErrorDialogFragment.TAG)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -39,6 +39,7 @@ import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
|||
import dev.jdtech.jellyfin.utils.setIconTintColorAttribute
|
||||
import dev.jdtech.jellyfin.viewmodels.MovieEvent
|
||||
import dev.jdtech.jellyfin.viewmodels.MovieViewModel
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
@ -126,6 +127,15 @@ class MovieFragment : Fragment() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
playerViewModel.eventsChannelFlow.collect { event ->
|
||||
when (event) {
|
||||
is PlayerItemsEvent.PlayerItemsReady -> bindPlayerItems(event.items)
|
||||
is PlayerItemsEvent.PlayerItemsError -> bindPlayerItemsError(event.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -137,13 +147,6 @@ class MovieFragment : Fragment() {
|
|||
errorDialog.show(parentFragmentManager, ErrorDialogFragment.TAG)
|
||||
}
|
||||
|
||||
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
|
||||
when (playerItems) {
|
||||
is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
|
||||
is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems)
|
||||
}
|
||||
}
|
||||
|
||||
binding.itemActions.playButton.setOnClickListener {
|
||||
binding.itemActions.playButton.isEnabled = false
|
||||
binding.itemActions.playButton.setIconResource(android.R.color.transparent)
|
||||
|
@ -180,13 +183,11 @@ class MovieFragment : Fragment() {
|
|||
}
|
||||
|
||||
binding.itemActions.checkButton.setOnClickListener {
|
||||
val played = viewModel.togglePlayed()
|
||||
bindCheckButtonState(played)
|
||||
viewModel.togglePlayed()
|
||||
}
|
||||
|
||||
binding.itemActions.favoriteButton.setOnClickListener {
|
||||
val favorite = viewModel.toggleFavorite()
|
||||
bindFavoriteButtonState(favorite)
|
||||
viewModel.toggleFavorite()
|
||||
}
|
||||
|
||||
binding.itemActions.downloadButton.setOnClickListener {
|
||||
|
@ -433,18 +434,18 @@ class MovieFragment : Fragment() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
|
||||
navigateToPlayerActivity(items.items.toTypedArray())
|
||||
private fun bindPlayerItems(items: List<PlayerItem>) {
|
||||
navigateToPlayerActivity(items.toTypedArray())
|
||||
binding.itemActions.playButton.setIconResource(CoreR.drawable.ic_play)
|
||||
binding.itemActions.progressPlay.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
|
||||
Timber.e(error.error.message)
|
||||
private fun bindPlayerItemsError(error: Exception) {
|
||||
Timber.e(error.message)
|
||||
binding.playerItemsError.visibility = View.VISIBLE
|
||||
playButtonNormal()
|
||||
binding.playerItemsErrorDetails.setOnClickListener {
|
||||
ErrorDialogFragment.newInstance(error.error)
|
||||
ErrorDialogFragment.newInstance(error)
|
||||
.show(parentFragmentManager, ErrorDialogFragment.TAG)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,9 +17,7 @@ import dev.jdtech.jellyfin.adapters.EpisodeListAdapter
|
|||
import dev.jdtech.jellyfin.databinding.FragmentSeasonBinding
|
||||
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
|
||||
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||
import dev.jdtech.jellyfin.models.PlayerItem
|
||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
||||
import dev.jdtech.jellyfin.viewmodels.SeasonEvent
|
||||
import dev.jdtech.jellyfin.viewmodels.SeasonViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -30,7 +28,6 @@ class SeasonFragment : Fragment() {
|
|||
|
||||
private lateinit var binding: FragmentSeasonBinding
|
||||
private val viewModel: SeasonViewModel by viewModels()
|
||||
private val playerViewModel: PlayerViewModel by viewModels()
|
||||
private val args: SeasonFragmentArgs by navArgs()
|
||||
|
||||
private lateinit var errorDialog: ErrorDialogFragment
|
||||
|
@ -74,15 +71,6 @@ class SeasonFragment : Fragment() {
|
|||
viewModel.loadEpisodes(args.seriesId, args.seasonId, args.offline)
|
||||
}
|
||||
|
||||
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
|
||||
when (playerItems) {
|
||||
is PlayerViewModel.PlayerItems -> {
|
||||
navigateToPlayerActivity(playerItems.items.toTypedArray())
|
||||
}
|
||||
is PlayerViewModel.PlayerItemError -> {}
|
||||
}
|
||||
}
|
||||
|
||||
binding.errorLayout.errorDetailsButton.setOnClickListener {
|
||||
errorDialog.show(parentFragmentManager, ErrorDialogFragment.TAG)
|
||||
}
|
||||
|
@ -129,14 +117,4 @@ class SeasonFragment : Fragment() {
|
|||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun navigateToPlayerActivity(
|
||||
playerItems: Array<PlayerItem>,
|
||||
) {
|
||||
findNavController().navigate(
|
||||
SeasonFragmentDirections.actionSeasonFragmentToPlayerActivity(
|
||||
playerItems,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ import dev.jdtech.jellyfin.models.PlayerItem
|
|||
import dev.jdtech.jellyfin.models.isDownloaded
|
||||
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
|
||||
import dev.jdtech.jellyfin.utils.setIconTintColorAttribute
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
||||
import dev.jdtech.jellyfin.viewmodels.ShowEvent
|
||||
import dev.jdtech.jellyfin.viewmodels.ShowViewModel
|
||||
|
@ -86,6 +87,15 @@ class ShowFragment : Fragment() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
playerViewModel.eventsChannelFlow.collect { event ->
|
||||
when (event) {
|
||||
is PlayerItemsEvent.PlayerItemsReady -> bindPlayerItems(event.items)
|
||||
is PlayerItemsEvent.PlayerItemsError -> bindPlayerItemsError(event.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -96,13 +106,6 @@ class ShowFragment : Fragment() {
|
|||
viewModel.loadData(args.itemId, args.offline)
|
||||
}
|
||||
|
||||
playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
|
||||
when (playerItems) {
|
||||
is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
|
||||
is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems)
|
||||
}
|
||||
}
|
||||
|
||||
binding.itemActions.trailerButton.setOnClickListener {
|
||||
viewModel.item.trailer.let { trailerUri ->
|
||||
val intent = Intent(
|
||||
|
@ -144,13 +147,11 @@ class ShowFragment : Fragment() {
|
|||
}
|
||||
|
||||
binding.itemActions.checkButton.setOnClickListener {
|
||||
val played = viewModel.togglePlayed()
|
||||
bindCheckButtonState(played)
|
||||
viewModel.togglePlayed()
|
||||
}
|
||||
|
||||
binding.itemActions.favoriteButton.setOnClickListener {
|
||||
val favorite = viewModel.toggleFavorite()
|
||||
bindFavoriteButtonState(favorite)
|
||||
viewModel.toggleFavorite()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -290,18 +291,18 @@ class ShowFragment : Fragment() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
|
||||
navigateToPlayerActivity(items.items.toTypedArray())
|
||||
private fun bindPlayerItems(items: List<PlayerItem>) {
|
||||
navigateToPlayerActivity(items.toTypedArray())
|
||||
binding.itemActions.playButton.setIconResource(CoreR.drawable.ic_play)
|
||||
binding.itemActions.progressPlay.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
|
||||
Timber.e(error.error.message)
|
||||
private fun bindPlayerItemsError(error: Exception) {
|
||||
Timber.e(error.message)
|
||||
binding.playerItemsError.visibility = View.VISIBLE
|
||||
playButtonNormal()
|
||||
binding.playerItemsErrorDetails.setOnClickListener {
|
||||
ErrorDialogFragment.newInstance(error.error)
|
||||
ErrorDialogFragment.newInstance(error)
|
||||
.show(parentFragmentManager, ErrorDialogFragment.TAG)
|
||||
}
|
||||
}
|
||||
|
|
112
app/tv/build.gradle.kts
Normal file
112
app/tv/build.gradle.kts
Normal file
|
@ -0,0 +1,112 @@
|
|||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.ktlint)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "dev.jdtech.jellyfin"
|
||||
compileSdk = Versions.compileSdk
|
||||
buildToolsVersion = Versions.buildTools
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "dev.jdtech.jellyfin"
|
||||
minSdk = Versions.minSdk
|
||||
targetSdk = Versions.targetSdk
|
||||
|
||||
versionCode = Versions.appCode
|
||||
versionName = Versions.appName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
named("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
named("release") {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
register("staging") {
|
||||
initWith(getByName("release"))
|
||||
applicationIdSuffix = ".staging"
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += "variant"
|
||||
productFlavors {
|
||||
register("libre") {
|
||||
dimension = "variant"
|
||||
isDefault = true
|
||||
}
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
reset()
|
||||
include("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = Versions.java
|
||||
targetCompatibility = Versions.java
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = Versions.composeCompiler
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ktlint {
|
||||
version.set(Versions.ktlint)
|
||||
android.set(true)
|
||||
ignoreFailures.set(false)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core"))
|
||||
implementation(project(":data"))
|
||||
implementation(project(":preferences"))
|
||||
implementation(project(":player:core"))
|
||||
implementation(project(":player:video"))
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.media3.exoplayer)
|
||||
implementation(libs.androidx.media3.ui)
|
||||
implementation(libs.androidx.media3.session)
|
||||
implementation(libs.androidx.paging.compose)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.svg)
|
||||
implementation(libs.compose.destinations.core)
|
||||
ksp(libs.compose.destinations.ksp)
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.jellyfin.core)
|
||||
implementation(libs.androidx.tv.foundation)
|
||||
implementation(libs.androidx.tv.material)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
}
|
30
app/tv/proguard-rules.pro
vendored
Normal file
30
app/tv/proguard-rules.pro
vendored
Normal file
|
@ -0,0 +1,30 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
# These classes are from okhttp and are not used in Android
|
||||
-dontwarn org.bouncycastle.jsse.BCSSLSocket
|
||||
-dontwarn org.bouncycastle.jsse.BCSSLParameters
|
||||
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
|
||||
-dontwarn org.conscrypt.*
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
|
||||
-dontwarn org.openjsse.net.ssl.OpenJSSE
|
38
app/tv/src/main/AndroidManifest.xml
Normal file
38
app/tv/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,38 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="true" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".BaseApplication"
|
||||
android:allowBackup="true"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Findroid">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Findroid">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".PlayerActivity"
|
||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
|
||||
android:screenOrientation="sensorLandscape" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
18
app/tv/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt
Normal file
18
app/tv/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt
Normal file
|
@ -0,0 +1,18 @@
|
|||
package dev.jdtech.jellyfin
|
||||
|
||||
import android.app.Application
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.decode.SvgDecoder
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class BaseApplication : Application(), ImageLoaderFactory {
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader.Builder(this)
|
||||
.components {
|
||||
add(SvgDecoder.Factory())
|
||||
}
|
||||
.build()
|
||||
}
|
||||
}
|
70
app/tv/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
Normal file
70
app/tv/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
Normal file
|
@ -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
|
||||
}
|
||||
}
|
52
app/tv/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt
Normal file
52
app/tv/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt
Normal file
|
@ -0,0 +1,52 @@
|
|||
package dev.jdtech.jellyfin
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import com.ramcosta.composedestinations.DestinationsNavHost
|
||||
import com.ramcosta.composedestinations.annotation.ActivityDestination
|
||||
import com.ramcosta.composedestinations.manualcomposablecalls.composable
|
||||
import com.ramcosta.composedestinations.scope.resultRecipient
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dev.jdtech.jellyfin.destinations.PlayerActivityDestination
|
||||
import dev.jdtech.jellyfin.destinations.PlayerScreenDestination
|
||||
import dev.jdtech.jellyfin.models.PlayerItem
|
||||
import dev.jdtech.jellyfin.ui.PlayerScreen
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
|
||||
data class PlayerActivityNavArgs(
|
||||
val items: ArrayList<PlayerItem>,
|
||||
)
|
||||
|
||||
@AndroidEntryPoint
|
||||
@ActivityDestination(
|
||||
navArgsDelegate = PlayerActivityNavArgs::class,
|
||||
)
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
class PlayerActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val args = PlayerActivityDestination.argsFrom(intent)
|
||||
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
setContent {
|
||||
FindroidTheme {
|
||||
DestinationsNavHost(
|
||||
navGraph = NavGraphs.root,
|
||||
startRoute = PlayerScreenDestination,
|
||||
) {
|
||||
composable(PlayerScreenDestination) {
|
||||
PlayerScreen(
|
||||
navigator = destinationsNavigator,
|
||||
items = args.items,
|
||||
resultRecipient = resultRecipient(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
186
app/tv/src/main/java/dev/jdtech/jellyfin/ui/AddServerScreen.kt
Normal file
186
app/tv/src/main/java/dev/jdtech/jellyfin/ui/AddServerScreen.kt
Normal file
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
185
app/tv/src/main/java/dev/jdtech/jellyfin/ui/HomeScreen.kt
Normal file
185
app/tv/src/main/java/dev/jdtech/jellyfin/ui/HomeScreen.kt
Normal file
|
@ -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<HomeItem> by remember { mutableStateOf(emptyList()) }
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
when (uiState) {
|
||||
is HomeViewModel.UiState.Normal -> {
|
||||
homeItems = uiState.homeItems
|
||||
isLoading(false)
|
||||
}
|
||||
is HomeViewModel.UiState.Loading -> {
|
||||
isLoading(true)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
TvLazyColumn(
|
||||
contentPadding = PaddingValues(bottom = MaterialTheme.spacings.large),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester),
|
||||
) {
|
||||
items(homeItems, key = { it.id }) { homeItem ->
|
||||
when (homeItem) {
|
||||
is HomeItem.Section -> {
|
||||
Text(
|
||||
text = homeItem.homeSection.name.asString(),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(start = MaterialTheme.spacings.large),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
|
||||
TvLazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
|
||||
contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.large),
|
||||
) {
|
||||
items(homeItem.homeSection.items, key = { it.id }) { item ->
|
||||
ItemCard(
|
||||
item = item,
|
||||
direction = Direction.HORIZONTAL,
|
||||
onClick = {
|
||||
onClick(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
|
||||
}
|
||||
is HomeItem.ViewItem -> {
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.latest_library, homeItem.view.name),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(start = MaterialTheme.spacings.large),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
|
||||
TvLazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
|
||||
contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.large),
|
||||
) {
|
||||
items(homeItem.view.items.orEmpty(), key = { it.id }) { item ->
|
||||
ItemCard(
|
||||
item = item,
|
||||
direction = Direction.VERTICAL,
|
||||
onClick = {
|
||||
onClick(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(homeItems) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(device = "id:tv_1080p")
|
||||
@Composable
|
||||
private fun HomeScreenLayoutPreview() {
|
||||
FindroidTheme {
|
||||
HomeScreenLayout(
|
||||
uiState = HomeViewModel.UiState.Normal(dummyHomeItems),
|
||||
isLoading = {},
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
114
app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibrariesScreen.kt
Normal file
114
app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibrariesScreen.kt
Normal file
|
@ -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<FindroidCollection> by remember {
|
||||
mutableStateOf(emptyList())
|
||||
}
|
||||
|
||||
when (uiState) {
|
||||
is MediaViewModel.UiState.Normal -> {
|
||||
collections = uiState.collections
|
||||
isLoading(false)
|
||||
}
|
||||
is MediaViewModel.UiState.Loading -> {
|
||||
isLoading(true)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Fixed(3),
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large),
|
||||
contentPadding = PaddingValues(
|
||||
start = MaterialTheme.spacings.large,
|
||||
top = MaterialTheme.spacings.small,
|
||||
end = MaterialTheme.spacings.large,
|
||||
bottom = MaterialTheme.spacings.large,
|
||||
),
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
) {
|
||||
items(collections, key = { it.id }) { collection ->
|
||||
ItemCard(
|
||||
item = collection,
|
||||
direction = Direction.HORIZONTAL,
|
||||
onClick = {
|
||||
onClick(collection.id, collection.name, collection.type)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(collections) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(device = "id:tv_1080p")
|
||||
@Composable
|
||||
private fun LibrariesScreenLayoutPreview() {
|
||||
FindroidTheme {
|
||||
LibrariesScreenLayout(
|
||||
uiState = MediaViewModel.UiState.Normal(dummyCollections),
|
||||
isLoading = {},
|
||||
onClick = { _, _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
135
app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibraryScreen.kt
Normal file
135
app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibraryScreen.kt
Normal file
|
@ -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<PagingData<FindroidItem>> = flowOf(PagingData.from(dummyMovies))
|
||||
FindroidTheme {
|
||||
LibraryScreenLayout(
|
||||
libraryName = "Movies",
|
||||
uiState = LibraryViewModel.UiState.Normal(data),
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
276
app/tv/src/main/java/dev/jdtech/jellyfin/ui/LoginScreen.kt
Normal file
276
app/tv/src/main/java/dev/jdtech/jellyfin/ui/LoginScreen.kt
Normal file
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
203
app/tv/src/main/java/dev/jdtech/jellyfin/ui/MainScreen.kt
Normal file
203
app/tv/src/main/java/dev/jdtech/jellyfin/ui/MainScreen.kt
Normal file
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
371
app/tv/src/main/java/dev/jdtech/jellyfin/ui/MovieScreen.kt
Normal file
371
app/tv/src/main/java/dev/jdtech/jellyfin/ui/MovieScreen.kt
Normal file
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
315
app/tv/src/main/java/dev/jdtech/jellyfin/ui/PlayerScreen.kt
Normal file
315
app/tv/src/main/java/dev/jdtech/jellyfin/ui/PlayerScreen.kt
Normal file
|
@ -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<PlayerItem>,
|
||||
resultRecipient: ResultRecipient<VideoPlayerTrackSelectorDialogDestination, VideoPlayerTrackSelectorDialogResult>,
|
||||
) {
|
||||
val viewModel = hiltViewModel<PlayerActivityViewModel>()
|
||||
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
var lifecycle by remember {
|
||||
mutableStateOf(Lifecycle.Event.ON_CREATE)
|
||||
}
|
||||
var mediaSession by remember {
|
||||
mutableStateOf<MediaSession?>(null)
|
||||
}
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
lifecycle = event
|
||||
|
||||
// Handle creation and release of media session
|
||||
when (lifecycle) {
|
||||
Lifecycle.Event.ON_STOP -> {
|
||||
println("ON_STOP")
|
||||
mediaSession?.release()
|
||||
}
|
||||
|
||||
Lifecycle.Event.ON_START -> {
|
||||
println("ON_START")
|
||||
mediaSession = MediaSession.Builder(context, viewModel.player).build()
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
val videoPlayerState = rememberVideoPlayerState()
|
||||
|
||||
var currentPosition by remember {
|
||||
mutableLongStateOf(0L)
|
||||
}
|
||||
var isPlaying by remember {
|
||||
mutableStateOf(viewModel.player.isPlaying)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(300)
|
||||
currentPosition = viewModel.player.currentPosition
|
||||
isPlaying = viewModel.player.isPlaying
|
||||
}
|
||||
}
|
||||
|
||||
resultRecipient.onNavResult { result ->
|
||||
when (result) {
|
||||
is NavResult.Canceled -> Unit
|
||||
is NavResult.Value -> {
|
||||
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<Track> {
|
||||
val tracks = arrayListOf<Track>()
|
||||
for (groupIndex in 0 until player.currentTracks.groups.count()) {
|
||||
val group = player.currentTracks.groups[groupIndex]
|
||||
if (group.type == type) {
|
||||
val format = group.mediaTrackGroup.getFormat(0)
|
||||
|
||||
val 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
|
||||
}
|
156
app/tv/src/main/java/dev/jdtech/jellyfin/ui/SeasonScreen.kt
Normal file
156
app/tv/src/main/java/dev/jdtech/jellyfin/ui/SeasonScreen.kt
Normal file
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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<DiscoveredServer>()
|
||||
var discoveredServers = emptyList<DiscoveredServer>()
|
||||
|
||||
when (uiState) {
|
||||
is ServerSelectViewModel.UiState.Normal -> {
|
||||
servers =
|
||||
uiState.servers.map { DiscoveredServer(id = it.id, name = it.name, address = "") }
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
when (discoveredServersState) {
|
||||
is ServerSelectViewModel.DiscoveredServersState.Servers -> {
|
||||
discoveredServers = discoveredServersState.servers
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
161
app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsScreen.kt
Normal file
161
app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsScreen.kt
Normal file
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
241
app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsSubScreen.kt
Normal file
241
app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsSubScreen.kt
Normal file
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
420
app/tv/src/main/java/dev/jdtech/jellyfin/ui/ShowScreen.kt
Normal file
420
app/tv/src/main/java/dev/jdtech/jellyfin/ui/ShowScreen.kt
Normal file
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
276
app/tv/src/main/java/dev/jdtech/jellyfin/ui/UserSelectScreen.kt
Normal file
276
app/tv/src/main/java/dev/jdtech/jellyfin/ui/UserSelectScreen.kt
Normal file
|
@ -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<User> = 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",
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
|
@ -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),
|
||||
)
|
||||
}
|
|
@ -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),
|
||||
)
|
||||
}
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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')
|
|
@ -0,0 +1,45 @@
|
|||
package dev.jdtech.jellyfin.ui.components.player
|
||||
|
||||
import androidx.annotation.IntRange
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
|
||||
import kotlinx.coroutines.flow.consumeAsFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
|
||||
class VideoPlayerState internal constructor(
|
||||
@IntRange(from = 0)
|
||||
private val hideSeconds: Int,
|
||||
) {
|
||||
private var _controlsVisible by mutableStateOf(true)
|
||||
val controlsVisible get() = _controlsVisible
|
||||
|
||||
fun showControls(seconds: Int = hideSeconds) {
|
||||
_controlsVisible = true
|
||||
channel.trySend(seconds)
|
||||
}
|
||||
|
||||
private val channel = Channel<Int>(CONFLATED)
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
suspend fun observe() {
|
||||
channel.consumeAsFlow()
|
||||
.debounce { it.toLong() * 1000 }
|
||||
.collect { _controlsVisible = false }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberVideoPlayerState(@IntRange(from = 0) hideSeconds: Int = 2) =
|
||||
remember {
|
||||
VideoPlayerState(hideSeconds = hideSeconds)
|
||||
}
|
||||
.also {
|
||||
LaunchedEffect(it) { it.observe() }
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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<Track>,
|
||||
resultNavigator: ResultBackNavigator<VideoPlayerTrackSelectorDialogResult>,
|
||||
) {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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),
|
||||
)
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
62
app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Movies.kt
Normal file
62
app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Movies.kt
Normal file
|
@ -0,0 +1,62 @@
|
|||
package dev.jdtech.jellyfin.ui.dummy
|
||||
|
||||
import dev.jdtech.jellyfin.models.FindroidImages
|
||||
import dev.jdtech.jellyfin.models.FindroidMediaStream
|
||||
import dev.jdtech.jellyfin.models.FindroidMovie
|
||||
import dev.jdtech.jellyfin.models.FindroidSource
|
||||
import dev.jdtech.jellyfin.models.FindroidSourceType
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
val dummyMovie = FindroidMovie(
|
||||
id = UUID.randomUUID(),
|
||||
name = "Alita: Battle Angel",
|
||||
originalTitle = null,
|
||||
overview = "When Alita awakens with no memory of who she is in a future world she does not recognize, she is taken in by Ido, a compassionate doctor who realizes that somewhere in this abandoned cyborg shell is the heart and soul of a young woman with an extraordinary past.",
|
||||
sources = listOf(
|
||||
FindroidSource(
|
||||
id = "",
|
||||
name = "",
|
||||
type = FindroidSourceType.REMOTE,
|
||||
path = "",
|
||||
size = 0L,
|
||||
mediaStreams = listOf(
|
||||
FindroidMediaStream(
|
||||
title = "",
|
||||
displayTitle = "",
|
||||
language = "en",
|
||||
type = MediaStreamType.VIDEO,
|
||||
codec = "hevc",
|
||||
isExternal = false,
|
||||
path = "",
|
||||
channelLayout = null,
|
||||
videoRangeType = null,
|
||||
height = 1080,
|
||||
width = 1920,
|
||||
videoDoViTitle = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
played = false,
|
||||
favorite = true,
|
||||
canPlay = true,
|
||||
canDownload = true,
|
||||
runtimeTicks = 20L,
|
||||
playbackPositionTicks = 15L,
|
||||
premiereDate = LocalDateTime.parse("2019-02-14T00:00:00"),
|
||||
people = emptyList(),
|
||||
genres = listOf("Action", "Sience Fiction", "Adventure"),
|
||||
communityRating = 7.2f,
|
||||
officialRating = "PG-13",
|
||||
status = "Ended",
|
||||
productionYear = 2019,
|
||||
endDate = null,
|
||||
trailer = "https://www.youtube.com/watch?v=puKWa8hrvA8",
|
||||
images = FindroidImages(),
|
||||
)
|
||||
|
||||
val dummyMovies = listOf(
|
||||
dummyMovie,
|
||||
)
|
22
app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Servers.kt
Normal file
22
app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Servers.kt
Normal file
|
@ -0,0 +1,22 @@
|
|||
package dev.jdtech.jellyfin.ui.dummy
|
||||
|
||||
import dev.jdtech.jellyfin.models.DiscoveredServer
|
||||
import dev.jdtech.jellyfin.models.Server
|
||||
import java.util.UUID
|
||||
|
||||
val dummyDiscoveredServer = DiscoveredServer(
|
||||
id = "",
|
||||
name = "Demo server",
|
||||
address = "https://demo.jellyfin.org/stable",
|
||||
)
|
||||
|
||||
val dummyDiscoveredServers = listOf(dummyDiscoveredServer)
|
||||
|
||||
val dummyServer = Server(
|
||||
id = "",
|
||||
name = "Demo server",
|
||||
currentServerAddressId = UUID.randomUUID(),
|
||||
currentUserId = UUID.randomUUID(),
|
||||
)
|
||||
|
||||
val dummyServers = listOf(dummyServer)
|
30
app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Show.kt
Normal file
30
app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Show.kt
Normal file
|
@ -0,0 +1,30 @@
|
|||
package dev.jdtech.jellyfin.ui.dummy
|
||||
|
||||
import dev.jdtech.jellyfin.models.FindroidImages
|
||||
import dev.jdtech.jellyfin.models.FindroidShow
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
val dummyShow = FindroidShow(
|
||||
id = UUID.randomUUID(),
|
||||
name = "Attack on Titan",
|
||||
originalTitle = null,
|
||||
overview = "After his hometown is destroyed and his mother is killed, young Eren Yeager vows to cleanse the earth of the giant humanoid Titans that have brought humanity to the brink of extinction.",
|
||||
sources = emptyList(),
|
||||
played = false,
|
||||
favorite = false,
|
||||
canPlay = true,
|
||||
canDownload = false,
|
||||
runtimeTicks = 0L,
|
||||
communityRating = 8.8f,
|
||||
endDate = LocalDateTime.parse("2023-11-04T00:00:00"),
|
||||
genres = listOf("Action", "Sience Fiction", "Adventure"),
|
||||
images = FindroidImages(),
|
||||
officialRating = "TV-MA",
|
||||
people = emptyList(),
|
||||
productionYear = 2013,
|
||||
seasons = emptyList(),
|
||||
status = "Ended",
|
||||
trailer = null,
|
||||
unplayedItemCount = 20,
|
||||
)
|
12
app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Users.kt
Normal file
12
app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Users.kt
Normal file
|
@ -0,0 +1,12 @@
|
|||
package dev.jdtech.jellyfin.ui.dummy
|
||||
|
||||
import dev.jdtech.jellyfin.models.User
|
||||
import java.util.UUID
|
||||
|
||||
val dummyUser = User(
|
||||
id = UUID.randomUUID(),
|
||||
name = "Username",
|
||||
serverId = "",
|
||||
)
|
||||
|
||||
val dummyUsers = listOf(dummyUser)
|
34
app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Color.kt
Normal file
34
app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Color.kt
Normal file
|
@ -0,0 +1,34 @@
|
|||
package dev.jdtech.jellyfin.ui.theme
|
||||
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.darkColorScheme as darkColorSchemeTv
|
||||
|
||||
val PrimaryDark = Color(0xffa1c9ff)
|
||||
val OnPrimaryDark = Color(0xff00315e)
|
||||
val PrimaryContainerDark = Color(0xff004884)
|
||||
val OnPrimaryContainerDark = Color(0xffd3e4ff)
|
||||
val Neutral900 = Color(0xff121A21)
|
||||
val Neutral1000 = Color(0xff000000)
|
||||
|
||||
val Yellow = Color(0xFFF2C94C)
|
||||
|
||||
val ColorScheme = darkColorScheme(
|
||||
primary = PrimaryDark,
|
||||
onPrimary = OnPrimaryDark,
|
||||
primaryContainer = PrimaryContainerDark,
|
||||
onPrimaryContainer = OnPrimaryContainerDark,
|
||||
surface = Neutral900,
|
||||
background = Neutral1000,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
val ColorSchemeTv = darkColorSchemeTv(
|
||||
primary = ColorScheme.primary,
|
||||
onPrimary = ColorScheme.onPrimary,
|
||||
primaryContainer = ColorScheme.primaryContainer,
|
||||
onPrimaryContainer = ColorScheme.onPrimaryContainer,
|
||||
surface = ColorScheme.surface,
|
||||
background = ColorScheme.background,
|
||||
)
|
18
app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Shape.kt
Normal file
18
app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Shape.kt
Normal file
|
@ -0,0 +1,18 @@
|
|||
package dev.jdtech.jellyfin.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Shapes as ShapesTv
|
||||
|
||||
val shapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(10.dp),
|
||||
small = RoundedCornerShape(10.dp),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
val shapesTv = ShapesTv(
|
||||
extraSmall = shapes.extraSmall,
|
||||
small = shapes.small,
|
||||
)
|
25
app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Spacing.kt
Normal file
25
app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Spacing.kt
Normal file
|
@ -0,0 +1,25 @@
|
|||
package dev.jdtech.jellyfin.ui.theme
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
|
||||
@Immutable
|
||||
data class Spacings(
|
||||
val default: Dp = 24.dp,
|
||||
val extraSmall: Dp = 4.dp,
|
||||
val small: Dp = 8.dp,
|
||||
val medium: Dp = 16.dp,
|
||||
val large: Dp = 32.dp,
|
||||
val extraLarge: Dp = 64.dp,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
val MaterialTheme.spacings
|
||||
get() = Spacings()
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
val LocalSpacings = compositionLocalOf { MaterialTheme.spacings }
|
58
app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Theme.kt
Normal file
58
app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Theme.kt
Normal file
|
@ -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,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
45
app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Type.kt
Normal file
45
app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Type.kt
Normal file
|
@ -0,0 +1,45 @@
|
|||
package dev.jdtech.jellyfin.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Typography as TypographyTv
|
||||
|
||||
val Typography = Typography(
|
||||
displayMedium = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 48.sp,
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 24.sp,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
),
|
||||
titleSmall = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
val TypographyTv = TypographyTv(
|
||||
displayMedium = Typography.displayMedium,
|
||||
headlineMedium = Typography.headlineMedium,
|
||||
titleMedium = Typography.titleMedium,
|
||||
titleSmall = Typography.titleSmall,
|
||||
bodyMedium = Typography.bodyMedium,
|
||||
labelMedium = Typography.labelMedium,
|
||||
)
|
76
app/tv/src/main/res/drawable/ic_banner.xml
Normal file
76
app/tv/src/main/res/drawable/ic_banner.xml
Normal file
|
@ -0,0 +1,76 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="1536dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="1536"
|
||||
android:viewportHeight="512">
|
||||
<path
|
||||
android:pathData="m307.94,275.03 l15.78,-27.34c2.19,-3.79 -3.5,-7.08 -5.69,-3.29l-15.98,27.69c-12.22,-5.58 -25.95,-8.69 -40.6,-8.69 -14.65,0 -28.38,3.11 -40.6,8.69l-15.98,-27.69c-2.19,-3.8 -7.88,-0.51 -5.69,3.28l15.79,27.34c-27.11,14.74 -45.65,42.19 -48.36,74.61h189.69c-2.71,-32.42 -21.25,-59.86 -48.36,-74.61m-46.46,-252.33c-61.81,0 -260.7,360.64 -230.39,421.55 30.31,60.91 430.79,60.21 460.79,0 30,-60.21 -168.58,-421.55 -230.39,-421.55zM412.5,391.47c-19.67,39.44 -282.07,39.94 -301.94,0 -19.87,-39.94 110.48,-276.25 150.92,-276.25s170.69,236.72 151.02,276.25z"
|
||||
android:strokeWidth=".14885">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startY="236.15994"
|
||||
android:startX="169.06345"
|
||||
android:endY="374.69077"
|
||||
android:endX="409.00986"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FF3DDC84"/>
|
||||
<item android:offset="1" android:color="#FF00A4DC"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="m569.69,357.11q-3.73,0 -6.13,-2.4 -2.13,-2.4 -2.13,-5.6v-170.67q0,-3.2 2.4,-5.6t5.6,-2.4h97.07q3.47,0 5.6,2.4 2.4,2.13 2.4,5.6 0,3.2 -2.4,5.6 -2.13,2.13 -5.6,2.13h-89.87l1.07,-1.6v70.93l-1.33,-2.4h78.13q3.47,0 5.6,2.4 2.4,2.13 2.4,5.33 0,3.47 -2.4,5.6 -2.13,2.13 -5.6,2.13h-78.67l1.87,-2.13v82.67q0,3.2 -2.4,5.6 -2.13,2.4 -5.6,2.4z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m723.83,349.11q0,3.2 -2.4,5.6t-5.6,2.4q-3.47,0 -5.87,-2.4 -2.13,-2.4 -2.13,-5.6v-122.67q0,-3.2 2.13,-5.6 2.4,-2.4 5.87,-2.4t5.6,2.4q2.4,2.4 2.4,5.6zM715.83,200.58q-5.6,0 -8.53,-2.4 -2.67,-2.67 -2.67,-7.47v-2.67q0,-4.8 2.93,-7.2 3.2,-2.67 8.53,-2.67 5.07,0 7.73,2.67 2.93,2.4 2.93,7.2v2.67q0,4.8 -2.93,7.47 -2.67,2.4 -8,2.4z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m829.69,217.65q17.6,0 28,7.2 10.67,6.93 15.2,19.2 4.8,12 4.8,26.67v78.4q0,3.2 -2.4,5.6t-5.6,2.4q-3.73,0 -5.87,-2.4t-2.13,-5.6v-77.6q0,-10.67 -3.47,-19.47t-11.47,-14.13q-7.73,-5.33 -20.53,-5.33 -11.47,0 -21.87,5.33 -10.13,5.33 -16.53,14.13 -6.4,8.8 -6.4,19.47v77.6q0,3.2 -2.4,5.6 -2.4,2.4 -5.6,2.4 -3.73,0 -5.87,-2.4 -2.13,-2.4 -2.13,-5.6v-119.47q0,-3.2 2.13,-5.6 2.4,-2.4 5.87,-2.4t5.6,2.4q2.4,2.4 2.4,5.6v22.4l-6.13,9.6q0.53,-8.53 5.33,-16.27 5.07,-8 12.8,-14.13 7.73,-6.4 17.07,-9.87 9.6,-3.73 19.2,-3.73z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m1030.2,159.78q3.47,0 5.6,2.4 2.4,2.13 2.4,5.6v181.33q0,3.2 -2.4,5.6t-5.6,2.4q-3.73,0 -5.87,-2.4 -2.13,-2.4 -2.13,-5.6v-31.73l4.53,-3.73q0,7.47 -4,15.73 -4,8 -11.47,14.93 -7.2,6.93 -17.07,11.2 -9.6,4.27 -21.07,4.27 -17.6,0 -32,-9.33 -14.13,-9.33 -22.4,-25.33 -8.27,-16 -8.27,-36.53 0,-20.27 8.27,-36.27 8.27,-16.27 22.4,-25.33 14.13,-9.33 31.73,-9.33 11.2,0 21.07,4 9.87,4 17.33,10.93 7.73,6.93 12,16 4.53,8.8 4.53,18.4l-5.6,-4v-95.2q0,-3.2 2.13,-5.6 2.13,-2.4 5.87,-2.4zM974.73,344.85q14.13,0 25.07,-7.2 10.93,-7.47 17.07,-20 6.4,-12.8 6.4,-29.07 0,-16 -6.4,-28.53 -6.13,-12.8 -17.07,-20 -10.93,-7.47 -25.07,-7.47 -13.87,0 -25.07,7.47 -10.93,7.2 -17.33,20 -6.13,12.53 -6.13,28.53t6.13,28.8q6.4,12.8 17.33,20.27 11.2,7.2 25.07,7.2z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m1088.1,357.11q-3.73,0 -5.87,-2.4 -2.13,-2.4 -2.13,-5.6v-119.47q0,-3.2 2.13,-5.6 2.4,-2.4 5.87,-2.4t5.6,2.4q2.4,2.4 2.4,5.6v40l-4,0.8q0.8,-9.33 4.53,-18.4 4,-9.33 10.67,-17.07t15.73,-12.53q9.33,-4.8 20.8,-4.8 4.8,0 9.33,2.13 4.53,1.87 4.53,6.4 0,4 -2.13,6.13 -2.13,2.13 -5.07,2.13 -2.4,0 -5.33,-1.33 -2.67,-1.33 -7.2,-1.33 -7.47,0 -14.93,4.53 -7.47,4.27 -13.6,11.73 -6.13,7.47 -9.87,16.8 -3.47,9.07 -3.47,18.4v65.87q0,3.2 -2.4,5.6t-5.6,2.4z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m1302.8,288.85q0,20.27 -9.07,36.53 -8.8,16 -24,25.33 -15.2,9.07 -34.4,9.07 -18.93,0 -34.4,-9.07 -15.2,-9.33 -24.27,-25.33 -8.8,-16.27 -8.8,-36.53 0,-20.53 8.8,-36.53 9.07,-16 24.27,-25.33 15.47,-9.33 34.4,-9.33 19.2,0 34.4,9.33 15.2,9.33 24,25.33 9.07,16 9.07,36.53zM1286.8,288.85q0,-16.27 -6.67,-28.8 -6.67,-12.8 -18.4,-20 -11.47,-7.47 -26.4,-7.47 -14.67,0 -26.4,7.47 -11.47,7.2 -18.4,20 -6.67,12.53 -6.67,28.8 0,16.27 6.67,28.8 6.93,12.53 18.4,20 11.73,7.2 26.4,7.2 14.93,0 26.4,-7.2 11.73,-7.47 18.4,-20 6.67,-12.53 6.67,-28.8z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m1351,349.11q0,3.2 -2.4,5.6t-5.6,2.4q-3.47,0 -5.87,-2.4 -2.13,-2.4 -2.13,-5.6v-122.67q0,-3.2 2.13,-5.6 2.4,-2.4 5.87,-2.4 3.47,0 5.6,2.4 2.4,2.4 2.4,5.6zM1343,200.58q-5.6,0 -8.53,-2.4 -2.67,-2.67 -2.67,-7.47v-2.67q0,-4.8 2.93,-7.2 3.2,-2.67 8.53,-2.67 5.07,0 7.73,2.67 2.93,2.4 2.93,7.2v2.67q0,4.8 -2.93,7.47 -2.67,2.4 -8,2.4z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m1503.3,159.78q3.47,0 5.6,2.4 2.4,2.13 2.4,5.6v181.33q0,3.2 -2.4,5.6t-5.6,2.4q-3.73,0 -5.87,-2.4 -2.13,-2.4 -2.13,-5.6v-31.73l4.53,-3.73q0,7.47 -4,15.73 -4,8 -11.47,14.93 -7.2,6.93 -17.07,11.2 -9.6,4.27 -21.07,4.27 -17.6,0 -32,-9.33 -14.13,-9.33 -22.4,-25.33 -8.27,-16 -8.27,-36.53 0,-20.27 8.27,-36.27 8.27,-16.27 22.4,-25.33 14.13,-9.33 31.73,-9.33 11.2,0 21.07,4 9.87,4 17.33,10.93 7.73,6.93 12,16 4.53,8.8 4.53,18.4l-5.6,-4v-95.2q0,-3.2 2.13,-5.6 2.13,-2.4 5.87,-2.4zM1447.83,344.85q14.13,0 25.07,-7.2 10.93,-7.47 17.07,-20 6.4,-12.8 6.4,-29.07 0,-16 -6.4,-28.53 -6.13,-12.8 -17.07,-20 -10.93,-7.47 -25.07,-7.47 -13.87,0 -25.07,7.47 -10.93,7.2 -17.33,20 -6.13,12.53 -6.13,28.53t6.13,28.8q6.4,12.8 17.33,20.27 11.2,7.2 25.07,7.2z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m1093.4,453.74q-1.19,0 -1.96,-0.77 -0.68,-0.77 -0.68,-1.79v-54.61q0,-1.02 0.77,-1.79t1.79,-0.77h31.06q1.11,0 1.79,0.77 0.77,0.68 0.77,1.79 0,1.02 -0.77,1.79 -0.68,0.68 -1.79,0.68h-28.76l0.34,-0.51v22.7l-0.43,-0.77h25q1.11,0 1.79,0.77 0.77,0.68 0.77,1.71 0,1.11 -0.77,1.79 -0.68,0.68 -1.79,0.68h-25.17l0.6,-0.68v26.45q0,1.02 -0.77,1.79 -0.68,0.77 -1.79,0.77z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m1176.8,431.9q0,6.49 -2.9,11.69 -2.82,5.12 -7.68,8.11 -4.86,2.9 -11.01,2.9 -6.06,0 -11.01,-2.9 -4.86,-2.99 -7.77,-8.11 -2.82,-5.21 -2.82,-11.69 0,-6.57 2.82,-11.69 2.9,-5.12 7.77,-8.11 4.95,-2.99 11.01,-2.99 6.14,0 11.01,2.99 4.86,2.99 7.68,8.11 2.9,5.12 2.9,11.69zM1171.68,431.9q0,-5.21 -2.13,-9.22 -2.13,-4.1 -5.89,-6.4 -3.67,-2.39 -8.45,-2.39 -4.69,0 -8.45,2.39 -3.67,2.3 -5.89,6.4 -2.13,4.01 -2.13,9.22 0,5.21 2.13,9.22 2.22,4.01 5.89,6.4 3.75,2.3 8.45,2.3 4.78,0 8.45,-2.3 3.75,-2.39 5.89,-6.4 2.13,-4.01 2.13,-9.22z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m1189.9,453.74q-1.19,0 -1.88,-0.77 -0.68,-0.77 -0.68,-1.79v-38.23q0,-1.02 0.68,-1.79 0.77,-0.77 1.88,-0.77 1.11,0 1.79,0.77 0.77,0.77 0.77,1.79v12.8l-1.28,0.26q0.26,-2.99 1.45,-5.89 1.28,-2.99 3.41,-5.46 2.13,-2.47 5.03,-4.01 2.99,-1.54 6.66,-1.54 1.54,0 2.99,0.68 1.45,0.6 1.45,2.05 0,1.28 -0.68,1.96 -0.68,0.68 -1.62,0.68 -0.77,0 -1.71,-0.43 -0.85,-0.43 -2.3,-0.43 -2.39,0 -4.78,1.45 -2.39,1.37 -4.35,3.75 -1.96,2.39 -3.16,5.38 -1.11,2.9 -1.11,5.89v21.08q0,1.02 -0.77,1.79t-1.79,0.77z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m1258.9,454.59q-5.38,0 -9.64,-2.99 -4.27,-2.99 -6.49,-7.85 -0.43,-0.77 -0.43,-1.37 0,-1.11 0.85,-1.71 0.85,-0.68 1.71,-0.68t1.37,0.43q0.6,0.43 1.02,1.02 1.62,3.58 4.69,5.8 3.07,2.22 6.91,2.22 3.93,0 6.91,-1.62 2.99,-1.71 4.61,-4.69 1.71,-2.99 1.71,-6.83v-39.76q0,-1.02 0.77,-1.79 0.85,-0.77 1.96,-0.77 1.19,0 1.88,0.77 0.77,0.77 0.77,1.79v39.76q0,5.29 -2.39,9.47 -2.39,4.1 -6.57,6.49 -4.18,2.3 -9.64,2.3z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m1311.6,454.59q-6.57,0 -11.6,-2.82t-7.85,-7.85q-2.82,-5.03 -2.82,-11.78 0,-7.25 2.82,-12.37 2.9,-5.12 7.42,-7.85 4.61,-2.82 9.73,-2.82 3.75,0 7.25,1.37 3.58,1.28 6.31,3.93 2.73,2.56 4.44,6.31 1.71,3.75 1.79,8.7 0,1.02 -0.77,1.79 -0.77,0.68 -1.79,0.68h-34.22l-1.02,-4.61h33.62l-1.11,1.02v-1.71q-0.43,-4.01 -2.65,-6.83 -2.22,-2.82 -5.38,-4.27 -3.07,-1.45 -6.49,-1.45 -2.56,0 -5.29,1.02 -2.65,1.02 -4.86,3.24 -2.13,2.13 -3.5,5.55 -1.37,3.33 -1.37,7.94 0,5.03 2.05,9.13 2.05,4.1 5.89,6.49 3.84,2.39 9.3,2.39 2.9,0 5.29,-0.85 2.39,-0.85 4.18,-2.22 1.88,-1.45 3.07,-2.99 0.94,-0.77 1.79,-0.77 0.94,0 1.54,0.68 0.68,0.68 0.68,1.54 0,1.02 -0.85,1.79 -2.56,3.07 -6.66,5.38 -4.1,2.22 -8.96,2.22z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m1345.4,451.18q0,1.02 -0.77,1.79t-1.79,0.77q-1.11,0 -1.88,-0.77 -0.68,-0.77 -0.68,-1.79v-58.03q0,-1.02 0.77,-1.79t1.79,-0.77q1.11,0 1.79,0.77 0.77,0.77 0.77,1.79z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m1365,451.18q0,1.02 -0.77,1.79t-1.79,0.77q-1.11,0 -1.88,-0.77 -0.68,-0.77 -0.68,-1.79v-58.03q0,-1.02 0.77,-1.79t1.79,-0.77q1.11,0 1.79,0.77 0.77,0.77 0.77,1.79z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m1410.2,409.37q1.11,0 1.79,0.77 0.77,0.77 0.77,1.79v37.63q0,6.91 -2.73,11.6 -2.73,4.78 -7.34,7.17 -4.61,2.47 -10.5,2.47 -3.67,0 -6.83,-0.85 -3.07,-0.77 -5.03,-2.05 -1.02,-0.6 -1.54,-1.45t-0.09,-1.79q0.43,-1.19 1.28,-1.62 0.94,-0.34 1.88,0.09 1.45,0.77 4.18,1.88 2.73,1.11 6.23,1.11 4.69,0 8.11,-1.96 3.5,-1.96 5.38,-5.72 1.88,-3.67 1.88,-8.79v-6.14l0.6,2.05q-1.28,2.65 -3.67,4.69 -2.3,2.05 -5.38,3.24 -2.99,1.11 -6.4,1.11 -5.12,0 -8.53,-2.05 -3.33,-2.13 -4.95,-5.8t-1.62,-8.62v-26.2q0,-1.02 0.68,-1.79 0.68,-0.77 1.88,-0.77 1.11,0 1.79,0.77 0.77,0.77 0.77,1.79v25.43q0,5.97 2.56,9.22 2.65,3.24 8.53,3.24 3.67,0 6.74,-1.71 3.07,-1.79 5.03,-4.61 1.96,-2.9 1.96,-6.14v-25.43q0,-1.02 0.68,-1.79 0.77,-0.77 1.88,-0.77z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m1441.9,390.94q1.28,0 2.73,0.26 1.54,0.26 2.65,0.94 1.11,0.6 1.11,1.88 0,0.94 -0.68,1.71 -0.68,0.68 -1.54,0.68 -0.85,0 -2.13,-0.43 -1.28,-0.51 -2.73,-0.51 -1.79,0 -3.07,0.85 -1.28,0.77 -1.96,2.3 -0.68,1.45 -0.68,3.58v48.98q0,1.02 -0.77,1.79 -0.68,0.77 -1.79,0.77t-1.88,-0.77q-0.68,-0.77 -0.68,-1.79v-48.98q0,-5.46 3.16,-8.36 3.24,-2.9 8.28,-2.9zM1445.14,410.99q1.02,0 1.71,0.68 0.68,0.68 0.68,1.71t-0.68,1.71q-0.68,0.68 -1.71,0.68h-20.91q-0.94,0 -1.71,-0.68 -0.68,-0.77 -0.68,-1.71 0,-1.11 0.68,-1.71 0.77,-0.68 1.71,-0.68zM1461.19,451.18q0,1.02 -0.77,1.79t-1.79,0.77q-1.11,0 -1.88,-0.77 -0.68,-0.77 -0.68,-1.79v-39.25q0,-1.02 0.68,-1.79 0.77,-0.77 1.88,-0.77t1.79,0.77q0.77,0.77 0.77,1.79zM1458.63,403.65q-1.79,0 -2.73,-0.77 -0.85,-0.85 -0.85,-2.39v-0.85q0,-1.54 0.94,-2.3 1.02,-0.85 2.73,-0.85 1.62,0 2.47,0.85 0.94,0.77 0.94,2.3v0.85q0,1.54 -0.94,2.39 -0.85,0.77 -2.56,0.77z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m1495.1,409.11q5.63,0 8.96,2.3 3.41,2.22 4.86,6.14 1.54,3.84 1.54,8.53v25.09q0,1.02 -0.77,1.79 -0.77,0.77 -1.79,0.77 -1.19,0 -1.88,-0.77 -0.68,-0.77 -0.68,-1.79v-24.83q0,-3.41 -1.11,-6.23 -1.11,-2.82 -3.67,-4.52 -2.47,-1.71 -6.57,-1.71 -3.67,0 -7,1.71 -3.24,1.71 -5.29,4.52t-2.05,6.23v24.83q0,1.02 -0.77,1.79t-1.79,0.77q-1.19,0 -1.88,-0.77t-0.68,-1.79v-38.23q0,-1.02 0.68,-1.79 0.77,-0.77 1.88,-0.77 1.11,0 1.79,0.77 0.77,0.77 0.77,1.79v7.17l-1.96,3.07q0.17,-2.73 1.71,-5.21 1.62,-2.56 4.1,-4.52 2.47,-2.05 5.46,-3.16 3.07,-1.19 6.14,-1.19z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
5
app/tv/src/main/res/values/themes.xml
Normal file
5
app/tv/src/main/res/values/themes.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Findroid" parent="Theme.Material3.Dark.NoActionBar" />
|
||||
</resources>
|
|
@ -11,5 +11,6 @@ object Versions {
|
|||
|
||||
val java = JavaVersion.VERSION_17
|
||||
|
||||
const val composeCompiler = "1.5.7"
|
||||
const val ktlint = "0.50.0"
|
||||
}
|
|
@ -36,6 +36,14 @@ android {
|
|||
sourceCompatibility = Versions.java
|
||||
targetCompatibility = Versions.java
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = Versions.composeCompiler
|
||||
}
|
||||
}
|
||||
|
||||
ktlint {
|
||||
|
@ -50,6 +58,7 @@ dependencies {
|
|||
implementation(project(":player:core"))
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.hilt.work)
|
||||
ksp(libs.androidx.hilt.compiler)
|
||||
|
|
|
@ -19,7 +19,7 @@ object ApiModule {
|
|||
fun provideJellyfinApi(
|
||||
@ApplicationContext application: Context,
|
||||
appPreferences: AppPreferences,
|
||||
serverDatabase: ServerDatabaseDao,
|
||||
database: ServerDatabaseDao,
|
||||
): JellyfinApi {
|
||||
val jellyfinApi = JellyfinApi.getInstance(
|
||||
context = application,
|
||||
|
@ -30,10 +30,10 @@ object ApiModule {
|
|||
|
||||
val serverId = appPreferences.currentServer ?: return jellyfinApi
|
||||
|
||||
val serverWithAddressesAndUsers = serverDatabase.getServerWithAddressesAndUsers(serverId) ?: return jellyfinApi
|
||||
val server = serverWithAddressesAndUsers.server
|
||||
val serverAddress = serverWithAddressesAndUsers.addresses.firstOrNull { it.id == server.currentServerAddressId } ?: return jellyfinApi
|
||||
val user = serverWithAddressesAndUsers.users.firstOrNull { it.id == server.currentUserId }
|
||||
val serverWithAddressAndUser = database.getServerWithAddressAndUser(serverId) ?: return jellyfinApi
|
||||
val serverAddress = serverWithAddressAndUser.address ?: return jellyfinApi
|
||||
val user = serverWithAddressAndUser.user
|
||||
|
||||
jellyfinApi.apply {
|
||||
api.baseUrl = serverAddress.address
|
||||
api.accessToken = user?.accessToken
|
||||
|
|
|
@ -7,10 +7,6 @@ sealed class HomeItem {
|
|||
override val id: UUID = UUID.fromString("dbfef8a9-7ff0-4c36-9e36-81dfd65fdd46")
|
||||
}
|
||||
|
||||
data class Libraries(val section: HomeSection) : HomeItem() {
|
||||
override val id = section.id
|
||||
}
|
||||
|
||||
data class Section(val homeSection: HomeSection) : HomeItem() {
|
||||
override val id = homeSection.id
|
||||
}
|
||||
|
|
|
@ -2,12 +2,14 @@ package dev.jdtech.jellyfin.models
|
|||
|
||||
import android.content.res.Resources
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
sealed class UiText {
|
||||
data class DynamicString(val value: String) : UiText()
|
||||
class StringResource(
|
||||
@StringRes val resId: Int,
|
||||
vararg val args: Any?,
|
||||
vararg val args: Any,
|
||||
) : UiText()
|
||||
|
||||
fun asString(resources: Resources): String {
|
||||
|
@ -16,4 +18,12 @@ sealed class UiText {
|
|||
is StringResource -> resources.getString(resId, *args)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun asString(): String {
|
||||
return when (this) {
|
||||
is DynamicString -> value
|
||||
is StringResource -> stringResource(resId, *args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
106
core/src/main/java/dev/jdtech/jellyfin/utils/ComposeUtils.kt
Normal file
106
core/src/main/java/dev/jdtech/jellyfin/utils/ComposeUtils.kt
Normal file
|
@ -0,0 +1,106 @@
|
|||
package dev.jdtech.jellyfin.utils
|
||||
|
||||
import android.view.KeyEvent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Composable
|
||||
fun <T> ObserveAsEvents(flow: Flow<T>, onEvent: (T) -> Unit) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
LaunchedEffect(flow, lifecycleOwner.lifecycle) {
|
||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
flow.collect(onEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val DPadEventsKeyCodes = listOf(
|
||||
KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT,
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT,
|
||||
KeyEvent.KEYCODE_DPAD_UP,
|
||||
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP,
|
||||
KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN,
|
||||
KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
KeyEvent.KEYCODE_ENTER,
|
||||
KeyEvent.KEYCODE_NUMPAD_ENTER,
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles horizontal (Left & Right) D-Pad Keys and consumes the event(s) so that the focus doesn't
|
||||
* accidentally move to another element.
|
||||
* */
|
||||
fun Modifier.handleDPadKeyEvents(
|
||||
onLeft: (() -> Unit)? = null,
|
||||
onRight: (() -> Unit)? = null,
|
||||
onEnter: (() -> Unit)? = null,
|
||||
) = onPreviewKeyEvent {
|
||||
fun onActionUp(block: () -> Unit) {
|
||||
if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) block()
|
||||
}
|
||||
|
||||
if (DPadEventsKeyCodes.contains(it.nativeKeyEvent.keyCode)) {
|
||||
when (it.nativeKeyEvent.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> {
|
||||
onLeft?.apply {
|
||||
onActionUp(::invoke)
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> {
|
||||
onRight?.apply {
|
||||
onActionUp(::invoke)
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
|
||||
onEnter?.apply {
|
||||
onActionUp(::invoke)
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles all D-Pad Keys
|
||||
* */
|
||||
fun Modifier.handleDPadKeyEvents(
|
||||
onLeft: (() -> Unit)? = null,
|
||||
onRight: (() -> Unit)? = null,
|
||||
onUp: (() -> Unit)? = null,
|
||||
onDown: (() -> Unit)? = null,
|
||||
onEnter: (() -> Unit)? = null,
|
||||
) = onKeyEvent {
|
||||
if (DPadEventsKeyCodes.contains(it.nativeKeyEvent.keyCode) && it.nativeKeyEvent.action == KeyEvent.ACTION_UP) {
|
||||
when (it.nativeKeyEvent.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> {
|
||||
onLeft?.invoke().also { return@onKeyEvent true }
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> {
|
||||
onRight?.invoke().also { return@onKeyEvent true }
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP -> {
|
||||
onUp?.invoke().also { return@onKeyEvent true }
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN -> {
|
||||
onDown?.invoke().also { return@onKeyEvent true }
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
|
||||
onEnter?.invoke().also { return@onKeyEvent true }
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
|
@ -223,7 +223,7 @@ constructor(
|
|||
UiText.StringResource(R.string.add_server_error_outdated, it.version)
|
||||
}
|
||||
is RecommendedServerIssue.InvalidProductName -> {
|
||||
UiText.StringResource(R.string.add_server_error_not_jellyfin, it.productName)
|
||||
UiText.StringResource(R.string.add_server_error_not_jellyfin, it.productName ?: "")
|
||||
}
|
||||
is RecommendedServerIssue.UnsupportedServerVersion -> {
|
||||
UiText.StringResource(R.string.add_server_error_version, it.version)
|
||||
|
|
|
@ -52,24 +52,19 @@ constructor(
|
|||
}
|
||||
|
||||
lateinit var item: FindroidEpisode
|
||||
private var played: Boolean = false
|
||||
private var favorite: Boolean = false
|
||||
|
||||
private var currentUiState: UiState = UiState.Loading
|
||||
|
||||
fun loadEpisode(episodeId: UUID) {
|
||||
viewModelScope.launch {
|
||||
_uiState.emit(UiState.Loading)
|
||||
try {
|
||||
item = repository.getEpisode(episodeId)
|
||||
played = item.played
|
||||
favorite = item.favorite
|
||||
if (item.isDownloading()) {
|
||||
pollDownloadProgress()
|
||||
}
|
||||
_uiState.emit(
|
||||
UiState.Normal(
|
||||
item,
|
||||
),
|
||||
)
|
||||
currentUiState = UiState.Normal(item)
|
||||
_uiState.emit(currentUiState)
|
||||
} catch (_: NullPointerException) {
|
||||
// Navigate back because item does not exist (probably because it's been deleted)
|
||||
eventsChannel.send(EpisodeBottomSheetEvent.NavigateBack)
|
||||
|
@ -79,48 +74,76 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun togglePlayed(): Boolean {
|
||||
when (played) {
|
||||
false -> {
|
||||
played = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.markAsPlayed(item.id)
|
||||
} catch (_: Exception) {}
|
||||
fun togglePlayed() {
|
||||
suspend fun updateUiPlayedState(played: Boolean) {
|
||||
item = item.copy(played = played)
|
||||
when (currentUiState) {
|
||||
is UiState.Normal -> {
|
||||
currentUiState = (currentUiState as UiState.Normal).copy(episode = item)
|
||||
_uiState.emit(currentUiState)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
true -> {
|
||||
played = false
|
||||
viewModelScope.launch {
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val originalPlayedState = item.played
|
||||
updateUiPlayedState(!item.played)
|
||||
|
||||
when (item.played) {
|
||||
false -> {
|
||||
try {
|
||||
repository.markAsUnplayed(item.id)
|
||||
} catch (_: Exception) {}
|
||||
} catch (_: Exception) {
|
||||
updateUiPlayedState(originalPlayedState)
|
||||
}
|
||||
}
|
||||
true -> {
|
||||
try {
|
||||
repository.markAsPlayed(item.id)
|
||||
} catch (_: Exception) {
|
||||
updateUiPlayedState(originalPlayedState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return played
|
||||
}
|
||||
|
||||
fun toggleFavorite(): Boolean {
|
||||
when (favorite) {
|
||||
false -> {
|
||||
favorite = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.markAsFavorite(item.id)
|
||||
} catch (_: Exception) {}
|
||||
fun toggleFavorite() {
|
||||
suspend fun updateUiFavoriteState(isFavorite: Boolean) {
|
||||
item = item.copy(favorite = isFavorite)
|
||||
when (currentUiState) {
|
||||
is UiState.Normal -> {
|
||||
currentUiState = (currentUiState as UiState.Normal).copy(episode = item)
|
||||
_uiState.emit(currentUiState)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
true -> {
|
||||
favorite = false
|
||||
viewModelScope.launch {
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val originalFavoriteState = item.favorite
|
||||
updateUiFavoriteState(!item.favorite)
|
||||
|
||||
when (item.favorite) {
|
||||
false -> {
|
||||
try {
|
||||
repository.unmarkAsFavorite(item.id)
|
||||
} catch (_: Exception) {}
|
||||
} catch (_: Exception) {
|
||||
updateUiFavoriteState(originalFavoriteState)
|
||||
}
|
||||
}
|
||||
true -> {
|
||||
try {
|
||||
repository.markAsFavorite(item.id)
|
||||
} catch (_: Exception) {
|
||||
updateUiFavoriteState(originalFavoriteState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return favorite
|
||||
}
|
||||
|
||||
fun download(sourceIndex: Int = 0, storageIndex: Int = 0) {
|
||||
|
|
|
@ -31,11 +31,9 @@ class HomeViewModel @Inject internal constructor(
|
|||
data class Error(val error: Exception) : UiState()
|
||||
}
|
||||
|
||||
private val uuidLibraries = UUID(4104409383667715086, -6276889634004763134) // 38f5ca96-9e4b-4c0e-a8e4-02225ed07e02
|
||||
private val uuidContinueWatching = UUID(4937169328197226115, -4704919157662094443) // 44845958-8326-4e83-beb4-c4f42e9eeb95
|
||||
private val uuidNextUp = UUID(1783371395749072194, -6164625418200444295) // 18bfced5-f237-4d42-aa72-d9d7fed19279
|
||||
|
||||
private val uiTextLibraries = UiText.StringResource(R.string.libraries)
|
||||
private val uiTextContinueWatching = UiText.StringResource(R.string.continue_watching)
|
||||
private val uiTextNextUp = UiText.StringResource(R.string.next_up)
|
||||
|
||||
|
@ -48,7 +46,7 @@ class HomeViewModel @Inject internal constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun loadData(includeLibraries: Boolean = false) {
|
||||
fun loadData() {
|
||||
viewModelScope.launch {
|
||||
_uiState.emit(UiState.Loading)
|
||||
try {
|
||||
|
@ -56,10 +54,6 @@ class HomeViewModel @Inject internal constructor(
|
|||
|
||||
if (appPreferences.offlineMode) items.add(HomeItem.OfflineCard)
|
||||
|
||||
if (includeLibraries) {
|
||||
items.add(loadLibraries())
|
||||
}
|
||||
|
||||
val updated = items + loadDynamicItems() + loadViews()
|
||||
|
||||
_uiState.emit(UiState.Normal(updated))
|
||||
|
@ -69,19 +63,6 @@ class HomeViewModel @Inject internal constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun loadLibraries(): HomeItem {
|
||||
val items = repository.getLibraries()
|
||||
val collections =
|
||||
items.filter { collection -> collection.type in CollectionType.supported }
|
||||
return HomeItem.Libraries(
|
||||
HomeSection(
|
||||
uuidLibraries,
|
||||
uiTextLibraries,
|
||||
collections,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun loadDynamicItems(): List<HomeItem.Section> {
|
||||
val resumeItems = repository.getResumeItems()
|
||||
val nextUpItems = repository.getNextUp()
|
||||
|
|
|
@ -1,7 +1,48 @@
|
|||
package dev.jdtech.jellyfin.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dev.jdtech.jellyfin.AppPreferences
|
||||
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||
import dev.jdtech.jellyfin.models.Server
|
||||
import dev.jdtech.jellyfin.models.User
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class MainViewModel : ViewModel() {
|
||||
@HiltViewModel
|
||||
class MainViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val appPreferences: AppPreferences,
|
||||
private val database: ServerDatabaseDao,
|
||||
) : ViewModel() {
|
||||
var startDestinationChanged = false
|
||||
|
||||
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
sealed class UiState {
|
||||
data class Normal(val server: Server?, val user: User?) : UiState()
|
||||
data object Loading : UiState()
|
||||
}
|
||||
|
||||
init {
|
||||
loadServerAndUser()
|
||||
}
|
||||
|
||||
private fun loadServerAndUser() {
|
||||
viewModelScope.launch {
|
||||
val serverId = appPreferences.currentServer
|
||||
serverId?.let { id ->
|
||||
database.getServerWithAddressAndUser(id)?.let { data ->
|
||||
_uiState.emit(
|
||||
UiState.Normal(data.server, data.user),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -75,41 +75,38 @@ constructor(
|
|||
}
|
||||
|
||||
lateinit var item: FindroidMovie
|
||||
private var played: Boolean = false
|
||||
private var favorite: Boolean = false
|
||||
private var writers: List<BaseItemPerson> = emptyList()
|
||||
private var writersString: String = ""
|
||||
private var runTime: String = ""
|
||||
|
||||
private var currentUiState: UiState = UiState.Loading
|
||||
|
||||
fun loadData(itemId: UUID) {
|
||||
viewModelScope.launch {
|
||||
_uiState.emit(UiState.Loading)
|
||||
try {
|
||||
item = repository.getMovie(itemId)
|
||||
played = item.played
|
||||
favorite = item.favorite
|
||||
writers = getWriters(item)
|
||||
writersString = writers.joinToString(separator = ", ") { it.name.toString() }
|
||||
runTime = "${item.runtimeTicks.div(600000000)} min"
|
||||
if (item.isDownloading()) {
|
||||
pollDownloadProgress()
|
||||
}
|
||||
_uiState.emit(
|
||||
UiState.Normal(
|
||||
item,
|
||||
getActors(item),
|
||||
getDirector(item),
|
||||
writers,
|
||||
parseVideoMetadata(item),
|
||||
writersString,
|
||||
item.genres.joinToString(separator = ", "),
|
||||
getMediaString(item, MediaStreamType.VIDEO),
|
||||
getMediaString(item, MediaStreamType.AUDIO),
|
||||
getMediaString(item, MediaStreamType.SUBTITLE),
|
||||
runTime,
|
||||
getDateString(item),
|
||||
),
|
||||
currentUiState = UiState.Normal(
|
||||
item,
|
||||
getActors(item),
|
||||
getDirector(item),
|
||||
writers,
|
||||
parseVideoMetadata(item),
|
||||
writersString,
|
||||
item.genres.joinToString(separator = ", "),
|
||||
getMediaString(item, MediaStreamType.VIDEO),
|
||||
getMediaString(item, MediaStreamType.AUDIO),
|
||||
getMediaString(item, MediaStreamType.SUBTITLE),
|
||||
runTime,
|
||||
getDateString(item),
|
||||
)
|
||||
_uiState.emit(currentUiState)
|
||||
} catch (_: NullPointerException) {
|
||||
// Navigate back because item does not exist (probably because it's been deleted)
|
||||
eventsChannel.send(MovieEvent.NavigateBack)
|
||||
|
@ -259,48 +256,76 @@ constructor(
|
|||
)
|
||||
}
|
||||
|
||||
fun togglePlayed(): Boolean {
|
||||
when (played) {
|
||||
false -> {
|
||||
played = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.markAsPlayed(item.id)
|
||||
} catch (_: Exception) {}
|
||||
fun togglePlayed() {
|
||||
suspend fun updateUiPlayedState(played: Boolean) {
|
||||
item = item.copy(played = played)
|
||||
when (currentUiState) {
|
||||
is UiState.Normal -> {
|
||||
currentUiState = (currentUiState as UiState.Normal).copy(item = item)
|
||||
_uiState.emit(currentUiState)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
true -> {
|
||||
played = false
|
||||
viewModelScope.launch {
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val originalPlayedState = item.played
|
||||
updateUiPlayedState(!item.played)
|
||||
|
||||
when (item.played) {
|
||||
false -> {
|
||||
try {
|
||||
repository.markAsUnplayed(item.id)
|
||||
} catch (_: Exception) {}
|
||||
} catch (_: Exception) {
|
||||
updateUiPlayedState(originalPlayedState)
|
||||
}
|
||||
}
|
||||
true -> {
|
||||
try {
|
||||
repository.markAsPlayed(item.id)
|
||||
} catch (_: Exception) {
|
||||
updateUiPlayedState(originalPlayedState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return played
|
||||
}
|
||||
|
||||
fun toggleFavorite(): Boolean {
|
||||
when (favorite) {
|
||||
false -> {
|
||||
favorite = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.markAsFavorite(item.id)
|
||||
} catch (_: Exception) {}
|
||||
fun toggleFavorite() {
|
||||
suspend fun updateUiFavoriteState(isFavorite: Boolean) {
|
||||
item = item.copy(favorite = isFavorite)
|
||||
when (currentUiState) {
|
||||
is UiState.Normal -> {
|
||||
currentUiState = (currentUiState as UiState.Normal).copy(item = item)
|
||||
_uiState.emit(currentUiState)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
true -> {
|
||||
favorite = false
|
||||
viewModelScope.launch {
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val originalFavoriteState = item.favorite
|
||||
updateUiFavoriteState(!item.favorite)
|
||||
|
||||
when (item.favorite) {
|
||||
false -> {
|
||||
try {
|
||||
repository.unmarkAsFavorite(item.id)
|
||||
} catch (_: Exception) {}
|
||||
} catch (_: Exception) {
|
||||
updateUiFavoriteState(originalFavoriteState)
|
||||
}
|
||||
}
|
||||
true -> {
|
||||
try {
|
||||
repository.markAsFavorite(item.id)
|
||||
} catch (_: Exception) {
|
||||
updateUiFavoriteState(originalFavoriteState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return favorite
|
||||
}
|
||||
|
||||
private fun getDateString(item: FindroidMovie): String {
|
||||
|
|
|
@ -6,7 +6,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||
import dev.jdtech.jellyfin.AppPreferences
|
||||
import dev.jdtech.jellyfin.api.JellyfinApi
|
||||
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||
import dev.jdtech.jellyfin.models.DiscoveredServer
|
||||
import dev.jdtech.jellyfin.models.Server
|
||||
import dev.jdtech.jellyfin.models.UiText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
@ -25,27 +27,59 @@ constructor(
|
|||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||
val uiState = _uiState.asStateFlow()
|
||||
private val _discoveredServersState = MutableStateFlow<DiscoveredServersState>(DiscoveredServersState.Loading)
|
||||
val discoveredServersState = _discoveredServersState.asStateFlow()
|
||||
|
||||
var currentServerId: String? = appPreferences.currentServer
|
||||
private val eventsChannel = Channel<ServerSelectEvent>()
|
||||
val eventsChannelFlow = eventsChannel.receiveAsFlow()
|
||||
|
||||
// TODO states may need to be merged / cleaned up
|
||||
sealed class UiState {
|
||||
data class Normal(val servers: List<Server>) : UiState()
|
||||
data object Loading : UiState()
|
||||
data class Error(val error: Exception) : UiState()
|
||||
data class Error(val message: Collection<UiText>) : UiState()
|
||||
}
|
||||
|
||||
sealed class DiscoveredServersState {
|
||||
data object Loading : DiscoveredServersState()
|
||||
data class Servers(val servers: List<DiscoveredServer>) : DiscoveredServersState()
|
||||
}
|
||||
|
||||
private val discoveredServers = mutableListOf<DiscoveredServer>()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
loadServers()
|
||||
discoverServers()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Jellyfin servers stored in the database and emit them
|
||||
*/
|
||||
private suspend fun loadServers() {
|
||||
val servers = database.getAllServersSync()
|
||||
_uiState.emit(UiState.Normal(servers))
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover Jellyfin servers and emit them
|
||||
*/
|
||||
private suspend fun discoverServers() {
|
||||
val servers = jellyfinApi.jellyfin.discovery.discoverLocalServers()
|
||||
servers.collect { serverDiscoveryInfo ->
|
||||
discoveredServers.add(
|
||||
DiscoveredServer(
|
||||
serverDiscoveryInfo.id,
|
||||
serverDiscoveryInfo.name,
|
||||
serverDiscoveryInfo.address,
|
||||
),
|
||||
)
|
||||
_discoveredServersState.emit(DiscoveredServersState.Servers(ArrayList(discoveredServers)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete server from database
|
||||
*
|
||||
|
@ -60,9 +94,9 @@ constructor(
|
|||
|
||||
fun connectToServer(server: Server) {
|
||||
viewModelScope.launch {
|
||||
val serverWithAddressesAndUsers = database.getServerWithAddressesAndUsers(server.id) ?: return@launch
|
||||
val serverAddress = serverWithAddressesAndUsers.addresses.firstOrNull { it.id == server.currentServerAddressId } ?: return@launch
|
||||
val user = serverWithAddressesAndUsers.users.firstOrNull { it.id == server.currentUserId }
|
||||
val serverWithAddressAndUser = database.getServerWithAddressAndUser(server.id) ?: return@launch
|
||||
val serverAddress = serverWithAddressAndUser.address ?: return@launch
|
||||
val user = serverWithAddressAndUser.user
|
||||
|
||||
// If server has no selected user, navigate to login fragment
|
||||
if (user == null) {
|
||||
|
@ -83,6 +117,7 @@ constructor(
|
|||
}
|
||||
|
||||
appPreferences.currentServer = server.id
|
||||
currentServerId = server.id
|
||||
|
||||
eventsChannel.send(ServerSelectEvent.NavigateToHome)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,223 @@
|
|||
package dev.jdtech.jellyfin.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dev.jdtech.jellyfin.AppPreferences
|
||||
import dev.jdtech.jellyfin.Constants
|
||||
import dev.jdtech.jellyfin.core.R
|
||||
import dev.jdtech.jellyfin.models.Preference
|
||||
import dev.jdtech.jellyfin.models.PreferenceCategory
|
||||
import dev.jdtech.jellyfin.models.PreferenceSelect
|
||||
import dev.jdtech.jellyfin.models.PreferenceSwitch
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val appPreferences: AppPreferences,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
private val eventsChannel = Channel<SettingsEvent>()
|
||||
val eventsChannelFlow = eventsChannel.receiveAsFlow()
|
||||
|
||||
sealed class UiState {
|
||||
data class Normal(
|
||||
val preferences: List<Preference>,
|
||||
) : UiState()
|
||||
|
||||
data object Loading : UiState()
|
||||
}
|
||||
|
||||
private val topLevelPreferences = listOf<Preference>(
|
||||
PreferenceCategory(
|
||||
nameStringResource = R.string.settings_category_language,
|
||||
iconDrawableId = R.drawable.ic_languages,
|
||||
onClick = {
|
||||
viewModelScope.launch {
|
||||
eventsChannel.send(SettingsEvent.NavigateToSettings(intArrayOf(0), it.nameStringResource))
|
||||
}
|
||||
},
|
||||
nestedPreferences = listOf(
|
||||
PreferenceSelect(
|
||||
nameStringResource = R.string.settings_preferred_audio_language,
|
||||
iconDrawableId = R.drawable.ic_speaker,
|
||||
backendName = Constants.PREF_AUDIO_LANGUAGE,
|
||||
backendDefaultValue = null,
|
||||
options = R.array.languages,
|
||||
optionValues = R.array.languages_values,
|
||||
),
|
||||
PreferenceSelect(
|
||||
nameStringResource = R.string.settings_preferred_subtitle_language,
|
||||
iconDrawableId = R.drawable.ic_closed_caption,
|
||||
backendName = Constants.PREF_SUBTITLE_LANGUAGE,
|
||||
backendDefaultValue = null,
|
||||
options = R.array.languages,
|
||||
optionValues = R.array.languages_values,
|
||||
),
|
||||
),
|
||||
),
|
||||
PreferenceCategory(
|
||||
nameStringResource = R.string.settings_category_appearance,
|
||||
iconDrawableId = R.drawable.ic_palette,
|
||||
enabled = false,
|
||||
),
|
||||
PreferenceCategory(
|
||||
nameStringResource = R.string.settings_category_player,
|
||||
iconDrawableId = R.drawable.ic_play,
|
||||
onClick = {
|
||||
viewModelScope.launch {
|
||||
eventsChannel.send(SettingsEvent.NavigateToSettings(intArrayOf(2), it.nameStringResource))
|
||||
}
|
||||
},
|
||||
nestedPreferences = listOf(
|
||||
PreferenceSwitch(
|
||||
nameStringResource = R.string.mpv_player,
|
||||
descriptionStringRes = R.string.mpv_player_summary,
|
||||
backendName = Constants.PREF_PLAYER_MPV,
|
||||
backendDefaultValue = false,
|
||||
),
|
||||
PreferenceSelect(
|
||||
nameStringResource = R.string.pref_player_mpv_hwdec,
|
||||
dependencies = listOf(Constants.PREF_PLAYER_MPV),
|
||||
backendName = Constants.PREF_PLAYER_MPV_HWDEC,
|
||||
backendDefaultValue = "mediacodec",
|
||||
options = R.array.mpv_hwdec,
|
||||
optionValues = R.array.mpv_hwdec,
|
||||
),
|
||||
PreferenceSelect(
|
||||
nameStringResource = R.string.pref_player_mpv_vo,
|
||||
dependencies = listOf(Constants.PREF_PLAYER_MPV),
|
||||
backendName = Constants.PREF_PLAYER_MPV_VO,
|
||||
backendDefaultValue = "gpu",
|
||||
options = R.array.mpv_vos,
|
||||
optionValues = R.array.mpv_vos,
|
||||
),
|
||||
PreferenceSelect(
|
||||
nameStringResource = R.string.pref_player_mpv_ao,
|
||||
dependencies = listOf(Constants.PREF_PLAYER_MPV),
|
||||
backendName = Constants.PREF_PLAYER_MPV_AO,
|
||||
backendDefaultValue = "audiotrack",
|
||||
options = R.array.mpv_aos,
|
||||
optionValues = R.array.mpv_aos,
|
||||
),
|
||||
),
|
||||
),
|
||||
PreferenceCategory(
|
||||
nameStringResource = R.string.users,
|
||||
iconDrawableId = R.drawable.ic_user,
|
||||
onClick = {
|
||||
viewModelScope.launch {
|
||||
eventsChannel.send(SettingsEvent.NavigateToUsers)
|
||||
}
|
||||
},
|
||||
),
|
||||
PreferenceCategory(
|
||||
nameStringResource = R.string.settings_category_servers,
|
||||
iconDrawableId = R.drawable.ic_server,
|
||||
onClick = {
|
||||
viewModelScope.launch {
|
||||
eventsChannel.send(SettingsEvent.NavigateToServers)
|
||||
}
|
||||
},
|
||||
),
|
||||
PreferenceCategory(
|
||||
nameStringResource = R.string.settings_category_device,
|
||||
iconDrawableId = R.drawable.ic_smartphone,
|
||||
enabled = false,
|
||||
),
|
||||
PreferenceCategory(
|
||||
nameStringResource = R.string.settings_category_network,
|
||||
iconDrawableId = R.drawable.ic_network,
|
||||
enabled = false,
|
||||
),
|
||||
PreferenceCategory(
|
||||
nameStringResource = R.string.settings_category_cache,
|
||||
iconDrawableId = R.drawable.ic_hard_drive,
|
||||
onClick = {
|
||||
viewModelScope.launch {
|
||||
eventsChannel.send(SettingsEvent.NavigateToSettings(intArrayOf(7), it.nameStringResource))
|
||||
}
|
||||
},
|
||||
nestedPreferences = listOf(
|
||||
PreferenceSwitch(
|
||||
nameStringResource = R.string.settings_use_cache_title,
|
||||
descriptionStringRes = R.string.settings_use_cache_summary,
|
||||
backendName = Constants.PREF_IMAGE_CACHE,
|
||||
backendDefaultValue = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
PreferenceCategory(
|
||||
nameStringResource = R.string.about,
|
||||
iconDrawableId = R.drawable.ic_info,
|
||||
enabled = false,
|
||||
),
|
||||
)
|
||||
|
||||
fun loadPreferences(indexes: IntArray = intArrayOf()) {
|
||||
viewModelScope.launch {
|
||||
var preferences = topLevelPreferences
|
||||
|
||||
// Show preferences based on index (depth)
|
||||
for (index in indexes) {
|
||||
val preference = preferences[index]
|
||||
if (preference is PreferenceCategory) {
|
||||
preferences = preference.nestedPreferences
|
||||
}
|
||||
}
|
||||
|
||||
// Update all (visible) preferences with there current values
|
||||
preferences = preferences.map { preference ->
|
||||
when (preference) {
|
||||
is PreferenceSwitch -> {
|
||||
preference.copy(
|
||||
enabled = preference.dependencies.all { getBoolean(it, false) },
|
||||
value = getBoolean(preference.backendName, preference.backendDefaultValue),
|
||||
)
|
||||
}
|
||||
is PreferenceSelect -> {
|
||||
preference.copy(
|
||||
enabled = preference.dependencies.all { getBoolean(it, false) },
|
||||
value = getString(preference.backendName, preference.backendDefaultValue),
|
||||
)
|
||||
}
|
||||
else -> preference
|
||||
}
|
||||
}
|
||||
|
||||
_uiState.emit(UiState.Normal(preferences))
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBoolean(key: String, default: Boolean): Boolean {
|
||||
return appPreferences.getBoolean(key, default)
|
||||
}
|
||||
|
||||
fun setBoolean(key: String, value: Boolean) {
|
||||
appPreferences.setBoolean(key, value)
|
||||
}
|
||||
|
||||
private fun getString(key: String, default: String?): String? {
|
||||
return appPreferences.getString(key, default)
|
||||
}
|
||||
|
||||
fun setString(key: String, value: String?) {
|
||||
appPreferences.setString(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface SettingsEvent {
|
||||
data object NavigateToUsers : SettingsEvent
|
||||
data object NavigateToServers : SettingsEvent
|
||||
|
||||
data class NavigateToSettings(val indexes: IntArray, val title: Int) : SettingsEvent
|
||||
}
|
|
@ -49,8 +49,6 @@ constructor(
|
|||
}
|
||||
|
||||
lateinit var item: FindroidShow
|
||||
private var played: Boolean = false
|
||||
private var favorite: Boolean = false
|
||||
private var actors: List<BaseItemPerson> = emptyList()
|
||||
private var director: BaseItemPerson? = null
|
||||
private var writers: List<BaseItemPerson> = emptyList()
|
||||
|
@ -61,13 +59,13 @@ constructor(
|
|||
var nextUp: FindroidEpisode? = null
|
||||
var seasons: List<FindroidSeason> = emptyList()
|
||||
|
||||
private var currentUiState: UiState = UiState.Loading
|
||||
|
||||
fun loadData(itemId: UUID, offline: Boolean) {
|
||||
viewModelScope.launch {
|
||||
_uiState.emit(UiState.Loading)
|
||||
try {
|
||||
item = jellyfinRepository.getShow(itemId)
|
||||
played = item.played
|
||||
favorite = item.favorite
|
||||
actors = getActors(item)
|
||||
director = getDirector(item)
|
||||
writers = getWriters(item)
|
||||
|
@ -77,20 +75,19 @@ constructor(
|
|||
dateString = getDateString(item)
|
||||
nextUp = getNextUp(itemId)
|
||||
seasons = jellyfinRepository.getSeasons(itemId, offline)
|
||||
_uiState.emit(
|
||||
UiState.Normal(
|
||||
item,
|
||||
actors,
|
||||
director,
|
||||
writers,
|
||||
writersString,
|
||||
genresString,
|
||||
runTime,
|
||||
dateString,
|
||||
nextUp,
|
||||
seasons,
|
||||
),
|
||||
currentUiState = UiState.Normal(
|
||||
item,
|
||||
actors,
|
||||
director,
|
||||
writers,
|
||||
writersString,
|
||||
genresString,
|
||||
runTime,
|
||||
dateString,
|
||||
nextUp,
|
||||
seasons,
|
||||
)
|
||||
_uiState.emit(currentUiState)
|
||||
} catch (_: NullPointerException) {
|
||||
// Navigate back because item does not exist (probably because it's been deleted)
|
||||
eventsChannel.send(ShowEvent.NavigateBack)
|
||||
|
@ -129,48 +126,76 @@ constructor(
|
|||
return nextUpItems.getOrNull(0)
|
||||
}
|
||||
|
||||
fun togglePlayed(): Boolean {
|
||||
when (played) {
|
||||
false -> {
|
||||
played = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
jellyfinRepository.markAsPlayed(item.id)
|
||||
} catch (_: Exception) {}
|
||||
fun togglePlayed() {
|
||||
suspend fun updateUiPlayedState(played: Boolean) {
|
||||
item = item.copy(played = played)
|
||||
when (currentUiState) {
|
||||
is UiState.Normal -> {
|
||||
currentUiState = (currentUiState as UiState.Normal).copy(item = item)
|
||||
_uiState.emit(currentUiState)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
true -> {
|
||||
played = false
|
||||
viewModelScope.launch {
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val originalPlayedState = item.played
|
||||
updateUiPlayedState(!item.played)
|
||||
|
||||
when (item.played) {
|
||||
false -> {
|
||||
try {
|
||||
jellyfinRepository.markAsUnplayed(item.id)
|
||||
} catch (_: Exception) {}
|
||||
} catch (_: Exception) {
|
||||
updateUiPlayedState(originalPlayedState)
|
||||
}
|
||||
}
|
||||
true -> {
|
||||
try {
|
||||
jellyfinRepository.markAsPlayed(item.id)
|
||||
} catch (_: Exception) {
|
||||
updateUiPlayedState(originalPlayedState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return played
|
||||
}
|
||||
|
||||
fun toggleFavorite(): Boolean {
|
||||
when (favorite) {
|
||||
false -> {
|
||||
favorite = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
jellyfinRepository.markAsFavorite(item.id)
|
||||
} catch (_: Exception) {}
|
||||
fun toggleFavorite() {
|
||||
suspend fun updateUiFavoriteState(isFavorite: Boolean) {
|
||||
item = item.copy(favorite = isFavorite)
|
||||
when (currentUiState) {
|
||||
is UiState.Normal -> {
|
||||
currentUiState = (currentUiState as UiState.Normal).copy(item = item)
|
||||
_uiState.emit(currentUiState)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
true -> {
|
||||
favorite = false
|
||||
viewModelScope.launch {
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val originalFavoriteState = item.favorite
|
||||
updateUiFavoriteState(!item.favorite)
|
||||
|
||||
when (item.favorite) {
|
||||
false -> {
|
||||
try {
|
||||
jellyfinRepository.unmarkAsFavorite(item.id)
|
||||
} catch (_: Exception) {}
|
||||
} catch (_: Exception) {
|
||||
updateUiFavoriteState(originalFavoriteState)
|
||||
}
|
||||
}
|
||||
true -> {
|
||||
try {
|
||||
jellyfinRepository.markAsFavorite(item.id)
|
||||
} catch (_: Exception) {
|
||||
updateUiFavoriteState(originalFavoriteState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return favorite
|
||||
}
|
||||
|
||||
private fun getDateString(item: FindroidShow): String {
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
package dev.jdtech.jellyfin.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dev.jdtech.jellyfin.AppPreferences
|
||||
import dev.jdtech.jellyfin.api.JellyfinApi
|
||||
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||
import dev.jdtech.jellyfin.models.Server
|
||||
import dev.jdtech.jellyfin.models.User
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class UserSelectViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val appPreferences: AppPreferences,
|
||||
private val jellyfinApi: JellyfinApi,
|
||||
private val database: ServerDatabaseDao,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
private val eventsChannel = Channel<UserSelectEvent>()
|
||||
val eventsChannelFlow = eventsChannel.receiveAsFlow()
|
||||
|
||||
sealed class UiState {
|
||||
data class Normal(val server: Server, val users: List<User>) : UiState()
|
||||
data object Loading : UiState()
|
||||
data class Error(val error: Exception) : UiState()
|
||||
}
|
||||
|
||||
private val currentServerId: String? = appPreferences.currentServer
|
||||
|
||||
/**
|
||||
* Load users from the database and emit them
|
||||
*/
|
||||
fun loadUsers() {
|
||||
viewModelScope.launch {
|
||||
_uiState.emit(UiState.Loading)
|
||||
if (currentServerId == null) {
|
||||
_uiState.emit(UiState.Error(Exception("No server in use")))
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
val serverWithUser = database.getServerWithUsers(currentServerId)
|
||||
_uiState.emit(UiState.Normal(serverWithUser.server, serverWithUser.users))
|
||||
} catch (e: Exception) {
|
||||
_uiState.emit(UiState.Error(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in as user and navigate to home screen
|
||||
*
|
||||
* @param user The user
|
||||
*/
|
||||
fun loginAsUser(user: User) {
|
||||
viewModelScope.launch {
|
||||
if (currentServerId == null) {
|
||||
return@launch
|
||||
}
|
||||
val server = database.get(currentServerId) ?: return@launch
|
||||
server.currentUserId = user.id
|
||||
database.update(server)
|
||||
|
||||
jellyfinApi.apply {
|
||||
api.accessToken = user.accessToken
|
||||
userId = user.id
|
||||
}
|
||||
|
||||
eventsChannel.send(UserSelectEvent.NavigateToMain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface UserSelectEvent {
|
||||
data object NavigateToMain : UserSelectEvent
|
||||
}
|
31
core/src/main/res/drawable/ic_hard_drive.xml
Normal file
31
core/src/main/res/drawable/ic_hard_drive.xml
Normal file
|
@ -0,0 +1,31 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:pathData="M22,12L2,12"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M5.45,5.11 L2,12v6a2,2 0,0 0,2 2h16a2,2 0,0 0,2 -2v-6l-3.45,-6.89A2,2 0,0 0,16.76 4H7.24a2,2 0,0 0,-1.79 1.11z"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M6,16L6.01,16"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M10,16L10.01,16"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeLineCap="round"/>
|
||||
</vector>
|
25
core/src/main/res/drawable/ic_info.xml
Normal file
25
core/src/main/res/drawable/ic_info.xml
Normal file
|
@ -0,0 +1,25 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:pathData="M12,12m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M12,16v-4"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M12,8h0.01"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeLineCap="round"/>
|
||||
</vector>
|
22
core/src/main/res/drawable/ic_logo.xml
Normal file
22
core/src/main/res/drawable/ic_logo.xml
Normal file
|
@ -0,0 +1,22 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="100dp"
|
||||
android:height="100dp"
|
||||
android:viewportWidth="100"
|
||||
android:viewportHeight="100">
|
||||
<path
|
||||
android:pathData="m59.96,54.04 l3.38,-5.85c0.47,-0.81 -0.75,-1.52 -1.22,-0.7l-3.42,5.93c-2.62,-1.19 -5.56,-1.86 -8.69,-1.86 -3.14,0 -6.08,0.67 -8.69,1.86l-3.42,-5.93c-0.47,-0.81 -1.69,-0.11 -1.22,0.7l3.38,5.86c-5.8,3.16 -9.78,9.03 -10.36,15.98l40.62,0c-0.58,-6.94 -4.55,-12.82 -10.36,-15.98m-9.95,-54.04c-13.24,0 -55.83,77.23 -49.34,90.28 6.49,13.04 92.25,12.89 98.68,0 6.43,-12.89 -36.1,-90.28 -49.34,-90.28zM82.35,78.97c-4.21,8.45 -60.41,8.55 -64.66,0 -4.25,-8.55 23.66,-59.16 32.32,-59.16 8.66,0 36.55,50.69 32.34,59.16z"
|
||||
android:strokeWidth="0.03">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="30.22"
|
||||
android:startY="45.71"
|
||||
android:endX="81.6"
|
||||
android:endY="75.38"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="@color/logo_primary"/>
|
||||
<item android:offset="1" android:color="@color/logo_secondary"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
42
core/src/main/res/drawable/ic_sparkles.xml
Normal file
42
core/src/main/res/drawable/ic_sparkles.xml
Normal file
|
@ -0,0 +1,42 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:pathData="m12,3 l-1.912,5.813a2,2 0,0 1,-1.275 1.275L3,12l5.813,1.912a2,2 0,0 1,1.275 1.275L12,21l1.912,-5.813a2,2 0,0 1,1.275 -1.275L21,12l-5.813,-1.912a2,2 0,0 1,-1.275 -1.275L12,3Z"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M5,3v4"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M19,17v4"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M3,5h4"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M17,19h4"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeLineCap="round"/>
|
||||
</vector>
|
21
core/src/main/res/drawable/ic_tv.xml
Normal file
21
core/src/main/res/drawable/ic_tv.xml
Normal file
|
@ -0,0 +1,21 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:pathData="M4,7L20,7A2,2 0,0 1,22 9L22,20A2,2 0,0 1,20 22L4,22A2,2 0,0 1,2 20L2,9A2,2 0,0 1,4 7z"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M17,2l-5,5l-5,-5"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeLineCap="round"/>
|
||||
</vector>
|
5
core/src/main/res/mipmap-anydpi/ic_banner.xml
Normal file
5
core/src/main/res/mipmap-anydpi/ic_banner.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_banner_background"/>
|
||||
<foreground android:drawable="@drawable/ic_banner_foreground"/>
|
||||
</adaptive-icon>
|
|
@ -120,7 +120,6 @@
|
|||
<string name="theme_dark">Oscuro</string>
|
||||
<string name="settings_category_network">Red</string>
|
||||
<string name="pref_player_mpv_hwdec">Decodificación por hardware</string>
|
||||
<string name="pref_player_mpv_hwdec_codecs">Códecs de decodificación por hardware</string>
|
||||
<string name="pref_player_mpv_vo">Salida de vídeo</string>
|
||||
<string name="pref_player_mpv_ao">Salida de audio</string>
|
||||
<string name="addresses">Direcciones</string>
|
||||
|
|
|
@ -108,7 +108,6 @@
|
|||
<string name="sort_by_options_1">Classificação IMDB</string>
|
||||
<string name="sort_by_options_3">data adicionada</string>
|
||||
<string name="seek_back_increment">Buscar incremento de volta (ms)</string>
|
||||
<string name="pref_player_mpv_hwdec_codecs">Codecs de decodificação de hardware</string>
|
||||
<string name="pref_player_mpv_vo">Saida de video</string>
|
||||
<string name="pref_player_intro_skipper_summary">Requer que o plugin Confused Polar Bears Intro Skipper esteja instalado no servidor</string>
|
||||
<string name="remove_user">Remover usuário</string>
|
||||
|
|
|
@ -134,7 +134,6 @@
|
|||
<string name="pref_player_intro_skipper_summary">Benötigt ConfusedPolarBears Intro Skipper Plugin installiert auf dem Server</string>
|
||||
<string name="theme_system">Übernehme Systemeinstellung</string>
|
||||
<string name="pref_player_mpv_hwdec">Hardware-Dekodierung</string>
|
||||
<string name="pref_player_mpv_hwdec_codecs">Hardware-Dekodierung Codecs</string>
|
||||
<string name="pref_player_mpv_vo">Videoausgang</string>
|
||||
<string name="pref_player_mpv_ao">Audioausgang</string>
|
||||
<string name="pref_player_trick_play">Trickspiel</string>
|
||||
|
|
|
@ -128,7 +128,6 @@
|
|||
<string name="users">Usuarios</string>
|
||||
<string name="add_user">Agregar usuario</string>
|
||||
<string name="pref_player_mpv_hwdec">Decodificación por hardware</string>
|
||||
<string name="pref_player_mpv_hwdec_codecs">Codecs con decodificación por hardware</string>
|
||||
<string name="pref_player_mpv_vo">Salida de video</string>
|
||||
<string name="pref_player_mpv_ao">Salida de audio</string>
|
||||
<string name="addresses">Direcciones</string>
|
||||
|
|
|
@ -125,7 +125,6 @@
|
|||
<string name="settings_socket_timeout">Esperar zócalo (ms)</string>
|
||||
<string name="users">Usuarios</string>
|
||||
<string name="add_user">Agregar usuario</string>
|
||||
<string name="pref_player_mpv_hwdec_codecs">Codecs de decodificación por hardware</string>
|
||||
<string name="pref_player_mpv_vo">Salida de video</string>
|
||||
<string name="pref_player_mpv_ao">Salida de audio</string>
|
||||
<string name="remove_user">Quitar usuario</string>
|
||||
|
|
|
@ -123,7 +123,6 @@
|
|||
<string name="remove_user">Supprimer l\'utilisateur</string>
|
||||
<string name="remove_user_dialog_text">Voulez-vous vraiment supprimer l\'utilisateur %1$s</string>
|
||||
<string name="users">Utilisateurs</string>
|
||||
<string name="pref_player_mpv_hwdec_codecs">Décodages matériels supportés</string>
|
||||
<string name="quick_connect">Connexion rapide</string>
|
||||
<string name="pref_player_intro_skipper">Passer l\'introduction</string>
|
||||
<string name="pref_player_intro_skipper_summary">Le plugin Intro Skipper de ConfusedPolarBear doit être installé sur le serveur</string>
|
||||
|
|
|
@ -128,7 +128,6 @@
|
|||
<string name="settings_connect_timeout">Csatlakozási időtúllépés (ms)</string>
|
||||
<string name="add_user">Felhasználó hozzádása</string>
|
||||
<string name="pref_player_mpv_hwdec">Hardveres dekódolás</string>
|
||||
<string name="pref_player_mpv_hwdec_codecs">Hardveres dekódolási kodekek</string>
|
||||
<string name="settings_socket_timeout">Socket időtúllépése (ms)</string>
|
||||
<string name="settings_request_timeout">Lekérdezési idő túllépés (ms)</string>
|
||||
<string name="dynamic_colors">Dinamikus színek</string>
|
||||
|
@ -176,4 +175,14 @@
|
|||
<string name="picture_in_picture">Kép a képben</string>
|
||||
<string name="picture_in_picture_gesture">Kép a képben otthoni gesztus</string>
|
||||
<string name="picture_in_picture_gesture_summary">A home gomb vagy a gesztus használatával lépjen be a kép-a-képbe a videó lejátszása közben</string>
|
||||
<string name="no_users_found">Nincs felhasználó</string>
|
||||
<string name="select_user">Válassz felhasználót</string>
|
||||
<string name="no_servers_found">Nem található szerver</string>
|
||||
<string name="unmark_as_played">Megjelölés nem megtekintettként</string>
|
||||
<string name="add_to_favorites">Hozzáadás kedvencekhez</string>
|
||||
<string name="remove_from_favorites">Eltávolítás kedvencekből</string>
|
||||
<string name="mark_as_played">Jelölés megtekintettként</string>
|
||||
<string name="live_tv">Élő TV</string>
|
||||
<string name="play">Lejátszás</string>
|
||||
<string name="watch_trailer">Előzetes megtekintése</string>
|
||||
</resources>
|
|
@ -28,7 +28,7 @@
|
|||
<string name="director">Regista</string>
|
||||
<string name="cast_amp_crew">Cast</string>
|
||||
<string name="seasons">Stagioni</string>
|
||||
<string name="play_button_description">Riproduci</string>
|
||||
<string name="play_button_description">Riproduci i contenuti multimediali</string>
|
||||
<string name="check_button_description">Segna come visto o non visto</string>
|
||||
<string name="favorite_button_description">Preferito</string>
|
||||
<string name="episode_watched_indicator">Indicatore episodio visto</string>
|
||||
|
@ -128,7 +128,6 @@
|
|||
<string name="users">Utenti</string>
|
||||
<string name="add_user">Aggiungi utente</string>
|
||||
<string name="pref_player_mpv_hwdec">Decodifica hardware</string>
|
||||
<string name="pref_player_mpv_hwdec_codecs">Codec di decodifica hardware</string>
|
||||
<string name="pref_player_mpv_vo">Output video</string>
|
||||
<string name="addresses">Indirizzi</string>
|
||||
<string name="add_address">Aggiungi indirizzo</string>
|
||||
|
@ -176,4 +175,14 @@
|
|||
<string name="picture_in_picture">Immagine nell\'immagine</string>
|
||||
<string name="picture_in_picture_gesture">Gesto/pulsante home</string>
|
||||
<string name="picture_in_picture_gesture_summary">Usa il gesto/pulsante Home durante la riproduzione del video per attivare la modalità immagine nell\'immagine</string>
|
||||
<string name="no_servers_found">Nessun server trovato</string>
|
||||
<string name="no_users_found">Nessun utente trovato</string>
|
||||
<string name="select_user">Seleziona utente</string>
|
||||
<string name="watch_trailer">Guarda Trailer</string>
|
||||
<string name="add_to_favorites">Aggiungi ai preferiti</string>
|
||||
<string name="mark_as_played">Segna come visto</string>
|
||||
<string name="unmark_as_played">Segna come non visto</string>
|
||||
<string name="live_tv">Diretta TV</string>
|
||||
<string name="play">Riproduci</string>
|
||||
<string name="remove_from_favorites">Rimuovi dai preferiti</string>
|
||||
</resources>
|
|
@ -117,7 +117,6 @@
|
|||
<string name="subtitle">כתוביות</string>
|
||||
<string name="extra_info">הצג מידע נוסף</string>
|
||||
<string name="settings_socket_timeout">זמן קצוב ל-Socket (מילי שניות)</string>
|
||||
<string name="pref_player_mpv_hwdec_codecs">מפענחי חומרה</string>
|
||||
<string name="pref_player_mpv_vo">יציאת וידאו</string>
|
||||
<string name="pref_player_mpv_ao">יציאת שמע</string>
|
||||
<string name="pref_player_intro_skipper">מדלג פתיחים</string>
|
||||
|
|
|
@ -128,7 +128,6 @@
|
|||
<string name="add_user">유저 추가</string>
|
||||
<string name="pref_player_mpv_hwdec">하드웨어 디코딩</string>
|
||||
<string name="add_server_address">서버 주소 추가</string>
|
||||
<string name="pref_player_mpv_hwdec_codecs">하드웨어 디코딩 코덱</string>
|
||||
<string name="pref_player_mpv_vo">비디오 출력</string>
|
||||
<string name="pref_player_mpv_ao">오디오 출력</string>
|
||||
<string name="addresses">주소</string>
|
||||
|
|
|
@ -70,7 +70,6 @@
|
|||
<string name="seek_forward_increment">Zoek vooruitstap (ms)</string>
|
||||
<string name="select_video_version_title">Selecteer versie</string>
|
||||
<string name="pref_player_mpv_hwdec">Hardware decoding</string>
|
||||
<string name="pref_player_mpv_hwdec_codecs">Hardware decodering codecs</string>
|
||||
<string name="pref_player_mpv_vo">Video uitvoer</string>
|
||||
<string name="pref_player_mpv_ao">Audio uitvoer</string>
|
||||
<string name="libraries">Bibliotheken</string>
|
||||
|
|
|
@ -122,7 +122,6 @@
|
|||
<string name="theme_dark">Ciemny</string>
|
||||
<string name="episodes_label">Odcinki</string>
|
||||
<string name="add_user">Dodaj użytkownika</string>
|
||||
<string name="pref_player_mpv_hwdec_codecs">Sprzętowe kodeki do dekodowania</string>
|
||||
<string name="pref_player_mpv_vo">Wyjście wideo</string>
|
||||
<string name="pref_player_mpv_ao">Wyjście audio</string>
|
||||
<string name="seek_back_increment">Krok przesuwania wstecznego (ms)</string>
|
||||
|
|
|
@ -115,7 +115,6 @@
|
|||
<string name="users">Usuários</string>
|
||||
<string name="add_user">Adicionar usuário</string>
|
||||
<string name="pref_player_mpv_hwdec">Decodificação de hardware</string>
|
||||
<string name="pref_player_mpv_hwdec_codecs">Codecs de decodificação de hardware</string>
|
||||
<string name="sort_by_options_3">Data de Adição</string>
|
||||
<string name="sort_by_options_4">Data de Reprodução</string>
|
||||
<string name="ascending">Crescente</string>
|
||||
|
@ -176,4 +175,14 @@
|
|||
<string name="picture_in_picture">Picture-in-picture</string>
|
||||
<string name="picture_in_picture_gesture">Picture-in-Picture</string>
|
||||
<string name="picture_in_picture_gesture_summary">Use o botão home ou gestos para entrar no modo picture-in-picture enquanto o vídeo está sendo reproduzido</string>
|
||||
<string name="no_servers_found">Nenhum servidor encontrado</string>
|
||||
<string name="mark_as_played">Marcar como reproduzido</string>
|
||||
<string name="unmark_as_played">Desmarcar como reproduzido</string>
|
||||
<string name="add_to_favorites">Adicionar aos favoritos</string>
|
||||
<string name="remove_from_favorites">Remover dos favoritos</string>
|
||||
<string name="no_users_found">Usuários não encontrados</string>
|
||||
<string name="select_user">Selecione o usuário</string>
|
||||
<string name="live_tv">TV ao vivo</string>
|
||||
<string name="play">Reproduzir</string>
|
||||
<string name="watch_trailer">Assista o trailer</string>
|
||||
</resources>
|
|
@ -124,7 +124,6 @@
|
|||
<string name="remove_user_dialog_text">Tem certeza de que deseja remover o usuário %1$s</string>
|
||||
<string name="pref_player_mpv_vo">Saida de video</string>
|
||||
<string name="pref_player_mpv_ao">Saída de áudio</string>
|
||||
<string name="pref_player_mpv_hwdec_codecs">Codecs de decodificação de hardware</string>
|
||||
<string name="add_address">Adicionar endereço</string>
|
||||
<string name="pref_player_trick_play">Jogo de truque</string>
|
||||
<string name="episode_name">%1$d. %2$s</string>
|
||||
|
|
|
@ -77,7 +77,6 @@
|
|||
<string name="users">Utilizatori</string>
|
||||
<string name="add_user">Adaugă un utilizator</string>
|
||||
<string name="pref_player_mpv_hwdec">Decodare hardware</string>
|
||||
<string name="pref_player_mpv_hwdec_codecs">Codecuri de decodare hardware</string>
|
||||
<string name="pref_player_mpv_vo">Ieșire video</string>
|
||||
<string name="pref_player_mpv_ao">Ieșire audio</string>
|
||||
<string name="pref_player_intro_skipper_summary">Necesită ca pluginul IntroSkipper de ConfusedPolarBear să fie instalat pe server</string>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue