feat: android tv (#598)
* Add AddServerScreen * Upgrade androidx-compose-material3 and androidx-compose-ui to alpha * Add DiscoveredServerComponent * Show discovered servers * Add navigation using compose-destinations * Implement Loginscreen * Start of HomeScreen * Use coil for home screen images and update layout with spacers * Select correct startRoute based on conditions * Upgrade compose material3 to 1.1.0-alpha05 * Add series title, max 1 line, padding * Upgrade dependencies * Switch to TvLazyColumn and TvLazyRow * Add header to `HomeScreen` * Add progress bar to Continue watching items * Limit the number of lines under Movie or Show and use correct episode text * chore: run ktlintFormat * ci: assemble tv * feat: `LibraryScreen` * fix: update to reworked items system * chore(deps): update androidx-paging-compose and compose-destination Also fix lint issue on HomeScreen * feat: start using androidx.material3 composables * feat: add coil svg * feat: experimenting with cards * lint: fix linting issues * feat: server select screen * build: upgrade dependencies * lint: run ktlintFormat * feat(ServerSelectScreen): add "No servers found" text * feat: update AddServerScreen * feat: implement `UiText.asString()` composable * lint: run ktlintFormat * refactor(phone): remove livedata from `ServerSelectScreen` * feat: add `UserSelectScreen` * feat(UserSelectScreen): load user's profile picture * feat: update LoginScreen * feat: update progress indicator on `AddServerScreen` * fix: change color of `ServerComponent` * style(ServerSelect): use material typography * chore: update ktlint config in build.gradle * style: use material typography * refactor: move home screen to separate layout function so it can be previewed - Introduce dummy items - Fix `UiText.asString()` composable * refactor: preview `LibraryScreen` * refactor: preview `ServerSelectScreen` * refactor: preview `AddServerScreen` * refactor: preview `UserSelectScreen` * refactor: preview `LoginScreen` * lint: run ktlintFormat * feat: switch servers * feat: main screen tabs navigation * feat: add Live TV tab (not shown for now) * chore: remove libraries from `HomeViewModel` * chore: change colors of selected tab * feat: new item card component * chore: remove `HomeItem.Libraries` * style: update spacing * feat: add findroid icon and profile button on main screen * style(main): add gradient background * fix: use "latest" + library name in home screen * fix: navigate from LoginScreen and UserSelectScreen to MainScreen * style: update tab colors * fix: remove reference to `HomeItem.Libraries` from `ViewListAdapter` * chore: update kotlin compiler to 1.5.0 * feat: add horizontal item card variant * feat: `LibrariesScreen` * feat: `LibraryScreen` * fix(`LibraryScreen`): remove hardcoded library name * feat: `MovieScreen` * feat(`MovieScreen`): Make trailer button work Also hide the button when there is no trailer * refactor(`MovieScreen`): move click logic out of layout * refactor: create `FindroidImages` which holds all image uris * build: upgrade androidx.tv to 1.0.0-alpha08 * chore: update compose libraries * chore: update compose compiler to 1.5.1 * fix(ItemCard): only show progress when horizontal * refactor: clean build.gradle.kts * build: up minSdk to 28 and targetSdk to 34 * refactor: use spacings to provide paddings (#443) * feat: add Spacings in MaterialTheme * style: use MaterialTheme.spacings in layouts Using sizes in Spacer & padding & PaddingValues to standardize it. * fix: linting issues and a few paddings * feat: use spacings in `LibraryScreen` * feat: use spacings in `MovieScreen` * fix: missing trailing comma * refactor: replace hardcoded spacer in ItemCard with spacing --------- Co-authored-by: Jarne Demeulemeester <jarnedemeulemeester@gmail.com> * build: migrate to ksp and upgrade compose compiler * build: upgrade compose libraries * fix: align with main codebase * chore: update agp to 8.1.3 * chore: update ksp and compose-destinations ksp 1.9.10-1.0.13 -> 1.9.20-1.0.14 compose-destinations 1.9.51 -> 1.9.54 * refactor(UserSelectViewModel): use channel for events * feat: basic video player First implementation of the video player. Uses the basic player view with no custom layout. Only media keys are passed to the PlayerView. * feat: show screen Still a work in progress * fix: make player background black * fix(player): keep screen on * feat: add border around focused tab * lint: run ktlintFormat * feat: focus improvements * feat: logo for main screen * fix: remember tab position * feat: add loading indicator to main screen And fix home and libraries screen list refresh on navigating back * feat: add seasons to show screen * feat: add season screen * feat: add progress badge * chore(deps) update dependencies android-plugin 8.1.3 -> 8.1.4 androidx-activity 1.8.0 -> 1.8.1 androidx-media3 1.1.1 -> 1.2.0 coil 2.4.0 -> 2.5.0 kotlinx-serialization 1.6.0 -> 1.6.1 * ci: upload tv artifacts and don't build universal apks * chore: get rid of deprecated android.defaults.buildfeatures.buildconfig * build: upgrade dependencies android-plugin 8.1.4 -> 8.2.0 androidx-room 2.6.0 -> 2.6.1 androidx-work 2.8.1 -> 2.9.0 jellyfin 1.4.5 -> 1.4.6 compose compiler 1.5.4 -> 1.5.5 * fix: workManagerConfiguration is now a property * feat: add profile picture to main screen * feat: start of settings screen * refactor: base tv theme on normal compose material theme * chore(deps): update kotlin to 1.9.21 kotlin 1.9.20 -> 1.9.21 ksp 1.9.20-1.0.14 -> 1.9.21-1.0.15 compose-compiler 1.5.5 -> 1.5.6 * feat(settings): add categories and pop backstack when navigating to main screen * feat(settings): nested settings and switch setting * feat(settings): settings select component * feat(settings): icons for cache and about * feat(settings): add option to toggle mpv player * feat(settings): move preference value logic to viewmodel * feat(settings): add dependencies * chore: update compose compiler compose-compiler 1.5.6 -> 1.5.7 * feat(settings): add settings detail select card New sub settings screen with different layout Settings detail select card to select an option * feat: play episode from home screen * feat(player): basic custom overlay Courtesy of Android TV JetStreamCompose sample * feat(player): add track selection dialog * feat(player): add media session and clean up dpad events * refactor(mpv): implement track selection via TrackSelectionParameters Need to add ability to disable track type * feat: implement watched and favorite buttons * refactor: remove unused PreferenceType enum ---------
This commit is contained in:
parent
42650ee6c4
commit
7171ec72c1
113 changed files with 7504 additions and 489 deletions
25
.github/workflows/build.yaml
vendored
25
.github/workflows/build.yaml
vendored
|
@ -40,11 +40,6 @@ jobs:
|
|||
- name: Build with Gradle
|
||||
run: ./gradlew assembleDebug
|
||||
# Upload all build artifacts in separate steps. This can be shortened once https://github.com/actions/upload-artifact/pull/354 is merged.
|
||||
- name: Upload artifact phone-libre-universal-debug.apk
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: phone-libre-universal-debug.apk
|
||||
path: ./app/phone/build/outputs/apk/libre/debug/phone-libre-universal-debug.apk
|
||||
- name: Upload artifact phone-libre-arm64-v8a-debug.apk
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
|
@ -65,3 +60,23 @@ jobs:
|
|||
with:
|
||||
name: phone-libre-x86-debug.apk
|
||||
path: ./app/phone/build/outputs/apk/libre/debug/phone-libre-x86-debug.apk
|
||||
- name: Upload artifact tv-libre-arm64-v8a-debug.apk
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: tv-libre-arm64-v8a-debug.apk
|
||||
path: ./app/tv/build/outputs/apk/libre/debug/tv-libre-arm64-v8a-debug.apk
|
||||
- name: Upload artifact tv-libre-armeabi-v7a-debug.apk
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: tv-libre-armeabi-v7a-debug.apk
|
||||
path: ./app/tv/build/outputs/apk/libre/debug/tv-libre-armeabi-v7a-debug.apk
|
||||
- name: Upload artifact tv-libre-x86_64-debug.apk
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: tv-libre-x86_64-debug.apk
|
||||
path: ./app/tv/build/outputs/apk/libre/debug/tv-libre-x86_64-debug.apk
|
||||
- name: Upload artifact tv-libre-x86-debug.apk
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: tv-libre-x86-debug.apk
|
||||
path: ./app/tv/build/outputs/apk/libre/debug/tv-libre-x86-debug.apk
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
90
app/tv/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
Normal file
90
app/tv/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
Normal file
|
@ -0,0 +1,90 @@
|
|||
package dev.jdtech.jellyfin
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.NonInteractiveSurfaceDefaults
|
||||
import androidx.tv.material3.Surface
|
||||
import com.ramcosta.composedestinations.DestinationsNavHost
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||
import dev.jdtech.jellyfin.destinations.AddServerScreenDestination
|
||||
import dev.jdtech.jellyfin.destinations.LoginScreenDestination
|
||||
import dev.jdtech.jellyfin.destinations.ServerSelectScreenDestination
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.viewmodels.MainViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
|
||||
@Inject
|
||||
lateinit var database: ServerDatabaseDao
|
||||
|
||||
@Inject
|
||||
lateinit var appPreferences: AppPreferences
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
var startRoute = NavGraphs.root.startRoute
|
||||
if (checkServersEmpty()) {
|
||||
startRoute = AddServerScreenDestination
|
||||
} else if (checkUser()) {
|
||||
startRoute = LoginScreenDestination
|
||||
}
|
||||
|
||||
// TODO remove temp always show server selection screen
|
||||
startRoute = ServerSelectScreenDestination
|
||||
|
||||
setContent {
|
||||
FindroidTheme {
|
||||
Surface(
|
||||
colors = NonInteractiveSurfaceDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
),
|
||||
shape = RectangleShape,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
DestinationsNavHost(
|
||||
navGraph = NavGraphs.root,
|
||||
startRoute = startRoute,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkServersEmpty(): Boolean {
|
||||
if (!viewModel.startDestinationChanged) {
|
||||
val nServers = database.getServersCount()
|
||||
if (nServers < 1) {
|
||||
viewModel.startDestinationChanged = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun checkUser(): Boolean {
|
||||
if (!viewModel.startDestinationChanged) {
|
||||
appPreferences.currentServer?.let {
|
||||
val currentUser = database.getServerCurrentUser(it)
|
||||
if (currentUser == null) {
|
||||
viewModel.startDestinationChanged = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
194
app/tv/src/main/java/dev/jdtech/jellyfin/ui/AddServerScreen.kt
Normal file
194
app/tv/src/main/java/dev/jdtech/jellyfin/ui/AddServerScreen.kt
Normal file
|
@ -0,0 +1,194 @@
|
|||
package dev.jdtech.jellyfin.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.LocalContentColor
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import dev.jdtech.jellyfin.destinations.LoginScreenDestination
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
import dev.jdtech.jellyfin.utils.ObserveAsEvents
|
||||
import dev.jdtech.jellyfin.viewmodels.AddServerEvent
|
||||
import dev.jdtech.jellyfin.viewmodels.AddServerViewModel
|
||||
import dev.jdtech.jellyfin.core.R as CoreR
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
fun AddServerScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
addServerViewModel: AddServerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by addServerViewModel.uiState.collectAsState()
|
||||
|
||||
ObserveAsEvents(addServerViewModel.eventsChannelFlow) { event ->
|
||||
when (event) {
|
||||
is AddServerEvent.NavigateToLogin -> {
|
||||
navigator.navigate(LoginScreenDestination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AddServerScreenLayout(
|
||||
uiState = uiState,
|
||||
onConnectClick = { serverAddress ->
|
||||
addServerViewModel.checkServer(serverAddress)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AddServerScreenLayout(
|
||||
uiState: AddServerViewModel.UiState,
|
||||
onConnectClick: (String) -> Unit,
|
||||
) {
|
||||
var serverAddress by rememberSaveable {
|
||||
mutableStateOf("")
|
||||
}
|
||||
val isError = uiState is AddServerViewModel.UiState.Error
|
||||
val isLoading = uiState is AddServerViewModel.UiState.Loading
|
||||
val context = LocalContext.current
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.Center),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.add_server),
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
|
||||
OutlinedTextField(
|
||||
value = serverAddress,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(id = CoreR.drawable.ic_server),
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onValueChange = { serverAddress = it },
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.edit_text_server_address_hint),
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
autoCorrect = false,
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
isError = isError,
|
||||
enabled = !isLoading,
|
||||
supportingText = {
|
||||
if (isError) {
|
||||
Text(
|
||||
text = (uiState as AddServerViewModel.UiState.Error).message.joinToString {
|
||||
it.asString(
|
||||
context.resources,
|
||||
)
|
||||
},
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.width(360.dp)
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
|
||||
Box {
|
||||
Button(
|
||||
onClick = {
|
||||
onConnectClick(serverAddress)
|
||||
},
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier.width(360.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
color = LocalContentColor.current,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.button_connect),
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(true) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview(widthDp = 960, heightDp = 540)
|
||||
@Composable
|
||||
private fun AddServerScreenLayoutPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
AddServerScreenLayout(
|
||||
uiState = AddServerViewModel.UiState.Normal,
|
||||
onConnectClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
212
app/tv/src/main/java/dev/jdtech/jellyfin/ui/HomeScreen.kt
Normal file
212
app/tv/src/main/java/dev/jdtech/jellyfin/ui/HomeScreen.kt
Normal file
|
@ -0,0 +1,212 @@
|
|||
package dev.jdtech.jellyfin.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.tv.foundation.lazy.list.TvLazyColumn
|
||||
import androidx.tv.foundation.lazy.list.TvLazyRow
|
||||
import androidx.tv.foundation.lazy.list.items
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import dev.jdtech.jellyfin.destinations.MovieScreenDestination
|
||||
import dev.jdtech.jellyfin.destinations.PlayerActivityDestination
|
||||
import dev.jdtech.jellyfin.destinations.ShowScreenDestination
|
||||
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||
import dev.jdtech.jellyfin.models.FindroidItem
|
||||
import dev.jdtech.jellyfin.models.FindroidMovie
|
||||
import dev.jdtech.jellyfin.models.FindroidShow
|
||||
import dev.jdtech.jellyfin.models.HomeItem
|
||||
import dev.jdtech.jellyfin.ui.components.Direction
|
||||
import dev.jdtech.jellyfin.ui.components.ItemCard
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyHomeItems
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
import dev.jdtech.jellyfin.utils.ObserveAsEvents
|
||||
import dev.jdtech.jellyfin.viewmodels.HomeViewModel
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
||||
import dev.jdtech.jellyfin.core.R as CoreR
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
homeViewModel: HomeViewModel = hiltViewModel(),
|
||||
playerViewModel: PlayerViewModel = hiltViewModel(),
|
||||
isLoading: (Boolean) -> Unit,
|
||||
) {
|
||||
LaunchedEffect(key1 = true) {
|
||||
homeViewModel.loadData()
|
||||
}
|
||||
|
||||
ObserveAsEvents(playerViewModel.eventsChannelFlow) { event ->
|
||||
when (event) {
|
||||
is PlayerItemsEvent.PlayerItemsReady -> {
|
||||
navigator.navigate(PlayerActivityDestination(items = ArrayList(event.items)))
|
||||
}
|
||||
is PlayerItemsEvent.PlayerItemsError -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
val delegatedUiState by homeViewModel.uiState.collectAsState()
|
||||
|
||||
HomeScreenLayout(
|
||||
uiState = delegatedUiState,
|
||||
isLoading = isLoading,
|
||||
onClick = { item ->
|
||||
when (item) {
|
||||
is FindroidMovie -> {
|
||||
navigator.navigate(MovieScreenDestination(item.id))
|
||||
}
|
||||
is FindroidShow -> {
|
||||
navigator.navigate(ShowScreenDestination(item.id))
|
||||
}
|
||||
is FindroidEpisode -> {
|
||||
playerViewModel.loadPlayerItems(item = item)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun HomeScreenLayout(
|
||||
uiState: HomeViewModel.UiState,
|
||||
isLoading: (Boolean) -> Unit,
|
||||
onClick: (FindroidItem) -> Unit,
|
||||
) {
|
||||
var homeItems: List<HomeItem> by remember { mutableStateOf(emptyList()) }
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
when (uiState) {
|
||||
is HomeViewModel.UiState.Normal -> {
|
||||
homeItems = uiState.homeItems
|
||||
isLoading(false)
|
||||
}
|
||||
is HomeViewModel.UiState.Loading -> {
|
||||
isLoading(true)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
TvLazyColumn(
|
||||
contentPadding = PaddingValues(bottom = MaterialTheme.spacings.large),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester),
|
||||
) {
|
||||
items(homeItems, key = { it.id }) { homeItem ->
|
||||
when (homeItem) {
|
||||
is HomeItem.Section -> {
|
||||
Text(
|
||||
text = homeItem.homeSection.name.asString(),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(start = MaterialTheme.spacings.large),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
|
||||
TvLazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
|
||||
contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.large),
|
||||
) {
|
||||
items(homeItem.homeSection.items, key = { it.id }) { item ->
|
||||
ItemCard(
|
||||
item = item,
|
||||
direction = Direction.HORIZONTAL,
|
||||
onClick = {
|
||||
onClick(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
|
||||
}
|
||||
is HomeItem.ViewItem -> {
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.latest_library, homeItem.view.name),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(start = MaterialTheme.spacings.large),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
|
||||
TvLazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
|
||||
contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.large),
|
||||
) {
|
||||
items(homeItem.view.items.orEmpty(), key = { it.id }) { item ->
|
||||
ItemCard(
|
||||
item = item,
|
||||
direction = Direction.VERTICAL,
|
||||
onClick = {
|
||||
onClick(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(homeItems) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview(widthDp = 960, heightDp = 540)
|
||||
@Composable
|
||||
private fun HomeScreenLayoutPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
HomeScreenLayout(
|
||||
uiState = HomeViewModel.UiState.Normal(dummyHomeItems),
|
||||
isLoading = {},
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun Header(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(80.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = CoreR.drawable.ic_banner),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(40.dp),
|
||||
)
|
||||
}
|
||||
}
|
118
app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibrariesScreen.kt
Normal file
118
app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibrariesScreen.kt
Normal file
|
@ -0,0 +1,118 @@
|
|||
package dev.jdtech.jellyfin.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.tv.foundation.lazy.grid.TvGridCells
|
||||
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
||||
import androidx.tv.foundation.lazy.grid.items
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import dev.jdtech.jellyfin.destinations.LibraryScreenDestination
|
||||
import dev.jdtech.jellyfin.models.CollectionType
|
||||
import dev.jdtech.jellyfin.models.FindroidCollection
|
||||
import dev.jdtech.jellyfin.ui.components.Direction
|
||||
import dev.jdtech.jellyfin.ui.components.ItemCard
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyCollections
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
import dev.jdtech.jellyfin.viewmodels.MediaViewModel
|
||||
import java.util.UUID
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
fun LibrariesScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
isLoading: (Boolean) -> Unit,
|
||||
mediaViewModel: MediaViewModel = hiltViewModel(),
|
||||
) {
|
||||
val delegatedUiState by mediaViewModel.uiState.collectAsState()
|
||||
|
||||
LibrariesScreenLayout(
|
||||
uiState = delegatedUiState,
|
||||
isLoading = isLoading,
|
||||
onClick = { libraryId, libraryName, libraryType ->
|
||||
navigator.navigate(LibraryScreenDestination(libraryId, libraryName, libraryType))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LibrariesScreenLayout(
|
||||
uiState: MediaViewModel.UiState,
|
||||
isLoading: (Boolean) -> Unit,
|
||||
onClick: (UUID, String, CollectionType) -> Unit,
|
||||
) {
|
||||
var collections: List<FindroidCollection> by remember {
|
||||
mutableStateOf(emptyList())
|
||||
}
|
||||
|
||||
when (uiState) {
|
||||
is MediaViewModel.UiState.Normal -> {
|
||||
collections = uiState.collections
|
||||
isLoading(false)
|
||||
}
|
||||
is MediaViewModel.UiState.Loading -> {
|
||||
isLoading(true)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Fixed(3),
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large),
|
||||
contentPadding = PaddingValues(
|
||||
start = MaterialTheme.spacings.large,
|
||||
top = MaterialTheme.spacings.small,
|
||||
end = MaterialTheme.spacings.large,
|
||||
bottom = MaterialTheme.spacings.large,
|
||||
),
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
) {
|
||||
items(collections, key = { it.id }) { collection ->
|
||||
ItemCard(
|
||||
item = collection,
|
||||
direction = Direction.HORIZONTAL,
|
||||
onClick = {
|
||||
onClick(collection.id, collection.name, collection.type)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(collections) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview(widthDp = 960, heightDp = 540)
|
||||
@Composable
|
||||
private fun LibrariesScreenLayoutPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
LibrariesScreenLayout(
|
||||
uiState = MediaViewModel.UiState.Normal(dummyCollections),
|
||||
isLoading = {},
|
||||
onClick = { _, _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
143
app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibraryScreen.kt
Normal file
143
app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibraryScreen.kt
Normal file
|
@ -0,0 +1,143 @@
|
|||
package dev.jdtech.jellyfin.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.tv.foundation.lazy.grid.TvGridCells
|
||||
import androidx.tv.foundation.lazy.grid.TvGridItemSpan
|
||||
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import dev.jdtech.jellyfin.destinations.MovieScreenDestination
|
||||
import dev.jdtech.jellyfin.destinations.ShowScreenDestination
|
||||
import dev.jdtech.jellyfin.models.CollectionType
|
||||
import dev.jdtech.jellyfin.models.FindroidItem
|
||||
import dev.jdtech.jellyfin.models.FindroidMovie
|
||||
import dev.jdtech.jellyfin.models.FindroidShow
|
||||
import dev.jdtech.jellyfin.ui.components.Direction
|
||||
import dev.jdtech.jellyfin.ui.components.ItemCard
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyMovies
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
import dev.jdtech.jellyfin.viewmodels.LibraryViewModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import java.util.UUID
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
libraryId: UUID,
|
||||
libraryName: String,
|
||||
libraryType: CollectionType,
|
||||
libraryViewModel: LibraryViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(true) {
|
||||
libraryViewModel.loadItems(libraryId, libraryType)
|
||||
}
|
||||
|
||||
val delegatedUiState by libraryViewModel.uiState.collectAsState()
|
||||
|
||||
LibraryScreenLayout(
|
||||
libraryName = libraryName,
|
||||
uiState = delegatedUiState,
|
||||
onClick = { item ->
|
||||
when (item) {
|
||||
is FindroidMovie -> {
|
||||
navigator.navigate(MovieScreenDestination(item.id))
|
||||
}
|
||||
is FindroidShow -> {
|
||||
navigator.navigate(ShowScreenDestination(item.id))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LibraryScreenLayout(
|
||||
libraryName: String,
|
||||
uiState: LibraryViewModel.UiState,
|
||||
onClick: (FindroidItem) -> Unit,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
when (uiState) {
|
||||
is LibraryViewModel.UiState.Loading -> Text(text = "LOADING")
|
||||
is LibraryViewModel.UiState.Normal -> {
|
||||
val items = uiState.items.collectAsLazyPagingItems()
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Fixed(5),
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
|
||||
contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.default * 2, vertical = MaterialTheme.spacings.large),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721))))
|
||||
.focusRequester(focusRequester),
|
||||
) {
|
||||
item(span = { TvGridItemSpan(this.maxLineSpan) }) {
|
||||
Text(
|
||||
text = libraryName,
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
)
|
||||
}
|
||||
items(items.itemCount) { i ->
|
||||
val item = items[i]
|
||||
item?.let {
|
||||
ItemCard(
|
||||
item = item,
|
||||
direction = Direction.VERTICAL,
|
||||
onClick = {
|
||||
onClick(item)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(items.itemCount > 0) {
|
||||
if (items.itemCount > 0) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
is LibraryViewModel.UiState.Error -> Text(text = uiState.error.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview(widthDp = 960, heightDp = 540)
|
||||
@Composable
|
||||
private fun LibraryScreenLayoutPreview() {
|
||||
val data: Flow<PagingData<FindroidItem>> = flowOf(PagingData.from(dummyMovies))
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
LibraryScreenLayout(
|
||||
libraryName = "Movies",
|
||||
uiState = LibraryViewModel.UiState.Normal(data),
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
287
app/tv/src/main/java/dev/jdtech/jellyfin/ui/LoginScreen.kt
Normal file
287
app/tv/src/main/java/dev/jdtech/jellyfin/ui/LoginScreen.kt
Normal file
|
@ -0,0 +1,287 @@
|
|||
package dev.jdtech.jellyfin.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.LocalContentColor
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.OutlinedButton
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.ramcosta.composedestinations.navigation.popUpTo
|
||||
import dev.jdtech.jellyfin.NavGraphs
|
||||
import dev.jdtech.jellyfin.destinations.MainScreenDestination
|
||||
import dev.jdtech.jellyfin.models.UiText
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
import dev.jdtech.jellyfin.utils.ObserveAsEvents
|
||||
import dev.jdtech.jellyfin.viewmodels.LoginEvent
|
||||
import dev.jdtech.jellyfin.viewmodels.LoginViewModel
|
||||
import dev.jdtech.jellyfin.core.R as CoreR
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
loginViewModel: LoginViewModel = hiltViewModel(),
|
||||
) {
|
||||
val delegatedUiState by loginViewModel.uiState.collectAsState()
|
||||
val delegatedQuickConnectUiState by loginViewModel.quickConnectUiState.collectAsState(
|
||||
initial = LoginViewModel.QuickConnectUiState.Disabled,
|
||||
)
|
||||
|
||||
ObserveAsEvents(loginViewModel.eventsChannelFlow) { event ->
|
||||
when (event) {
|
||||
is LoginEvent.NavigateToHome -> {
|
||||
navigator.navigate(MainScreenDestination) {
|
||||
popUpTo(NavGraphs.root) {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LoginScreenLayout(
|
||||
uiState = delegatedUiState,
|
||||
quickConnectUiState = delegatedQuickConnectUiState,
|
||||
onLoginClick = { username, password ->
|
||||
loginViewModel.login(username, password)
|
||||
},
|
||||
onQuickConnectClick = {
|
||||
loginViewModel.useQuickConnect()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LoginScreenLayout(
|
||||
uiState: LoginViewModel.UiState,
|
||||
quickConnectUiState: LoginViewModel.QuickConnectUiState,
|
||||
onLoginClick: (String, String) -> Unit,
|
||||
onQuickConnectClick: () -> Unit,
|
||||
) {
|
||||
var username by rememberSaveable {
|
||||
mutableStateOf("")
|
||||
}
|
||||
var password by rememberSaveable {
|
||||
mutableStateOf("")
|
||||
}
|
||||
|
||||
var quickConnectValue = stringResource(id = CoreR.string.quick_connect)
|
||||
|
||||
when (quickConnectUiState) {
|
||||
is LoginViewModel.QuickConnectUiState.Waiting -> {
|
||||
quickConnectValue = quickConnectUiState.code
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
val isError = uiState is LoginViewModel.UiState.Error
|
||||
val isLoading = uiState is LoginViewModel.UiState.Loading
|
||||
|
||||
val quickConnectEnabled = quickConnectUiState !is LoginViewModel.QuickConnectUiState.Disabled
|
||||
val isWaiting = quickConnectUiState is LoginViewModel.QuickConnectUiState.Waiting
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.Center),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.login),
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(id = CoreR.drawable.ic_user),
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onValueChange = { username = it },
|
||||
label = { Text(text = stringResource(id = CoreR.string.edit_text_username_hint)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
autoCorrect = false,
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
isError = isError,
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier
|
||||
.width(360.dp)
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(id = CoreR.drawable.ic_lock),
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onValueChange = { password = it },
|
||||
label = { Text(text = stringResource(id = CoreR.string.edit_text_password_hint)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
autoCorrect = false,
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
isError = isError,
|
||||
enabled = !isLoading,
|
||||
supportingText = {
|
||||
if (isError) {
|
||||
Text(
|
||||
text = (uiState as LoginViewModel.UiState.Error).message.asString(),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.width(360.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.default))
|
||||
Box {
|
||||
Button(
|
||||
onClick = {
|
||||
onLoginClick(username, password)
|
||||
},
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier.width(360.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
color = LocalContentColor.current,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.button_login),
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (quickConnectEnabled) {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
|
||||
Box {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
onQuickConnectClick()
|
||||
},
|
||||
modifier = Modifier.width(360.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (isWaiting) {
|
||||
CircularProgressIndicator(
|
||||
color = LocalContentColor.current,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = quickConnectValue,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(true) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview(widthDp = 960, heightDp = 540)
|
||||
@Composable
|
||||
private fun LoginScreenLayoutPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
LoginScreenLayout(
|
||||
uiState = LoginViewModel.UiState.Normal,
|
||||
quickConnectUiState = LoginViewModel.QuickConnectUiState.Normal,
|
||||
onLoginClick = { _, _ -> },
|
||||
onQuickConnectClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview(widthDp = 960, heightDp = 540)
|
||||
@Composable
|
||||
private fun LoginScreenLayoutPreviewError() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
LoginScreenLayout(
|
||||
uiState = LoginViewModel.UiState.Error(UiText.DynamicString("Invalid username or password")),
|
||||
quickConnectUiState = LoginViewModel.QuickConnectUiState.Normal,
|
||||
onLoginClick = { _, _ -> },
|
||||
onQuickConnectClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
210
app/tv/src/main/java/dev/jdtech/jellyfin/ui/MainScreen.kt
Normal file
210
app/tv/src/main/java/dev/jdtech/jellyfin/ui/MainScreen.kt
Normal file
|
@ -0,0 +1,210 @@
|
|||
package dev.jdtech.jellyfin.ui
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Tab
|
||||
import androidx.tv.material3.TabDefaults
|
||||
import androidx.tv.material3.TabRow
|
||||
import androidx.tv.material3.TabRowDefaults
|
||||
import androidx.tv.material3.Text
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootNavGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
|
||||
import dev.jdtech.jellyfin.destinations.SettingsScreenDestination
|
||||
import dev.jdtech.jellyfin.models.User
|
||||
import dev.jdtech.jellyfin.ui.components.LoadingIndicator
|
||||
import dev.jdtech.jellyfin.ui.components.PillBorderIndicator
|
||||
import dev.jdtech.jellyfin.ui.components.ProfileButton
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyServer
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyUser
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
import dev.jdtech.jellyfin.viewmodels.MainViewModel
|
||||
import dev.jdtech.jellyfin.core.R as CoreR
|
||||
|
||||
@RootNavGraph(start = true)
|
||||
@Destination
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
mainViewModel: MainViewModel = hiltViewModel(),
|
||||
navigator: DestinationsNavigator,
|
||||
) {
|
||||
val delegatedUiState by mainViewModel.uiState.collectAsState()
|
||||
|
||||
MainScreenLayout(
|
||||
uiState = delegatedUiState,
|
||||
navigator = navigator,
|
||||
)
|
||||
}
|
||||
|
||||
enum class TabDestination(
|
||||
@DrawableRes val icon: Int,
|
||||
@StringRes val label: Int,
|
||||
) {
|
||||
Search(CoreR.drawable.ic_search, CoreR.string.search),
|
||||
Home(CoreR.drawable.ic_home, CoreR.string.title_home),
|
||||
Libraries(CoreR.drawable.ic_library, CoreR.string.libraries),
|
||||
// LiveTV(CoreR.drawable.ic_tv, CoreR.string.live_tv)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun MainScreenLayout(
|
||||
uiState: MainViewModel.UiState,
|
||||
navigator: DestinationsNavigator,
|
||||
) {
|
||||
var focusedTabIndex by rememberSaveable { mutableIntStateOf(1) }
|
||||
var activeTabIndex by rememberSaveable { mutableIntStateOf(focusedTabIndex) }
|
||||
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
|
||||
var user: User? = null
|
||||
when (uiState) {
|
||||
is MainViewModel.UiState.Normal -> {
|
||||
user = uiState.user
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(80.dp)
|
||||
.padding(horizontal = MaterialTheme.spacings.default),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = CoreR.drawable.ic_logo),
|
||||
contentDescription = null,
|
||||
tint = Color.Unspecified,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
)
|
||||
TabRow(
|
||||
selectedTabIndex = focusedTabIndex,
|
||||
indicator = { tabPositions, isActivated ->
|
||||
// FocusedTab's indicator
|
||||
PillBorderIndicator(
|
||||
currentTabPosition = tabPositions[focusedTabIndex],
|
||||
activeBorderColor = Color.White,
|
||||
inactiveBorderColor = Color.Transparent,
|
||||
doesTabRowHaveFocus = isActivated,
|
||||
)
|
||||
|
||||
// SelectedTab's indicator
|
||||
TabRowDefaults.PillIndicator(
|
||||
currentTabPosition = tabPositions[activeTabIndex],
|
||||
activeColor = Color.White,
|
||||
inactiveColor = Color.White,
|
||||
doesTabRowHaveFocus = isActivated,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
) {
|
||||
TabDestination.entries.forEachIndexed { index, tab ->
|
||||
Tab(
|
||||
selected = activeTabIndex == index,
|
||||
onFocus = { focusedTabIndex = index },
|
||||
colors = TabDefaults.pillIndicatorTabColors(
|
||||
contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f),
|
||||
selectedContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
focusedContentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f),
|
||||
focusedSelectedContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
),
|
||||
onClick = {
|
||||
focusedTabIndex = index
|
||||
activeTabIndex = index
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = MaterialTheme.spacings.default / 2, vertical = MaterialTheme.spacings.small),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = tab.icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(MaterialTheme.spacings.extraSmall))
|
||||
Text(
|
||||
text = stringResource(id = tab.label),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium),
|
||||
modifier = Modifier.align(Alignment.CenterEnd),
|
||||
) {
|
||||
if (isLoading) {
|
||||
LoadingIndicator()
|
||||
}
|
||||
ProfileButton(
|
||||
user = user,
|
||||
onClick = {
|
||||
navigator.navigate(SettingsScreenDestination())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
when (activeTabIndex) {
|
||||
1 -> {
|
||||
HomeScreen(navigator = navigator, isLoading = { isLoading = it })
|
||||
}
|
||||
2 -> {
|
||||
LibrariesScreen(navigator = navigator, isLoading = { isLoading = it })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview(widthDp = 960, heightDp = 540)
|
||||
@Composable
|
||||
private fun MainScreenLayoutPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
MainScreenLayout(
|
||||
uiState = MainViewModel.UiState.Normal(server = dummyServer, user = dummyUser),
|
||||
navigator = EmptyDestinationsNavigator,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
375
app/tv/src/main/java/dev/jdtech/jellyfin/ui/MovieScreen.kt
Normal file
375
app/tv/src/main/java/dev/jdtech/jellyfin/ui/MovieScreen.kt
Normal file
|
@ -0,0 +1,375 @@
|
|||
package dev.jdtech.jellyfin.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.LocalContentColor
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import dev.jdtech.jellyfin.destinations.PlayerActivityDestination
|
||||
import dev.jdtech.jellyfin.models.AudioChannel
|
||||
import dev.jdtech.jellyfin.models.AudioCodec
|
||||
import dev.jdtech.jellyfin.models.DisplayProfile
|
||||
import dev.jdtech.jellyfin.models.Resolution
|
||||
import dev.jdtech.jellyfin.models.VideoMetadata
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyMovie
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.Yellow
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
import dev.jdtech.jellyfin.utils.ObserveAsEvents
|
||||
import dev.jdtech.jellyfin.viewmodels.MovieViewModel
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
||||
import org.jellyfin.sdk.model.api.BaseItemPerson
|
||||
import java.util.UUID
|
||||
import dev.jdtech.jellyfin.core.R as CoreR
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
fun MovieScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
itemId: UUID,
|
||||
movieViewModel: MovieViewModel = hiltViewModel(),
|
||||
playerViewModel: PlayerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) {
|
||||
movieViewModel.loadData(itemId)
|
||||
}
|
||||
|
||||
ObserveAsEvents(playerViewModel.eventsChannelFlow) { event ->
|
||||
when (event) {
|
||||
is PlayerItemsEvent.PlayerItemsReady -> {
|
||||
navigator.navigate(PlayerActivityDestination(items = ArrayList(event.items)))
|
||||
}
|
||||
is PlayerItemsEvent.PlayerItemsError -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
val delegatedUiState by movieViewModel.uiState.collectAsState()
|
||||
|
||||
MovieScreenLayout(
|
||||
uiState = delegatedUiState,
|
||||
onPlayClick = {
|
||||
playerViewModel.loadPlayerItems(movieViewModel.item)
|
||||
},
|
||||
onTrailerClick = { trailerUri ->
|
||||
try {
|
||||
Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse(trailerUri),
|
||||
).also {
|
||||
context.startActivity(it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, e.localizedMessage, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onPlayedClick = {
|
||||
movieViewModel.togglePlayed()
|
||||
},
|
||||
onFavoriteClick = {
|
||||
movieViewModel.toggleFavorite()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun MovieScreenLayout(
|
||||
uiState: MovieViewModel.UiState,
|
||||
onPlayClick: () -> Unit,
|
||||
onTrailerClick: (String) -> Unit,
|
||||
onPlayedClick: () -> Unit,
|
||||
onFavoriteClick: () -> Unit,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
when (uiState) {
|
||||
is MovieViewModel.UiState.Loading -> Text(text = "LOADING")
|
||||
is MovieViewModel.UiState.Normal -> {
|
||||
val item = uiState.item
|
||||
var size by remember {
|
||||
mutableStateOf(Size.Zero)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
size = coordinates.size.toSize()
|
||||
},
|
||||
) {
|
||||
AsyncImage(
|
||||
model = item.images.backdrop,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
)
|
||||
if (size != Size.Zero) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
listOf(Color.Black.copy(alpha = .2f), Color.Black),
|
||||
center = Offset(size.width, 0f),
|
||||
radius = size.width * .8f,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = MaterialTheme.spacings.default * 2, end = MaterialTheme.spacings.default * 2),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(112.dp))
|
||||
Text(
|
||||
text = item.name,
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
)
|
||||
if (item.originalTitle != item.name) {
|
||||
item.originalTitle?.let { originalTitle ->
|
||||
Text(
|
||||
text = originalTitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.small))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.small),
|
||||
) {
|
||||
Text(
|
||||
text = uiState.dateString,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Text(
|
||||
text = uiState.runTime,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
item.officialRating?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
item.communityRating?.let {
|
||||
Row {
|
||||
Icon(
|
||||
painter = painterResource(id = CoreR.drawable.ic_star),
|
||||
contentDescription = null,
|
||||
tint = Yellow,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(MaterialTheme.spacings.extraSmall))
|
||||
Text(
|
||||
text = String.format("%.1f", item.communityRating),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
|
||||
Text(
|
||||
text = item.overview,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.width(640.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.default))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium),
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
onPlayClick()
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = CoreR.drawable.ic_play),
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = stringResource(id = CoreR.string.play))
|
||||
}
|
||||
item.trailer?.let { trailerUri ->
|
||||
Button(
|
||||
onClick = {
|
||||
onTrailerClick(trailerUri)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = CoreR.drawable.ic_film),
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = stringResource(id = CoreR.string.watch_trailer))
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
onPlayedClick()
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = CoreR.drawable.ic_check),
|
||||
contentDescription = null,
|
||||
tint = if (item.played) Color.Red else LocalContentColor.current,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = stringResource(id = if (item.played) CoreR.string.unmark_as_played else CoreR.string.mark_as_played))
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
onFavoriteClick()
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = if (item.favorite) CoreR.drawable.ic_heart_filled else CoreR.drawable.ic_heart),
|
||||
contentDescription = null,
|
||||
tint = if (item.favorite) Color.Red else LocalContentColor.current,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = stringResource(id = if (item.favorite) CoreR.string.remove_from_favorites else CoreR.string.add_to_favorites))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.default))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.genres),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = .5f),
|
||||
)
|
||||
Text(
|
||||
text = uiState.genresString,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
uiState.director?.let { director ->
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.director),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = .5f),
|
||||
)
|
||||
Text(
|
||||
text = director.name ?: "Unknown",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.writers),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = .5f),
|
||||
)
|
||||
Text(
|
||||
text = uiState.writersString,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
|
||||
// Text(
|
||||
// text = stringResource(id = CoreR.string.cast_amp_crew),
|
||||
// style = MaterialTheme.typography.headlineMedium,
|
||||
// )
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(true) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
is MovieViewModel.UiState.Error -> Text(text = uiState.error.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview(widthDp = 960, heightDp = 540)
|
||||
@Composable
|
||||
private fun MovieScreenLayoutPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
MovieScreenLayout(
|
||||
uiState = MovieViewModel.UiState.Normal(
|
||||
item = dummyMovie,
|
||||
actors = emptyList(),
|
||||
director = BaseItemPerson(
|
||||
id = UUID.randomUUID(),
|
||||
name = "Robert Rodriguez",
|
||||
),
|
||||
writers = emptyList(),
|
||||
videoMetadata = VideoMetadata(
|
||||
resolution = listOf(Resolution.UHD),
|
||||
displayProfiles = listOf(DisplayProfile.HDR10),
|
||||
audioChannels = listOf(AudioChannel.CH_5_1),
|
||||
audioCodecs = listOf(AudioCodec.EAC3),
|
||||
isAtmos = listOf(false),
|
||||
),
|
||||
writersString = "James Cameron, Laeta Kalogridis, Yukito Kishiro",
|
||||
genresString = "Action, Science Fiction, Adventure",
|
||||
videoString = "",
|
||||
audioString = "",
|
||||
subtitleString = "",
|
||||
runTime = "121 min",
|
||||
dateString = "2019",
|
||||
),
|
||||
onPlayClick = {},
|
||||
onTrailerClick = {},
|
||||
onPlayedClick = {},
|
||||
onFavoriteClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
297
app/tv/src/main/java/dev/jdtech/jellyfin/ui/PlayerScreen.kt
Normal file
297
app/tv/src/main/java/dev/jdtech/jellyfin/ui/PlayerScreen.kt
Normal file
|
@ -0,0 +1,297 @@
|
|||
package dev.jdtech.jellyfin.ui
|
||||
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.ramcosta.composedestinations.result.NavResult
|
||||
import com.ramcosta.composedestinations.result.ResultRecipient
|
||||
import dev.jdtech.jellyfin.core.R
|
||||
import dev.jdtech.jellyfin.destinations.VideoPlayerTrackSelectorDialogDestination
|
||||
import dev.jdtech.jellyfin.models.PlayerItem
|
||||
import dev.jdtech.jellyfin.models.Track
|
||||
import dev.jdtech.jellyfin.ui.components.player.VideoPlayerControlsLayout
|
||||
import dev.jdtech.jellyfin.ui.components.player.VideoPlayerMediaButton
|
||||
import dev.jdtech.jellyfin.ui.components.player.VideoPlayerMediaTitle
|
||||
import dev.jdtech.jellyfin.ui.components.player.VideoPlayerOverlay
|
||||
import dev.jdtech.jellyfin.ui.components.player.VideoPlayerSeeker
|
||||
import dev.jdtech.jellyfin.ui.components.player.VideoPlayerState
|
||||
import dev.jdtech.jellyfin.ui.components.player.rememberVideoPlayerState
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
import dev.jdtech.jellyfin.utils.handleDPadKeyEvents
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
fun PlayerScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
items: ArrayList<PlayerItem>,
|
||||
resultRecipient: ResultRecipient<VideoPlayerTrackSelectorDialogDestination, Int>,
|
||||
) {
|
||||
val viewModel = hiltViewModel<PlayerActivityViewModel>()
|
||||
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
var lifecycle by remember {
|
||||
mutableStateOf(Lifecycle.Event.ON_CREATE)
|
||||
}
|
||||
var mediaSession by remember {
|
||||
mutableStateOf<MediaSession?>(null)
|
||||
}
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
lifecycle = event
|
||||
|
||||
// Handle creation and release of media session
|
||||
when (lifecycle) {
|
||||
Lifecycle.Event.ON_STOP -> {
|
||||
println("ON_STOP")
|
||||
mediaSession?.release()
|
||||
}
|
||||
|
||||
Lifecycle.Event.ON_START -> {
|
||||
println("ON_START")
|
||||
mediaSession = MediaSession.Builder(context, viewModel.player).build()
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
val videoPlayerState = rememberVideoPlayerState()
|
||||
|
||||
var currentPosition by remember {
|
||||
mutableLongStateOf(0L)
|
||||
}
|
||||
var isPlaying by remember {
|
||||
mutableStateOf(viewModel.player.isPlaying)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(300)
|
||||
currentPosition = viewModel.player.currentPosition
|
||||
isPlaying = viewModel.player.isPlaying
|
||||
}
|
||||
}
|
||||
|
||||
resultRecipient.onNavResult { result ->
|
||||
when (result) {
|
||||
is NavResult.Canceled -> Unit
|
||||
is NavResult.Value -> {
|
||||
viewModel.player.trackSelectionParameters = viewModel.player.trackSelectionParameters
|
||||
.buildUpon()
|
||||
.setOverrideForType(
|
||||
TrackSelectionOverride(viewModel.player.currentTracks.groups[result.value].mediaTrackGroup, 0),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.dPadEvents(
|
||||
exoPlayer = viewModel.player,
|
||||
videoPlayerState = videoPlayerState,
|
||||
)
|
||||
.focusable(),
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
PlayerView(context).also { playerView ->
|
||||
playerView.player = viewModel.player
|
||||
playerView.useController = false
|
||||
viewModel.initializePlayer(items.toTypedArray())
|
||||
playerView.setBackgroundColor(
|
||||
context.resources.getColor(
|
||||
android.R.color.black,
|
||||
context.theme,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
update = {
|
||||
when (lifecycle) {
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
it.onPause()
|
||||
it.player?.pause()
|
||||
}
|
||||
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
it.onResume()
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
)
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
VideoPlayerOverlay(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
focusRequester = focusRequester,
|
||||
state = videoPlayerState,
|
||||
isPlaying = isPlaying,
|
||||
controls = {
|
||||
VideoPlayerControls(
|
||||
title = uiState.currentItemTitle,
|
||||
isPlaying = isPlaying,
|
||||
contentCurrentPosition = currentPosition,
|
||||
player = viewModel.player,
|
||||
state = videoPlayerState,
|
||||
focusRequester = focusRequester,
|
||||
navigator = navigator,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(UnstableApi::class)
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun VideoPlayerControls(
|
||||
title: String,
|
||||
isPlaying: Boolean,
|
||||
contentCurrentPosition: Long,
|
||||
player: Player,
|
||||
state: VideoPlayerState,
|
||||
focusRequester: FocusRequester,
|
||||
navigator: DestinationsNavigator,
|
||||
) {
|
||||
val onPlayPauseToggle = { shouldPlay: Boolean ->
|
||||
if (shouldPlay) {
|
||||
player.play()
|
||||
} else {
|
||||
player.pause()
|
||||
}
|
||||
}
|
||||
|
||||
VideoPlayerControlsLayout(
|
||||
mediaTitle = {
|
||||
VideoPlayerMediaTitle(
|
||||
title = title,
|
||||
subtitle = null,
|
||||
)
|
||||
},
|
||||
seeker = {
|
||||
VideoPlayerSeeker(
|
||||
focusRequester = focusRequester,
|
||||
state = state,
|
||||
isPlaying = isPlaying,
|
||||
onPlayPauseToggle = onPlayPauseToggle,
|
||||
onSeek = { player.seekTo(player.duration.times(it).toLong()) },
|
||||
contentProgress = contentCurrentPosition.milliseconds,
|
||||
contentDuration = player.duration.milliseconds,
|
||||
)
|
||||
},
|
||||
mediaActions = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium),
|
||||
) {
|
||||
VideoPlayerMediaButton(
|
||||
icon = painterResource(id = R.drawable.ic_speaker),
|
||||
state = state,
|
||||
isPlaying = isPlaying,
|
||||
onClick = {
|
||||
val tracks = getTracks(player, C.TRACK_TYPE_AUDIO)
|
||||
navigator.navigate(VideoPlayerTrackSelectorDialogDestination(tracks))
|
||||
},
|
||||
)
|
||||
VideoPlayerMediaButton(
|
||||
icon = painterResource(id = R.drawable.ic_closed_caption),
|
||||
state = state,
|
||||
isPlaying = isPlaying,
|
||||
onClick = {
|
||||
val tracks = getTracks(player, C.TRACK_TYPE_TEXT)
|
||||
navigator.navigate(VideoPlayerTrackSelectorDialogDestination(tracks))
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun Modifier.dPadEvents(
|
||||
exoPlayer: Player,
|
||||
videoPlayerState: VideoPlayerState,
|
||||
): Modifier = this.handleDPadKeyEvents(
|
||||
onLeft = {},
|
||||
onRight = {},
|
||||
onUp = {},
|
||||
onDown = {},
|
||||
onEnter = {
|
||||
exoPlayer.pause()
|
||||
videoPlayerState.showControls()
|
||||
},
|
||||
)
|
||||
|
||||
@androidx.annotation.OptIn(UnstableApi::class)
|
||||
private fun getTracks(player: Player, type: Int): ArrayList<Track> {
|
||||
val tracks = arrayListOf<Track>()
|
||||
for (groupIndex in 0 until player.currentTracks.groups.count()) {
|
||||
val group = player.currentTracks.groups[groupIndex]
|
||||
if (group.type == type) {
|
||||
val format = group.mediaTrackGroup.getFormat(0)
|
||||
|
||||
val label = format.label
|
||||
val language = Locale(format.language.toString()).displayLanguage
|
||||
val codec = format.codecs
|
||||
val selected = group.isSelected
|
||||
|
||||
val track = Track(
|
||||
id = groupIndex,
|
||||
label = label,
|
||||
language = language,
|
||||
codec = codec,
|
||||
selected = selected,
|
||||
)
|
||||
|
||||
tracks.add(track)
|
||||
}
|
||||
}
|
||||
return tracks
|
||||
}
|
170
app/tv/src/main/java/dev/jdtech/jellyfin/ui/SeasonScreen.kt
Normal file
170
app/tv/src/main/java/dev/jdtech/jellyfin/ui/SeasonScreen.kt
Normal file
|
@ -0,0 +1,170 @@
|
|||
package dev.jdtech.jellyfin.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.tv.foundation.lazy.list.TvLazyColumn
|
||||
import androidx.tv.foundation.lazy.list.items
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import dev.jdtech.jellyfin.destinations.PlayerActivityDestination
|
||||
import dev.jdtech.jellyfin.models.EpisodeItem
|
||||
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||
import dev.jdtech.jellyfin.ui.components.EpisodeCard
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyEpisodeItems
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
import dev.jdtech.jellyfin.utils.ObserveAsEvents
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
||||
import dev.jdtech.jellyfin.viewmodels.SeasonViewModel
|
||||
import java.util.UUID
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
fun SeasonScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
seriesId: UUID,
|
||||
seasonId: UUID,
|
||||
seriesName: String,
|
||||
seasonName: String,
|
||||
seasonViewModel: SeasonViewModel = hiltViewModel(),
|
||||
playerViewModel: PlayerViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(true) {
|
||||
seasonViewModel.loadEpisodes(
|
||||
seriesId = seriesId,
|
||||
seasonId = seasonId,
|
||||
offline = false,
|
||||
)
|
||||
}
|
||||
|
||||
ObserveAsEvents(playerViewModel.eventsChannelFlow) { event ->
|
||||
when (event) {
|
||||
is PlayerItemsEvent.PlayerItemsReady -> {
|
||||
navigator.navigate(PlayerActivityDestination(items = ArrayList(event.items)))
|
||||
}
|
||||
is PlayerItemsEvent.PlayerItemsError -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
val delegatedUiState by seasonViewModel.uiState.collectAsState()
|
||||
|
||||
SeasonScreenLayout(
|
||||
seriesName = seriesName,
|
||||
seasonName = seasonName,
|
||||
uiState = delegatedUiState,
|
||||
onClick = { episode ->
|
||||
playerViewModel.loadPlayerItems(item = episode)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SeasonScreenLayout(
|
||||
seriesName: String,
|
||||
seasonName: String,
|
||||
uiState: SeasonViewModel.UiState,
|
||||
onClick: (FindroidEpisode) -> Unit,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
when (uiState) {
|
||||
is SeasonViewModel.UiState.Loading -> Text(text = "LOADING")
|
||||
is SeasonViewModel.UiState.Normal -> {
|
||||
val episodes = uiState.episodes
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(
|
||||
start = MaterialTheme.spacings.extraLarge,
|
||||
top = MaterialTheme.spacings.large,
|
||||
end = MaterialTheme.spacings.large,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = seasonName,
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
)
|
||||
Text(
|
||||
text = seriesName,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
}
|
||||
TvLazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = MaterialTheme.spacings.large,
|
||||
bottom = MaterialTheme.spacings.large,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium),
|
||||
modifier = Modifier
|
||||
.weight(2f)
|
||||
.padding(end = MaterialTheme.spacings.extraLarge)
|
||||
.focusRequester(focusRequester),
|
||||
) {
|
||||
items(episodes) { episodeItem ->
|
||||
when (episodeItem) {
|
||||
is EpisodeItem.Episode -> {
|
||||
EpisodeCard(episode = episodeItem.episode, onClick = { onClick(episodeItem.episode) })
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(true) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is SeasonViewModel.UiState.Error -> Text(text = uiState.error.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview(widthDp = 960, heightDp = 540)
|
||||
@Composable
|
||||
private fun SeasonScreenLayoutPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
SeasonScreenLayout(
|
||||
seriesName = "86 EIGHTY-SIX",
|
||||
seasonName = "Season 1",
|
||||
uiState = SeasonViewModel.UiState.Normal(dummyEpisodeItems),
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,350 @@
|
|||
package dev.jdtech.jellyfin.ui
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.tv.foundation.lazy.list.TvLazyRow
|
||||
import androidx.tv.foundation.lazy.list.items
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.OutlinedButton
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.ramcosta.composedestinations.navigation.popUpTo
|
||||
import dev.jdtech.jellyfin.NavGraphs
|
||||
import dev.jdtech.jellyfin.destinations.AddServerScreenDestination
|
||||
import dev.jdtech.jellyfin.destinations.MainScreenDestination
|
||||
import dev.jdtech.jellyfin.destinations.UserSelectScreenDestination
|
||||
import dev.jdtech.jellyfin.models.DiscoveredServer
|
||||
import dev.jdtech.jellyfin.models.Server
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyDiscoveredServer
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyDiscoveredServers
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyServers
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
import dev.jdtech.jellyfin.utils.ObserveAsEvents
|
||||
import dev.jdtech.jellyfin.viewmodels.ServerSelectEvent
|
||||
import dev.jdtech.jellyfin.viewmodels.ServerSelectViewModel
|
||||
import dev.jdtech.jellyfin.core.R as CoreR
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
fun ServerSelectScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
serverSelectViewModel: ServerSelectViewModel = hiltViewModel(),
|
||||
) {
|
||||
val delegatedUiState by serverSelectViewModel.uiState.collectAsState()
|
||||
val delegatedDiscoveredServersState by serverSelectViewModel.discoveredServersState.collectAsState()
|
||||
|
||||
ObserveAsEvents(serverSelectViewModel.eventsChannelFlow) { event ->
|
||||
when (event) {
|
||||
ServerSelectEvent.NavigateToLogin -> {
|
||||
navigator.navigate(UserSelectScreenDestination)
|
||||
}
|
||||
ServerSelectEvent.NavigateToHome -> {
|
||||
navigator.navigate(MainScreenDestination) {
|
||||
popUpTo(NavGraphs.root) {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ServerSelectScreenLayout(
|
||||
uiState = delegatedUiState,
|
||||
discoveredServersState = delegatedDiscoveredServersState,
|
||||
onServerClick = { server ->
|
||||
serverSelectViewModel.connectToServer(
|
||||
Server(
|
||||
id = server.id,
|
||||
name = server.name,
|
||||
currentUserId = null,
|
||||
currentServerAddressId = null,
|
||||
),
|
||||
)
|
||||
},
|
||||
onAddServerClick = {
|
||||
navigator.navigate(AddServerScreenDestination)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ServerSelectScreenLayout(
|
||||
uiState: ServerSelectViewModel.UiState,
|
||||
discoveredServersState: ServerSelectViewModel.DiscoveredServersState,
|
||||
onServerClick: (DiscoveredServer) -> Unit,
|
||||
onAddServerClick: () -> Unit,
|
||||
) {
|
||||
var servers = emptyList<DiscoveredServer>()
|
||||
var discoveredServers = emptyList<DiscoveredServer>()
|
||||
|
||||
when (uiState) {
|
||||
is ServerSelectViewModel.UiState.Normal -> {
|
||||
servers =
|
||||
uiState.servers.map { DiscoveredServer(id = it.id, name = it.name, address = "") }
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
when (discoveredServersState) {
|
||||
is ServerSelectViewModel.DiscoveredServersState.Servers -> {
|
||||
discoveredServers = discoveredServersState.servers
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.Center),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.select_server),
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
)
|
||||
if (discoveredServers.isNotEmpty()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = CoreR.drawable.ic_sparkles),
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFBDBDBD),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(MaterialTheme.spacings.extraSmall))
|
||||
Text(
|
||||
text = pluralStringResource(
|
||||
id = CoreR.plurals.discovered_servers,
|
||||
count = discoveredServers.count(),
|
||||
discoveredServers.count(),
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color(0xFFBDBDBD),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
|
||||
if (servers.isEmpty() && discoveredServers.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.no_servers_found),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
} else {
|
||||
TvLazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large),
|
||||
contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.default),
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
) {
|
||||
items(servers) { server ->
|
||||
ServerComponent(server) { onServerClick(it) }
|
||||
}
|
||||
items(discoveredServers) {
|
||||
ServerComponent(it, discovered = true)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(true) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
|
||||
OutlinedButton(
|
||||
onClick = { onAddServerClick() },
|
||||
) {
|
||||
Text(text = stringResource(id = CoreR.string.add_server))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview(widthDp = 960, heightDp = 540)
|
||||
@Composable
|
||||
private fun ServerSelectScreenLayoutPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
ServerSelectScreenLayout(
|
||||
uiState = ServerSelectViewModel.UiState.Normal(dummyServers),
|
||||
discoveredServersState = ServerSelectViewModel.DiscoveredServersState.Servers(
|
||||
dummyDiscoveredServers,
|
||||
),
|
||||
onServerClick = {},
|
||||
onAddServerClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview(widthDp = 960, heightDp = 540)
|
||||
@Composable
|
||||
private fun ServerSelectScreenLayoutPreviewNoDiscovered() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
ServerSelectScreenLayout(
|
||||
uiState = ServerSelectViewModel.UiState.Normal(dummyServers),
|
||||
discoveredServersState = ServerSelectViewModel.DiscoveredServersState.Servers(
|
||||
emptyList(),
|
||||
),
|
||||
onServerClick = {},
|
||||
onAddServerClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview(widthDp = 960, heightDp = 540)
|
||||
@Composable
|
||||
private fun ServerSelectScreenLayoutPreviewNoServers() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
ServerSelectScreenLayout(
|
||||
uiState = ServerSelectViewModel.UiState.Normal(emptyList()),
|
||||
discoveredServersState = ServerSelectViewModel.DiscoveredServersState.Servers(
|
||||
emptyList(),
|
||||
),
|
||||
onServerClick = {},
|
||||
onAddServerClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ServerComponent(
|
||||
server: DiscoveredServer,
|
||||
discovered: Boolean = false,
|
||||
onClick: (DiscoveredServer) -> Unit = {},
|
||||
) {
|
||||
Surface(
|
||||
onClick = {
|
||||
onClick(server)
|
||||
},
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color(0xFF132026),
|
||||
focusedContainerColor = Color(0xFF132026),
|
||||
),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(16.dp)),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
BorderStroke(
|
||||
4.dp,
|
||||
Color.White,
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
),
|
||||
),
|
||||
modifier = Modifier
|
||||
.width(270.dp)
|
||||
.height(115.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (discovered) {
|
||||
Icon(
|
||||
painter = painterResource(id = CoreR.drawable.ic_sparkles),
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(start = MaterialTheme.spacings.default / 2, top = MaterialTheme.spacings.default / 2),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.align(Alignment.Center)
|
||||
.padding(
|
||||
vertical = MaterialTheme.spacings.default,
|
||||
horizontal = MaterialTheme.spacings.medium,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = server.name,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = Color.White,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
Text(
|
||||
text = server.address,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFFBDBDBD),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ServerComponentPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
ServerComponent(dummyDiscoveredServer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ServerComponentPreviewDiscovered() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
ServerComponent(
|
||||
server = dummyDiscoveredServer,
|
||||
discovered = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
169
app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsScreen.kt
Normal file
169
app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsScreen.kt
Normal file
|
@ -0,0 +1,169 @@
|
|||
package dev.jdtech.jellyfin.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.tv.foundation.lazy.grid.TvGridCells
|
||||
import androidx.tv.foundation.lazy.grid.TvGridItemSpan
|
||||
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
||||
import androidx.tv.foundation.lazy.grid.items
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import dev.jdtech.jellyfin.destinations.ServerSelectScreenDestination
|
||||
import dev.jdtech.jellyfin.destinations.SettingsSubScreenDestination
|
||||
import dev.jdtech.jellyfin.destinations.UserSelectScreenDestination
|
||||
import dev.jdtech.jellyfin.models.Preference
|
||||
import dev.jdtech.jellyfin.models.PreferenceCategory
|
||||
import dev.jdtech.jellyfin.models.PreferenceSelect
|
||||
import dev.jdtech.jellyfin.models.PreferenceSwitch
|
||||
import dev.jdtech.jellyfin.ui.components.SettingsCategoryCard
|
||||
import dev.jdtech.jellyfin.ui.components.SettingsSelectCard
|
||||
import dev.jdtech.jellyfin.ui.components.SettingsSwitchCard
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
import dev.jdtech.jellyfin.utils.ObserveAsEvents
|
||||
import dev.jdtech.jellyfin.viewmodels.SettingsEvent
|
||||
import dev.jdtech.jellyfin.viewmodels.SettingsViewModel
|
||||
import dev.jdtech.jellyfin.core.R as CoreR
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
settingsViewModel: SettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(true) {
|
||||
settingsViewModel.loadPreferences(intArrayOf())
|
||||
}
|
||||
|
||||
ObserveAsEvents(settingsViewModel.eventsChannelFlow) { event ->
|
||||
when (event) {
|
||||
is SettingsEvent.NavigateToSettings -> {
|
||||
navigator.navigate(SettingsSubScreenDestination(event.indexes, event.title))
|
||||
}
|
||||
is SettingsEvent.NavigateToUsers -> {
|
||||
navigator.navigate(UserSelectScreenDestination)
|
||||
}
|
||||
is SettingsEvent.NavigateToServers -> {
|
||||
navigator.navigate(ServerSelectScreenDestination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val delegatedUiState by settingsViewModel.uiState.collectAsState()
|
||||
|
||||
SettingsScreenLayout(delegatedUiState) { preference ->
|
||||
when (preference) {
|
||||
is PreferenceSwitch -> {
|
||||
settingsViewModel.setBoolean(preference.backendName, preference.value)
|
||||
}
|
||||
is PreferenceSelect -> {
|
||||
settingsViewModel.setString(preference.backendName, preference.value)
|
||||
}
|
||||
}
|
||||
settingsViewModel.loadPreferences(intArrayOf())
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SettingsScreenLayout(
|
||||
uiState: SettingsViewModel.UiState,
|
||||
onUpdate: (Preference) -> Unit,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
when (uiState) {
|
||||
is SettingsViewModel.UiState.Normal -> {
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Fixed(3),
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
|
||||
contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.default * 2, vertical = MaterialTheme.spacings.large),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721))))
|
||||
.focusRequester(focusRequester),
|
||||
) {
|
||||
item(span = { TvGridItemSpan(this.maxLineSpan) }) {
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.title_settings),
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
)
|
||||
}
|
||||
items(uiState.preferences) { preference ->
|
||||
when (preference) {
|
||||
is PreferenceCategory -> SettingsCategoryCard(preference = preference)
|
||||
is PreferenceSwitch -> {
|
||||
SettingsSwitchCard(preference = preference) {
|
||||
onUpdate(preference.copy(value = !preference.value))
|
||||
}
|
||||
}
|
||||
is PreferenceSelect -> {
|
||||
val options = stringArrayResource(id = preference.options)
|
||||
SettingsSelectCard(preference = preference) {
|
||||
val currentIndex = options.indexOf(preference.value)
|
||||
val newIndex = if (currentIndex == options.count() - 1) {
|
||||
0
|
||||
} else {
|
||||
currentIndex + 1
|
||||
}
|
||||
onUpdate(preference.copy(value = options[newIndex]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(true) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
is SettingsViewModel.UiState.Loading -> {
|
||||
Text(text = "LOADING")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview(widthDp = 960, heightDp = 540)
|
||||
@Composable
|
||||
private fun SettingsScreenLayoutPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
SettingsScreenLayout(
|
||||
uiState = SettingsViewModel.UiState.Normal(
|
||||
listOf(
|
||||
PreferenceCategory(
|
||||
nameStringResource = CoreR.string.settings_category_language,
|
||||
iconDrawableId = CoreR.drawable.ic_languages,
|
||||
),
|
||||
PreferenceCategory(
|
||||
nameStringResource = CoreR.string.settings_category_appearance,
|
||||
iconDrawableId = CoreR.drawable.ic_palette,
|
||||
),
|
||||
),
|
||||
),
|
||||
onUpdate = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
249
app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsSubScreen.kt
Normal file
249
app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsSubScreen.kt
Normal file
|
@ -0,0 +1,249 @@
|
|||
package dev.jdtech.jellyfin.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.tv.foundation.lazy.list.TvLazyColumn
|
||||
import androidx.tv.foundation.lazy.list.items
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import dev.jdtech.jellyfin.Constants
|
||||
import dev.jdtech.jellyfin.destinations.ServerSelectScreenDestination
|
||||
import dev.jdtech.jellyfin.destinations.SettingsScreenDestination
|
||||
import dev.jdtech.jellyfin.destinations.UserSelectScreenDestination
|
||||
import dev.jdtech.jellyfin.models.Preference
|
||||
import dev.jdtech.jellyfin.models.PreferenceCategory
|
||||
import dev.jdtech.jellyfin.models.PreferenceSelect
|
||||
import dev.jdtech.jellyfin.models.PreferenceSwitch
|
||||
import dev.jdtech.jellyfin.ui.components.SettingsCategoryCard
|
||||
import dev.jdtech.jellyfin.ui.components.SettingsDetailsCard
|
||||
import dev.jdtech.jellyfin.ui.components.SettingsSelectCard
|
||||
import dev.jdtech.jellyfin.ui.components.SettingsSwitchCard
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
import dev.jdtech.jellyfin.utils.ObserveAsEvents
|
||||
import dev.jdtech.jellyfin.viewmodels.SettingsEvent
|
||||
import dev.jdtech.jellyfin.viewmodels.SettingsViewModel
|
||||
import dev.jdtech.jellyfin.core.R as CoreR
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
fun SettingsSubScreen(
|
||||
indexes: IntArray = intArrayOf(),
|
||||
@StringRes title: Int,
|
||||
navigator: DestinationsNavigator,
|
||||
settingsViewModel: SettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(true) {
|
||||
settingsViewModel.loadPreferences(indexes)
|
||||
}
|
||||
|
||||
ObserveAsEvents(settingsViewModel.eventsChannelFlow) { event ->
|
||||
when (event) {
|
||||
is SettingsEvent.NavigateToSettings -> {
|
||||
navigator.navigate(SettingsScreenDestination)
|
||||
}
|
||||
is SettingsEvent.NavigateToUsers -> {
|
||||
navigator.navigate(UserSelectScreenDestination)
|
||||
}
|
||||
is SettingsEvent.NavigateToServers -> {
|
||||
navigator.navigate(ServerSelectScreenDestination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val delegatedUiState by settingsViewModel.uiState.collectAsState()
|
||||
|
||||
SettingsSubScreenLayout(delegatedUiState, title) { preference ->
|
||||
when (preference) {
|
||||
is PreferenceSwitch -> {
|
||||
settingsViewModel.setBoolean(preference.backendName, preference.value)
|
||||
}
|
||||
is PreferenceSelect -> {
|
||||
settingsViewModel.setString(preference.backendName, preference.value)
|
||||
}
|
||||
}
|
||||
settingsViewModel.loadPreferences(indexes)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SettingsSubScreenLayout(
|
||||
uiState: SettingsViewModel.UiState,
|
||||
@StringRes title: Int? = null,
|
||||
onUpdate: (Preference) -> Unit,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
when (uiState) {
|
||||
is SettingsViewModel.UiState.Normal -> {
|
||||
var focusedPreference by remember {
|
||||
mutableStateOf(uiState.preferences.first())
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721))))
|
||||
.padding(
|
||||
start = MaterialTheme.spacings.large,
|
||||
top = MaterialTheme.spacings.default * 2,
|
||||
end = MaterialTheme.spacings.large,
|
||||
),
|
||||
) {
|
||||
if (title != null) {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(id = title),
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.title_settings),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.title_settings),
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large),
|
||||
) {
|
||||
TvLazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
|
||||
contentPadding = PaddingValues(vertical = MaterialTheme.spacings.large),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.focusRequester(focusRequester),
|
||||
) {
|
||||
items(uiState.preferences) { preference ->
|
||||
when (preference) {
|
||||
is PreferenceCategory -> SettingsCategoryCard(
|
||||
preference = preference,
|
||||
modifier = Modifier.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
focusedPreference = preference
|
||||
}
|
||||
},
|
||||
)
|
||||
is PreferenceSwitch -> {
|
||||
SettingsSwitchCard(
|
||||
preference = preference,
|
||||
modifier = Modifier.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
focusedPreference = preference
|
||||
}
|
||||
},
|
||||
) {
|
||||
onUpdate(preference.copy(value = !preference.value))
|
||||
}
|
||||
}
|
||||
is PreferenceSelect -> {
|
||||
val optionValues = stringArrayResource(id = preference.optionValues)
|
||||
SettingsSelectCard(
|
||||
preference = preference,
|
||||
modifier = Modifier.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
focusedPreference = preference
|
||||
}
|
||||
},
|
||||
) {
|
||||
val currentIndex = optionValues.indexOf(preference.value)
|
||||
val newIndex = if (currentIndex == optionValues.count() - 1) {
|
||||
0
|
||||
} else {
|
||||
currentIndex + 1
|
||||
}
|
||||
val newPreference = preference.copy(value = optionValues[newIndex])
|
||||
onUpdate(newPreference)
|
||||
if (focusedPreference == preference) {
|
||||
focusedPreference = newPreference
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.weight(2f),
|
||||
) {
|
||||
(focusedPreference as? PreferenceSelect)?.let {
|
||||
SettingsDetailsCard(
|
||||
preference = it,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = MaterialTheme.spacings.large),
|
||||
onOptionSelected = { value ->
|
||||
println(value)
|
||||
val newPreference = it.copy(value = value)
|
||||
onUpdate(newPreference)
|
||||
focusedPreference = newPreference
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(true) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
is SettingsViewModel.UiState.Loading -> {
|
||||
Text(text = "LOADING")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview(widthDp = 960, heightDp = 540)
|
||||
@Composable
|
||||
private fun SettingsSubScreenLayoutPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
SettingsSubScreenLayout(
|
||||
uiState = SettingsViewModel.UiState.Normal(
|
||||
listOf(
|
||||
PreferenceSelect(
|
||||
nameStringResource = CoreR.string.pref_player_mpv_hwdec,
|
||||
backendName = Constants.PREF_PLAYER_MPV_HWDEC,
|
||||
backendDefaultValue = "mediacodec",
|
||||
options = CoreR.array.mpv_hwdec,
|
||||
optionValues = CoreR.array.mpv_hwdec,
|
||||
),
|
||||
),
|
||||
),
|
||||
title = CoreR.string.settings_category_player,
|
||||
onUpdate = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
424
app/tv/src/main/java/dev/jdtech/jellyfin/ui/ShowScreen.kt
Normal file
424
app/tv/src/main/java/dev/jdtech/jellyfin/ui/ShowScreen.kt
Normal file
|
@ -0,0 +1,424 @@
|
|||
package dev.jdtech.jellyfin.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.view.KeyEvent
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.nativeKeyCode
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.tv.foundation.lazy.list.TvLazyColumn
|
||||
import androidx.tv.foundation.lazy.list.TvLazyRow
|
||||
import androidx.tv.foundation.lazy.list.items
|
||||
import androidx.tv.foundation.lazy.list.rememberTvLazyListState
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.LocalContentColor
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import dev.jdtech.jellyfin.destinations.PlayerActivityDestination
|
||||
import dev.jdtech.jellyfin.destinations.SeasonScreenDestination
|
||||
import dev.jdtech.jellyfin.models.FindroidSeason
|
||||
import dev.jdtech.jellyfin.ui.components.Direction
|
||||
import dev.jdtech.jellyfin.ui.components.ItemCard
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyShow
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.Yellow
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
import dev.jdtech.jellyfin.utils.ObserveAsEvents
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent
|
||||
import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
|
||||
import dev.jdtech.jellyfin.viewmodels.ShowViewModel
|
||||
import java.util.UUID
|
||||
import dev.jdtech.jellyfin.core.R as CoreR
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
fun ShowScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
itemId: UUID,
|
||||
showViewModel: ShowViewModel = hiltViewModel(),
|
||||
playerViewModel: PlayerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(key1 = true) {
|
||||
showViewModel.loadData(itemId, false)
|
||||
}
|
||||
|
||||
ObserveAsEvents(playerViewModel.eventsChannelFlow) { event ->
|
||||
when (event) {
|
||||
is PlayerItemsEvent.PlayerItemsReady -> {
|
||||
navigator.navigate(PlayerActivityDestination(items = ArrayList(event.items)))
|
||||
}
|
||||
is PlayerItemsEvent.PlayerItemsError -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
val delegatedUiState by showViewModel.uiState.collectAsState()
|
||||
|
||||
ShowScreenLayout(
|
||||
uiState = delegatedUiState,
|
||||
onPlayClick = {
|
||||
playerViewModel.loadPlayerItems(showViewModel.item)
|
||||
},
|
||||
onTrailerClick = { trailerUri ->
|
||||
try {
|
||||
Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse(trailerUri),
|
||||
).also {
|
||||
context.startActivity(it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, e.localizedMessage, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onPlayedClick = {
|
||||
showViewModel.togglePlayed()
|
||||
},
|
||||
onFavoriteClick = {
|
||||
showViewModel.toggleFavorite()
|
||||
},
|
||||
onSeasonClick = { season ->
|
||||
navigator.navigate(SeasonScreenDestination(seriesId = season.seriesId, seasonId = season.id, seriesName = season.seriesName, seasonName = season.name))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ShowScreenLayout(
|
||||
uiState: ShowViewModel.UiState,
|
||||
onPlayClick: () -> Unit,
|
||||
onTrailerClick: (String) -> Unit,
|
||||
onPlayedClick: () -> Unit,
|
||||
onFavoriteClick: () -> Unit,
|
||||
onSeasonClick: (FindroidSeason) -> Unit,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
val listState = rememberTvLazyListState()
|
||||
val listSize = remember { mutableIntStateOf(2) }
|
||||
var currentIndex by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(currentIndex) {
|
||||
listState.animateScrollToItem(currentIndex)
|
||||
}
|
||||
|
||||
when (uiState) {
|
||||
is ShowViewModel.UiState.Loading -> Text(text = "LOADING")
|
||||
is ShowViewModel.UiState.Normal -> {
|
||||
val item = uiState.item
|
||||
val seasons = uiState.seasons
|
||||
var size by remember {
|
||||
mutableStateOf(Size.Zero)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
size = coordinates.size.toSize()
|
||||
},
|
||||
) {
|
||||
AsyncImage(
|
||||
model = item.images.backdrop,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
)
|
||||
if (size != Size.Zero) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
listOf(Color.Black.copy(alpha = .2f), Color.Black),
|
||||
center = Offset(size.width, 0f),
|
||||
radius = size.width * .8f,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
TvLazyColumn(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(top = 112.dp, bottom = MaterialTheme.spacings.large),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium),
|
||||
userScrollEnabled = false,
|
||||
modifier = Modifier.onPreviewKeyEvent { keyEvent ->
|
||||
when (keyEvent.key.nativeKeyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> {
|
||||
currentIndex = (++currentIndex).coerceIn(0, listSize.intValue - 1)
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_UP -> {
|
||||
currentIndex = (--currentIndex).coerceIn(0, listSize.intValue - 1)
|
||||
}
|
||||
}
|
||||
false
|
||||
},
|
||||
) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = MaterialTheme.spacings.default * 2,
|
||||
end = MaterialTheme.spacings.default * 2,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = item.name,
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
)
|
||||
if (item.originalTitle != item.name) {
|
||||
item.originalTitle?.let { originalTitle ->
|
||||
Text(
|
||||
text = originalTitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.small))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.small),
|
||||
) {
|
||||
Text(
|
||||
text = uiState.dateString,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Text(
|
||||
text = uiState.runTime,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
item.officialRating?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
item.communityRating?.let {
|
||||
Row {
|
||||
Icon(
|
||||
painter = painterResource(id = CoreR.drawable.ic_star),
|
||||
contentDescription = null,
|
||||
tint = Yellow,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(MaterialTheme.spacings.extraSmall))
|
||||
Text(
|
||||
text = String.format("%.1f", item.communityRating),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
|
||||
Text(
|
||||
text = item.overview,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.width(640.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.default))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium),
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
onPlayClick()
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = CoreR.drawable.ic_play),
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = stringResource(id = CoreR.string.play))
|
||||
}
|
||||
item.trailer?.let { trailerUri ->
|
||||
Button(
|
||||
onClick = {
|
||||
onTrailerClick(trailerUri)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = CoreR.drawable.ic_film),
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = stringResource(id = CoreR.string.watch_trailer))
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
onPlayedClick()
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = CoreR.drawable.ic_check),
|
||||
contentDescription = null,
|
||||
tint = if (item.played) Color.Red else LocalContentColor.current,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = stringResource(id = if (item.played) CoreR.string.unmark_as_played else CoreR.string.mark_as_played))
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
onFavoriteClick()
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = if (item.favorite) CoreR.drawable.ic_heart_filled else CoreR.drawable.ic_heart),
|
||||
contentDescription = null,
|
||||
tint = if (item.favorite) Color.Red else LocalContentColor.current,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = stringResource(id = if (item.favorite) CoreR.string.remove_from_favorites else CoreR.string.add_to_favorites))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.default))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.genres),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = .5f),
|
||||
)
|
||||
Text(
|
||||
text = uiState.genresString,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
uiState.director?.let { director ->
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.director),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = .5f),
|
||||
)
|
||||
Text(
|
||||
text = director.name ?: "Unknown",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.writers),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = .5f),
|
||||
)
|
||||
Text(
|
||||
text = uiState.writersString,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.seasons),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
TvLazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
|
||||
contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.default * 2),
|
||||
) {
|
||||
items(seasons) { season ->
|
||||
ItemCard(
|
||||
item = season,
|
||||
direction = Direction.VERTICAL,
|
||||
onClick = {
|
||||
onSeasonClick(season)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(true) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
is ShowViewModel.UiState.Error -> Text(text = uiState.error.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview(widthDp = 960, heightDp = 540)
|
||||
@Composable
|
||||
private fun ShowScreenLayoutPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
ShowScreenLayout(
|
||||
uiState = ShowViewModel.UiState.Normal(
|
||||
item = dummyShow,
|
||||
actors = emptyList(),
|
||||
director = null,
|
||||
writers = emptyList(),
|
||||
writersString = "Hiroshi Seko, Hajime Isayama",
|
||||
genresString = "Action, Science Fiction, Adventure",
|
||||
runTime = "0 min",
|
||||
dateString = "2013 - 2023",
|
||||
nextUp = null,
|
||||
seasons = emptyList(),
|
||||
),
|
||||
onPlayClick = {},
|
||||
onTrailerClick = {},
|
||||
onPlayedClick = {},
|
||||
onFavoriteClick = {},
|
||||
onSeasonClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
288
app/tv/src/main/java/dev/jdtech/jellyfin/ui/UserSelectScreen.kt
Normal file
288
app/tv/src/main/java/dev/jdtech/jellyfin/ui/UserSelectScreen.kt
Normal file
|
@ -0,0 +1,288 @@
|
|||
package dev.jdtech.jellyfin.ui
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.tv.foundation.lazy.list.TvLazyRow
|
||||
import androidx.tv.foundation.lazy.list.items
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.OutlinedButton
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.ramcosta.composedestinations.navigation.popUpTo
|
||||
import dev.jdtech.jellyfin.NavGraphs
|
||||
import dev.jdtech.jellyfin.api.JellyfinApi
|
||||
import dev.jdtech.jellyfin.destinations.LoginScreenDestination
|
||||
import dev.jdtech.jellyfin.destinations.MainScreenDestination
|
||||
import dev.jdtech.jellyfin.models.Server
|
||||
import dev.jdtech.jellyfin.models.User
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyServer
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyUser
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyUsers
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
import dev.jdtech.jellyfin.utils.ObserveAsEvents
|
||||
import dev.jdtech.jellyfin.viewmodels.UserSelectEvent
|
||||
import dev.jdtech.jellyfin.viewmodels.UserSelectViewModel
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import dev.jdtech.jellyfin.core.R as CoreR
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
fun UserSelectScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
userSelectViewModel: UserSelectViewModel = hiltViewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val api = JellyfinApi.getInstance(context)
|
||||
val delegatedUiState by userSelectViewModel.uiState.collectAsState()
|
||||
|
||||
ObserveAsEvents(userSelectViewModel.eventsChannelFlow) { event ->
|
||||
when (event) {
|
||||
is UserSelectEvent.NavigateToMain -> {
|
||||
navigator.navigate(MainScreenDestination) {
|
||||
popUpTo(NavGraphs.root) {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = true) {
|
||||
userSelectViewModel.loadUsers()
|
||||
}
|
||||
|
||||
UserSelectScreenLayout(
|
||||
uiState = delegatedUiState,
|
||||
baseUrl = api.api.baseUrl ?: "",
|
||||
onUserClick = { user ->
|
||||
userSelectViewModel.loginAsUser(user)
|
||||
},
|
||||
onAddUserClick = {
|
||||
navigator.navigate(LoginScreenDestination)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun UserSelectScreenLayout(
|
||||
uiState: UserSelectViewModel.UiState,
|
||||
baseUrl: String,
|
||||
onUserClick: (User) -> Unit,
|
||||
onAddUserClick: () -> Unit,
|
||||
) {
|
||||
var server: Server? = null
|
||||
var users: List<User> = emptyList()
|
||||
|
||||
when (uiState) {
|
||||
is UserSelectViewModel.UiState.Normal -> {
|
||||
server = uiState.server
|
||||
users = uiState.users
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.Center),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.select_user),
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
)
|
||||
server?.let {
|
||||
Text(
|
||||
text = "Server: ${it.name}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color(0xFFBDBDBD),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
|
||||
if (users.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(id = CoreR.string.no_users_found),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
} else {
|
||||
TvLazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default),
|
||||
contentPadding = PaddingValues(MaterialTheme.spacings.default),
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
) {
|
||||
items(users) {
|
||||
UserComponent(
|
||||
user = it,
|
||||
baseUrl = baseUrl,
|
||||
) { user ->
|
||||
onUserClick(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(true) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.large))
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
onAddUserClick()
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(id = CoreR.string.add_user))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview(widthDp = 960, heightDp = 540)
|
||||
@Composable
|
||||
private fun UserSelectScreenLayoutPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
UserSelectScreenLayout(
|
||||
uiState = UserSelectViewModel.UiState.Normal(dummyServer, dummyUsers),
|
||||
baseUrl = "https://demo.jellyfin.org/stable",
|
||||
onUserClick = {},
|
||||
onAddUserClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview(widthDp = 960, heightDp = 540)
|
||||
@Composable
|
||||
private fun UserSelectScreenLayoutPreviewNoUsers() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
UserSelectScreenLayout(
|
||||
uiState = UserSelectViewModel.UiState.Normal(dummyServer, emptyList()),
|
||||
baseUrl = "https://demo.jellyfin.org/stable",
|
||||
onUserClick = {},
|
||||
onAddUserClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun UserComponent(
|
||||
user: User,
|
||||
baseUrl: String,
|
||||
onClick: (User) -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.width(120.dp),
|
||||
) {
|
||||
Surface(
|
||||
onClick = {
|
||||
onClick(user)
|
||||
},
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
focusedContainerColor = Color.Transparent,
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
border = Border(BorderStroke(1.dp, Color.White), shape = CircleShape),
|
||||
focusedBorder = Border(BorderStroke(4.dp, Color.White), shape = CircleShape),
|
||||
),
|
||||
shape = ClickableSurfaceDefaults.shape(
|
||||
shape = CircleShape,
|
||||
focusedShape = CircleShape,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = CoreR.drawable.ic_user),
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.width(48.dp)
|
||||
.height(48.dp)
|
||||
.align(Alignment.Center),
|
||||
)
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data("$baseUrl/users/${user.id}/Images/${ImageType.PRIMARY}")
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
|
||||
Text(
|
||||
text = user.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun UserComponentPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
UserComponent(
|
||||
user = dummyUser,
|
||||
baseUrl = "https://demo.jellyfin.org/stable",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package dev.jdtech.jellyfin.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Icon
|
||||
import dev.jdtech.jellyfin.R
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
fun Banner() {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_banner),
|
||||
contentDescription = null,
|
||||
tint = Color.Unspecified,
|
||||
modifier = Modifier.width(320.dp),
|
||||
)
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
package dev.jdtech.jellyfin.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ClickableSurfaceScale
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyEpisode
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun EpisodeCard(
|
||||
episode: FindroidEpisode,
|
||||
onClick: (FindroidEpisode) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = { onClick(episode) },
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
focusedContainerColor = Color.Transparent,
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
BorderStroke(
|
||||
4.dp,
|
||||
Color.White,
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
),
|
||||
),
|
||||
scale = ClickableSurfaceScale.None,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(MaterialTheme.spacings.small),
|
||||
) {
|
||||
Box(modifier = Modifier.width(160.dp)) {
|
||||
ItemPoster(
|
||||
item = episode,
|
||||
direction = Direction.HORIZONTAL,
|
||||
modifier = Modifier.clip(RoundedCornerShape(10.dp)),
|
||||
)
|
||||
ProgressBadge(
|
||||
item = episode,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(PaddingValues(MaterialTheme.spacings.small)),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(MaterialTheme.spacings.medium))
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = dev.jdtech.jellyfin.core.R.string.episode_name,
|
||||
episode.indexNumber,
|
||||
episode.name,
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.extraSmall))
|
||||
Text(
|
||||
text = episode.overview,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ItemCardPreviewEpisode() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
EpisodeCard(
|
||||
episode = dummyEpisode,
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,173 @@
|
|||
package dev.jdtech.jellyfin.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ClickableSurfaceScale
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import dev.jdtech.jellyfin.core.R
|
||||
import dev.jdtech.jellyfin.models.FindroidEpisode
|
||||
import dev.jdtech.jellyfin.models.FindroidItem
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyEpisode
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyMovie
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun ItemCard(
|
||||
item: FindroidItem,
|
||||
direction: Direction,
|
||||
onClick: (FindroidItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val width = when (direction) {
|
||||
Direction.HORIZONTAL -> 260
|
||||
Direction.VERTICAL -> 150
|
||||
}
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(width.dp),
|
||||
) {
|
||||
Surface(
|
||||
onClick = { onClick(item) },
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
BorderStroke(
|
||||
4.dp,
|
||||
Color.White,
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
),
|
||||
),
|
||||
scale = ClickableSurfaceScale.None,
|
||||
) {
|
||||
Box {
|
||||
ItemPoster(
|
||||
item = item,
|
||||
direction = direction,
|
||||
)
|
||||
ProgressBadge(
|
||||
item = item,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(MaterialTheme.spacings.small),
|
||||
)
|
||||
if (direction == Direction.HORIZONTAL) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(MaterialTheme.spacings.small),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(4.dp)
|
||||
.width(
|
||||
item.playbackPositionTicks
|
||||
.div(
|
||||
item.runtimeTicks.toFloat(),
|
||||
)
|
||||
.times(
|
||||
width - 16,
|
||||
).dp,
|
||||
)
|
||||
.clip(
|
||||
MaterialTheme.shapes.extraSmall,
|
||||
)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.small))
|
||||
Text(
|
||||
text = if (item is FindroidEpisode) item.seriesName else item.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = if (direction == Direction.HORIZONTAL) 1 else 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (item is FindroidEpisode) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = R.string.episode_name_extended,
|
||||
item.parentIndexNumber,
|
||||
item.indexNumber,
|
||||
item.name,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ItemCardPreviewMovie() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
ItemCard(
|
||||
item = dummyMovie,
|
||||
direction = Direction.HORIZONTAL,
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ItemCardPreviewMovieVertical() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
ItemCard(
|
||||
item = dummyMovie,
|
||||
direction = Direction.VERTICAL,
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ItemCardPreviewEpisode() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
ItemCard(
|
||||
item = dummyEpisode,
|
||||
direction = Direction.HORIZONTAL,
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,96 @@
|
|||
package dev.jdtech.jellyfin.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Surface
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import dev.jdtech.jellyfin.api.JellyfinApi
|
||||
import dev.jdtech.jellyfin.core.R
|
||||
import dev.jdtech.jellyfin.models.User
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyUser
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun ProfileButton(
|
||||
user: User?,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val baseUrl = JellyfinApi.getInstance(context).api.baseUrl
|
||||
Surface(
|
||||
onClick = {
|
||||
onClick()
|
||||
},
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
focusedContainerColor = Color.Transparent,
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
border = Border(BorderStroke(1.dp, Color.White), shape = CircleShape),
|
||||
focusedBorder = Border(BorderStroke(4.dp, Color.White), shape = CircleShape),
|
||||
),
|
||||
shape = ClickableSurfaceDefaults.shape(
|
||||
shape = CircleShape,
|
||||
focusedShape = CircleShape,
|
||||
),
|
||||
modifier = modifier
|
||||
.width(32.dp)
|
||||
.aspectRatio(1f),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_user),
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.width(16.dp)
|
||||
.height(16.dp)
|
||||
.align(Alignment.Center),
|
||||
)
|
||||
user?.let {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data("$baseUrl/users/${user.id}/Images/${ImageType.PRIMARY}")
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ProfileButtonPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
ProfileButton(
|
||||
user = dummyUser,
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
package dev.jdtech.jellyfin.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import dev.jdtech.jellyfin.models.FindroidItem
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyEpisode
|
||||
import dev.jdtech.jellyfin.ui.dummy.dummyShow
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
import dev.jdtech.jellyfin.core.R as CoreR
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun ProgressBadge(
|
||||
item: FindroidItem,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (!(!item.played && item.unplayedItemCount == null)) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(24.dp)
|
||||
.defaultMinSize(24.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
) {
|
||||
when (item.played) {
|
||||
true -> {
|
||||
Icon(
|
||||
painter = painterResource(id = CoreR.drawable.ic_check),
|
||||
contentDescription = "",
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
|
||||
false -> {
|
||||
Text(
|
||||
text = item.unplayedItemCount.toString(),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(horizontal = MaterialTheme.spacings.extraSmall),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ProgressBadgePreviewWatched() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
ProgressBadge(
|
||||
item = dummyEpisode,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ProgressBadgePreviewItemRemaining() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
ProgressBadge(
|
||||
item = dummyShow,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,110 @@
|
|||
package dev.jdtech.jellyfin.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ClickableSurfaceScale
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import dev.jdtech.jellyfin.models.PreferenceCategory
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
import dev.jdtech.jellyfin.core.R as CoreR
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsCategoryCard(
|
||||
preference: PreferenceCategory,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
onClick = {
|
||||
preference.onClick(preference)
|
||||
},
|
||||
enabled = preference.enabled,
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
BorderStroke(
|
||||
4.dp,
|
||||
Color.White,
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
),
|
||||
),
|
||||
scale = ClickableSurfaceScale.None,
|
||||
modifier = modifier
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(MaterialTheme.spacings.default),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (preference.iconDrawableId != null) {
|
||||
Icon(
|
||||
painter = painterResource(id = preference.iconDrawableId!!),
|
||||
contentDescription = null,
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = preference.nameStringResource),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
preference.descriptionStringRes?.let {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.extraSmall))
|
||||
Text(
|
||||
text = stringResource(id = it),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SettingsCategoryCardPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
SettingsCategoryCard(
|
||||
preference = PreferenceCategory(
|
||||
nameStringResource = CoreR.string.settings_category_player,
|
||||
iconDrawableId = CoreR.drawable.ic_play,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,124 @@
|
|||
package dev.jdtech.jellyfin.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ClickableSurfaceScale
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import dev.jdtech.jellyfin.Constants
|
||||
import dev.jdtech.jellyfin.models.PreferenceSelect
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
import dev.jdtech.jellyfin.core.R as CoreR
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsSelectCard(
|
||||
preference: PreferenceSelect,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val options = stringArrayResource(id = preference.options)
|
||||
val optionValues = stringArrayResource(id = preference.optionValues)
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
enabled = preference.enabled,
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
BorderStroke(
|
||||
4.dp,
|
||||
Color.White,
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
),
|
||||
),
|
||||
scale = ClickableSurfaceScale.None,
|
||||
modifier = modifier
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(MaterialTheme.spacings.default),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (preference.iconDrawableId != null) {
|
||||
Icon(
|
||||
painter = painterResource(id = preference.iconDrawableId!!),
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = preference.nameStringResource),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.extraSmall))
|
||||
Text(
|
||||
text = if (preference.value != null) {
|
||||
val index = optionValues.indexOf(preference.value)
|
||||
if (index == -1) {
|
||||
"Unknown"
|
||||
} else {
|
||||
options[index]
|
||||
}
|
||||
} else {
|
||||
"Not set"
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SettingsSelectCardPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
SettingsSelectCard(
|
||||
preference = PreferenceSelect(
|
||||
nameStringResource = CoreR.string.settings_preferred_audio_language,
|
||||
iconDrawableId = CoreR.drawable.ic_speaker,
|
||||
backendName = Constants.PREF_AUDIO_LANGUAGE,
|
||||
backendDefaultValue = null,
|
||||
options = CoreR.array.languages,
|
||||
optionValues = CoreR.array.languages_values,
|
||||
),
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,159 @@
|
|||
package dev.jdtech.jellyfin.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ClickableSurfaceScale
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Switch
|
||||
import androidx.tv.material3.Text
|
||||
import dev.jdtech.jellyfin.core.R
|
||||
import dev.jdtech.jellyfin.models.PreferenceSwitch
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsSwitchCard(
|
||||
preference: PreferenceSwitch,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
enabled = preference.enabled,
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
BorderStroke(
|
||||
4.dp,
|
||||
Color.White,
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
),
|
||||
),
|
||||
scale = ClickableSurfaceScale.None,
|
||||
modifier = modifier
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(MaterialTheme.spacings.default),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.small),
|
||||
) {
|
||||
if (preference.iconDrawableId != null) {
|
||||
Icon(
|
||||
painter = painterResource(id = preference.iconDrawableId!!),
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = preference.nameStringResource),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
preference.descriptionStringRes?.let {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.extraSmall))
|
||||
Text(
|
||||
text = stringResource(id = it),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Switch(
|
||||
checked = preference.value,
|
||||
onCheckedChange = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SettingsSwitchCardPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
SettingsSwitchCard(
|
||||
preference = PreferenceSwitch(
|
||||
nameStringResource = R.string.settings_use_cache_title,
|
||||
iconDrawableId = null,
|
||||
backendName = "image-cache",
|
||||
backendDefaultValue = false,
|
||||
value = false,
|
||||
),
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SettingsSwitchCardDisabledPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
SettingsSwitchCard(
|
||||
preference = PreferenceSwitch(
|
||||
nameStringResource = R.string.settings_use_cache_title,
|
||||
iconDrawableId = null,
|
||||
enabled = false,
|
||||
backendName = "image-cache",
|
||||
backendDefaultValue = false,
|
||||
value = false,
|
||||
),
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SettingsSwitchCardDescriptionPreview() {
|
||||
FindroidTheme {
|
||||
Surface {
|
||||
SettingsSwitchCard(
|
||||
preference = PreferenceSwitch(
|
||||
nameStringResource = R.string.settings_use_cache_title,
|
||||
descriptionStringRes = R.string.settings_use_cache_summary,
|
||||
iconDrawableId = null,
|
||||
backendName = "image-cache",
|
||||
backendDefaultValue = true,
|
||||
value = true,
|
||||
),
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,120 @@
|
|||
package dev.jdtech.jellyfin.ui.dialogs
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.foundation.lazy.list.TvLazyColumn
|
||||
import androidx.tv.foundation.lazy.list.items
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ClickableSurfaceScale
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.RadioButton
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.result.EmptyResultBackNavigator
|
||||
import com.ramcosta.composedestinations.result.ResultBackNavigator
|
||||
import dev.jdtech.jellyfin.models.Track
|
||||
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
|
||||
import dev.jdtech.jellyfin.ui.theme.spacings
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Destination(style = BaseDialogStyle::class)
|
||||
@Composable
|
||||
fun VideoPlayerTrackSelectorDialog(
|
||||
tracks: ArrayList<Track>,
|
||||
resultNavigator: ResultBackNavigator<Int>,
|
||||
) {
|
||||
Surface {
|
||||
Column(
|
||||
modifier = Modifier.padding(MaterialTheme.spacings.medium),
|
||||
) {
|
||||
Text(
|
||||
text = "Select track",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
|
||||
TvLazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium - MaterialTheme.spacings.extraSmall),
|
||||
contentPadding = PaddingValues(vertical = MaterialTheme.spacings.extraSmall),
|
||||
) {
|
||||
items(tracks) { track ->
|
||||
Surface(
|
||||
onClick = {
|
||||
resultNavigator.navigateBack(result = track.id)
|
||||
},
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
focusedContainerColor = Color.Transparent,
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
BorderStroke(
|
||||
4.dp,
|
||||
Color.White,
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
),
|
||||
),
|
||||
scale = ClickableSurfaceScale.None,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(MaterialTheme.spacings.extraSmall),
|
||||
) {
|
||||
RadioButton(
|
||||
selected = track.selected,
|
||||
onClick = null,
|
||||
enabled = true,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(MaterialTheme.spacings.medium))
|
||||
Text(text = listOf(track.label, track.language, track.codec).mapNotNull { it }.joinToString(" - "), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun VideoPlayerTrackSelectorDialogPreview() {
|
||||
FindroidTheme {
|
||||
VideoPlayerTrackSelectorDialog(
|
||||
tracks = arrayListOf(
|
||||
Track(
|
||||
id = 0,
|
||||
label = null,
|
||||
language = "English",
|
||||
codec = "flac",
|
||||
selected = true,
|
||||
),
|
||||
Track(
|
||||
id = 0,
|
||||
label = null,
|
||||
language = "Japanese",
|
||||
codec = "flac",
|
||||
selected = false,
|
||||
),
|
||||
),
|
||||
resultNavigator = EmptyResultBackNavigator(),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 }
|
30
app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Theme.kt
Normal file
30
app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Theme.kt
Normal file
|
@ -0,0 +1,30 @@
|
|||
package dev.jdtech.jellyfin.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme as MaterialThemeTv
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun FindroidTheme(
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = ColorScheme,
|
||||
typography = Typography,
|
||||
shapes = shapes,
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalSpacings provides Spacings(),
|
||||
) {
|
||||
MaterialThemeTv(
|
||||
colorScheme = ColorSchemeTv,
|
||||
typography = TypographyTv,
|
||||
shapes = shapesTv,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
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>
|
7
core/src/main/res/values/plurals.xml
Normal file
7
core/src/main/res/values/plurals.xml
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<plurals name="discovered_servers">
|
||||
<item quantity="one">%d server discovered</item>
|
||||
<item quantity="other">%d servers discovered</item>
|
||||
</plurals>
|
||||
</resources>
|
|
@ -177,4 +177,14 @@
|
|||
<string name="cancel_download_message">Are you sure you want to cancel the download?</string>
|
||||
<string name="stop_download">Stop download</string>
|
||||
<string name="privacy_policy_notice">By using Findroid you agree with the <a href='https://raw.githubusercontent.com/jarnedemeulemeester/findroid/main/PRIVACY'>Privacy Policy</a> which states that we do not collect any data</string>
|
||||
<string name="no_servers_found">No servers found</string>
|
||||
<string name="no_users_found">No users found</string>
|
||||
<string name="select_user">Select user</string>
|
||||
<string name="live_tv">Live TV</string>
|
||||
<string name="play">Play</string>
|
||||
<string name="watch_trailer">Watch trailer</string>
|
||||
<string name="mark_as_played">Mark as played</string>
|
||||
<string name="unmark_as_played">Unmark as played</string>
|
||||
<string name="add_to_favorites">Add to favorites</string>
|
||||
<string name="remove_from_favorites">Remove from favorites</string>
|
||||
</resources>
|
||||
|
|
|
@ -38,6 +38,10 @@ android {
|
|||
sourceCompatibility = Versions.java
|
||||
targetCompatibility = Versions.java
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
ktlint {
|
||||
|
|
|
@ -16,6 +16,7 @@ import dev.jdtech.jellyfin.models.FindroidUserDataDto
|
|||
import dev.jdtech.jellyfin.models.IntroDto
|
||||
import dev.jdtech.jellyfin.models.Server
|
||||
import dev.jdtech.jellyfin.models.ServerAddress
|
||||
import dev.jdtech.jellyfin.models.ServerWithAddressAndUser
|
||||
import dev.jdtech.jellyfin.models.ServerWithAddresses
|
||||
import dev.jdtech.jellyfin.models.ServerWithAddressesAndUsers
|
||||
import dev.jdtech.jellyfin.models.ServerWithUsers
|
||||
|
@ -55,6 +56,10 @@ interface ServerDatabaseDao {
|
|||
@Query("SELECT * FROM servers WHERE id = :id")
|
||||
fun getServerWithAddressesAndUsers(id: String): ServerWithAddressesAndUsers?
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM servers WHERE id = :id")
|
||||
fun getServerWithAddressAndUser(id: String): ServerWithAddressAndUser?
|
||||
|
||||
@Query("DELETE FROM servers")
|
||||
fun clear()
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package dev.jdtech.jellyfin.models
|
||||
|
||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import java.util.UUID
|
||||
|
||||
|
@ -16,11 +17,15 @@ data class FindroidBoxSet(
|
|||
override val runtimeTicks: Long = 0L,
|
||||
override val playbackPositionTicks: Long = 0L,
|
||||
override val unplayedItemCount: Int? = null,
|
||||
override val images: FindroidImages,
|
||||
) : FindroidItem
|
||||
|
||||
fun BaseItemDto.toFindroidBoxSet(): FindroidBoxSet {
|
||||
fun BaseItemDto.toFindroidBoxSet(
|
||||
jellyfinRepository: JellyfinRepository,
|
||||
): FindroidBoxSet {
|
||||
return FindroidBoxSet(
|
||||
id = id,
|
||||
name = name.orEmpty(),
|
||||
images = toFindroidImages(jellyfinRepository),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package dev.jdtech.jellyfin.models
|
||||
|
||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import java.util.UUID
|
||||
|
||||
|
@ -17,9 +18,12 @@ data class FindroidCollection(
|
|||
override val playbackPositionTicks: Long = 0L,
|
||||
override val unplayedItemCount: Int? = null,
|
||||
val type: CollectionType,
|
||||
override val images: FindroidImages,
|
||||
) : FindroidItem
|
||||
|
||||
fun BaseItemDto.toFindroidCollection(): FindroidCollection? {
|
||||
fun BaseItemDto.toFindroidCollection(
|
||||
jellyfinRepository: JellyfinRepository,
|
||||
): FindroidCollection? {
|
||||
val type = CollectionType.fromString(collectionType)
|
||||
|
||||
if (type !in CollectionType.supported) {
|
||||
|
@ -30,5 +34,6 @@ fun BaseItemDto.toFindroidCollection(): FindroidCollection? {
|
|||
id = id,
|
||||
name = name.orEmpty(),
|
||||
type = type,
|
||||
images = toFindroidImages(jellyfinRepository),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -30,6 +30,7 @@ data class FindroidEpisode(
|
|||
val communityRating: Float?,
|
||||
override val unplayedItemCount: Int? = null,
|
||||
val missing: Boolean = false,
|
||||
override val images: FindroidImages,
|
||||
) : FindroidItem, FindroidSources
|
||||
|
||||
suspend fun BaseItemDto.toFindroidEpisode(
|
||||
|
@ -63,6 +64,7 @@ suspend fun BaseItemDto.toFindroidEpisode(
|
|||
seasonId = seasonId!!,
|
||||
communityRating = communityRating,
|
||||
missing = locationType == LocationType.VIRTUAL,
|
||||
images = toFindroidImages(jellyfinRepository),
|
||||
)
|
||||
} catch (_: NullPointerException) {
|
||||
null
|
||||
|
@ -91,5 +93,6 @@ fun FindroidEpisodeDto.toFindroidEpisode(database: ServerDatabaseDao, userId: UU
|
|||
seriesId = seriesId,
|
||||
seasonId = seasonId,
|
||||
communityRating = communityRating,
|
||||
images = FindroidImages(),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
package dev.jdtech.jellyfin.models
|
||||
|
||||
import android.net.Uri
|
||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
data class FindroidImages(
|
||||
val primary: Uri? = null,
|
||||
val backdrop: Uri? = null,
|
||||
val logo: Uri? = null,
|
||||
val showPrimary: Uri? = null,
|
||||
)
|
||||
|
||||
fun BaseItemDto.toFindroidImages(
|
||||
jellyfinRepository: JellyfinRepository,
|
||||
): FindroidImages {
|
||||
val baseUrl = Uri.parse(jellyfinRepository.getBaseUrl())
|
||||
val primary = imageTags?.get(ImageType.PRIMARY)?.let { tag ->
|
||||
baseUrl.buildUpon()
|
||||
.appendEncodedPath("items/$id/Images/${ImageType.PRIMARY}")
|
||||
.appendQueryParameter("tag", tag)
|
||||
.build()
|
||||
}
|
||||
val backdrop = backdropImageTags?.firstOrNull()?.let { tag ->
|
||||
baseUrl.buildUpon()
|
||||
.appendEncodedPath("items/$id/Images/${ImageType.BACKDROP}/0")
|
||||
.appendQueryParameter("tag", tag)
|
||||
.build()
|
||||
}
|
||||
val logo = imageTags?.get(ImageType.LOGO)?.let { tag ->
|
||||
baseUrl.buildUpon()
|
||||
.appendEncodedPath("items/$id/Images/${ImageType.LOGO}")
|
||||
.appendQueryParameter("tag", tag)
|
||||
.build()
|
||||
}
|
||||
val showPrimary = seriesPrimaryImageTag?.let { tag ->
|
||||
baseUrl.buildUpon()
|
||||
.appendEncodedPath("items/$seriesId/Images/${ImageType.PRIMARY}")
|
||||
.appendQueryParameter("tag", tag)
|
||||
.build()
|
||||
}
|
||||
|
||||
return FindroidImages(
|
||||
primary = primary,
|
||||
backdrop = backdrop,
|
||||
logo = logo,
|
||||
showPrimary = showPrimary,
|
||||
)
|
||||
}
|
|
@ -19,6 +19,7 @@ interface FindroidItem {
|
|||
val runtimeTicks: Long
|
||||
val playbackPositionTicks: Long
|
||||
val unplayedItemCount: Int?
|
||||
val images: FindroidImages
|
||||
}
|
||||
|
||||
suspend fun BaseItemDto.toFindroidItem(
|
||||
|
@ -28,9 +29,9 @@ suspend fun BaseItemDto.toFindroidItem(
|
|||
return when (type) {
|
||||
BaseItemKind.MOVIE -> toFindroidMovie(jellyfinRepository, serverDatabase)
|
||||
BaseItemKind.EPISODE -> toFindroidEpisode(jellyfinRepository)
|
||||
BaseItemKind.SEASON -> toFindroidSeason()
|
||||
BaseItemKind.SERIES -> toFindroidShow()
|
||||
BaseItemKind.BOX_SET -> toFindroidBoxSet()
|
||||
BaseItemKind.SEASON -> toFindroidSeason(jellyfinRepository)
|
||||
BaseItemKind.SERIES -> toFindroidShow(jellyfinRepository)
|
||||
BaseItemKind.BOX_SET -> toFindroidBoxSet(jellyfinRepository)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,10 +2,10 @@ package dev.jdtech.jellyfin.models
|
|||
|
||||
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||
import org.jellyfin.sdk.model.DateTime
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemPerson
|
||||
import org.jellyfin.sdk.model.api.PlayAccess
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
data class FindroidMovie(
|
||||
|
@ -20,16 +20,17 @@ data class FindroidMovie(
|
|||
override val canDownload: Boolean,
|
||||
override val runtimeTicks: Long,
|
||||
override val playbackPositionTicks: Long,
|
||||
val premiereDate: DateTime?,
|
||||
val premiereDate: LocalDateTime?,
|
||||
val people: List<BaseItemPerson>,
|
||||
val genres: List<String>,
|
||||
val communityRating: Float?,
|
||||
val officialRating: String?,
|
||||
val status: String,
|
||||
val productionYear: Int?,
|
||||
val endDate: DateTime?,
|
||||
val endDate: LocalDateTime?,
|
||||
val trailer: String?,
|
||||
override val unplayedItemCount: Int? = null,
|
||||
override val images: FindroidImages,
|
||||
) : FindroidItem, FindroidSources
|
||||
|
||||
suspend fun BaseItemDto.toFindroidMovie(
|
||||
|
@ -62,6 +63,7 @@ suspend fun BaseItemDto.toFindroidMovie(
|
|||
productionYear = productionYear,
|
||||
endDate = endDate,
|
||||
trailer = remoteTrailers?.getOrNull(0)?.url,
|
||||
images = toFindroidImages(jellyfinRepository),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -88,5 +90,6 @@ fun FindroidMovieDto.toFindroidMovie(database: ServerDatabaseDao, userId: UUID):
|
|||
canPlay = true,
|
||||
sources = database.getSources(id).map { it.toFindroidSource(database) },
|
||||
trailer = null,
|
||||
images = FindroidImages(),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package dev.jdtech.jellyfin.models
|
||||
|
||||
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.PlayAccess
|
||||
import java.util.UUID
|
||||
|
@ -22,9 +23,12 @@ data class FindroidSeason(
|
|||
override val runtimeTicks: Long = 0L,
|
||||
override val playbackPositionTicks: Long = 0L,
|
||||
override val unplayedItemCount: Int?,
|
||||
override val images: FindroidImages,
|
||||
) : FindroidItem
|
||||
|
||||
fun BaseItemDto.toFindroidSeason(): FindroidSeason {
|
||||
fun BaseItemDto.toFindroidSeason(
|
||||
jellyfinRepository: JellyfinRepository,
|
||||
): FindroidSeason {
|
||||
return FindroidSeason(
|
||||
id = id,
|
||||
name = name.orEmpty(),
|
||||
|
@ -40,6 +44,7 @@ fun BaseItemDto.toFindroidSeason(): FindroidSeason {
|
|||
episodes = emptyList(),
|
||||
seriesId = seriesId!!,
|
||||
seriesName = seriesName.orEmpty(),
|
||||
images = toFindroidImages(jellyfinRepository),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -60,5 +65,6 @@ fun FindroidSeasonDto.toFindroidSeason(database: ServerDatabaseDao, userId: UUID
|
|||
episodes = emptyList(),
|
||||
seriesId = seriesId,
|
||||
seriesName = seriesName,
|
||||
images = FindroidImages(),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package dev.jdtech.jellyfin.models
|
||||
|
||||
import dev.jdtech.jellyfin.database.ServerDatabaseDao
|
||||
import dev.jdtech.jellyfin.repository.JellyfinRepository
|
||||
import org.jellyfin.sdk.model.DateTime
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemPerson
|
||||
|
@ -29,9 +30,12 @@ data class FindroidShow(
|
|||
val productionYear: Int?,
|
||||
val endDate: DateTime?,
|
||||
val trailer: String?,
|
||||
override val images: FindroidImages,
|
||||
) : FindroidItem
|
||||
|
||||
fun BaseItemDto.toFindroidShow(): FindroidShow {
|
||||
fun BaseItemDto.toFindroidShow(
|
||||
jellyfinRepository: JellyfinRepository,
|
||||
): FindroidShow {
|
||||
return FindroidShow(
|
||||
id = id,
|
||||
name = name.orEmpty(),
|
||||
|
@ -53,6 +57,7 @@ fun BaseItemDto.toFindroidShow(): FindroidShow {
|
|||
productionYear = productionYear,
|
||||
endDate = endDate,
|
||||
trailer = remoteTrailers?.getOrNull(0)?.url,
|
||||
images = toFindroidImages(jellyfinRepository),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -79,5 +84,6 @@ fun FindroidShowDto.toFindroidShow(database: ServerDatabaseDao, userId: UUID): F
|
|||
productionYear = productionYear,
|
||||
endDate = endDate,
|
||||
trailer = null,
|
||||
images = FindroidImages(),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
package dev.jdtech.jellyfin.models
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Relation
|
||||
|
||||
data class ServerWithAddressAndUser(
|
||||
@Embedded
|
||||
val server: Server,
|
||||
@Relation(
|
||||
parentColumn = "currentServerAddressId",
|
||||
entityColumn = "id",
|
||||
)
|
||||
val address: ServerAddress?,
|
||||
@Relation(
|
||||
parentColumn = "currentUserId",
|
||||
entityColumn = "id",
|
||||
)
|
||||
val user: User?,
|
||||
)
|
|
@ -90,7 +90,7 @@ class JellyfinRepositoryImpl(
|
|||
jellyfinApi.userLibraryApi.getItem(
|
||||
jellyfinApi.userId!!,
|
||||
itemId,
|
||||
).content.toFindroidShow()
|
||||
).content.toFindroidShow(this@JellyfinRepositoryImpl)
|
||||
}
|
||||
|
||||
override suspend fun getSeason(itemId: UUID): FindroidSeason =
|
||||
|
@ -98,7 +98,7 @@ class JellyfinRepositoryImpl(
|
|||
jellyfinApi.userLibraryApi.getItem(
|
||||
jellyfinApi.userId!!,
|
||||
itemId,
|
||||
).content.toFindroidSeason()
|
||||
).content.toFindroidSeason(this@JellyfinRepositoryImpl)
|
||||
}
|
||||
|
||||
override suspend fun getLibraries(): List<FindroidCollection> =
|
||||
|
@ -107,7 +107,7 @@ class JellyfinRepositoryImpl(
|
|||
jellyfinApi.userId!!,
|
||||
).content.items
|
||||
.orEmpty()
|
||||
.mapNotNull { it.toFindroidCollection() }
|
||||
.mapNotNull { it.toFindroidCollection(this@JellyfinRepositoryImpl) }
|
||||
}
|
||||
|
||||
override suspend fun getItems(
|
||||
|
@ -240,7 +240,7 @@ class JellyfinRepositoryImpl(
|
|||
if (!offline) {
|
||||
jellyfinApi.showsApi.getSeasons(seriesId, jellyfinApi.userId!!).content.items
|
||||
.orEmpty()
|
||||
.map { it.toFindroidSeason() }
|
||||
.map { it.toFindroidSeason(this@JellyfinRepositoryImpl) }
|
||||
} else {
|
||||
database.getSeasonsByShowId(seriesId).map { it.toFindroidSeason(database, jellyfinApi.userId!!) }
|
||||
}
|
||||
|
|
|
@ -18,4 +18,3 @@ android.useAndroidX=true
|
|||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
android.injected.testOnly=false
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
|
|
|
@ -1,26 +1,30 @@
|
|||
[versions]
|
||||
aboutlibraries = "10.9.2"
|
||||
android-plugin = "8.1.2"
|
||||
androidx-activity = "1.8.0"
|
||||
android-plugin = "8.2.0"
|
||||
androidx-activity = "1.8.1"
|
||||
androidx-appcompat = "1.6.1"
|
||||
androidx-compose-material3 = "1.2.0-alpha09"
|
||||
androidx-compose-ui = "1.6.0-alpha07"
|
||||
androidx-constraintlayout = "2.1.4"
|
||||
androidx-core = "1.12.0"
|
||||
androidx-hilt = "1.1.0"
|
||||
androidx-lifecycle = "2.6.2"
|
||||
androidx-media3 = "1.1.1"
|
||||
androidx-media3 = "1.2.0"
|
||||
androidx-navigation = "2.7.5"
|
||||
androidx-paging = "3.2.1"
|
||||
androidx-preference = "1.2.1"
|
||||
androidx-recyclerview = "1.3.2"
|
||||
androidx-room = "2.6.0"
|
||||
androidx-room = "2.6.1"
|
||||
androidx-swiperefreshlayout = "1.1.0"
|
||||
androidx-work = "2.8.1"
|
||||
coil = "2.4.0"
|
||||
androidx-tv = "1.0.0-alpha10"
|
||||
androidx-work = "2.9.0"
|
||||
coil = "2.5.0"
|
||||
hilt = "2.48.1"
|
||||
jellyfin = "1.4.5"
|
||||
kotlin = "1.9.10"
|
||||
kotlinx-serialization = "1.6.0"
|
||||
ksp = "1.9.10-1.0.13"
|
||||
compose-destinations = "1.9.54"
|
||||
jellyfin = "1.4.6"
|
||||
kotlin = "1.9.21"
|
||||
kotlinx-serialization = "1.6.1"
|
||||
ksp = "1.9.21-1.0.15"
|
||||
ktlint = "11.6.1"
|
||||
libmpv = "0.1.3"
|
||||
material = "1.10.0"
|
||||
|
@ -30,13 +34,20 @@ timber = "5.0.1"
|
|||
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlibraries" }
|
||||
aboutlibraries = { module = "com.mikepenz:aboutlibraries", version.ref = "aboutlibraries" }
|
||||
androidx-activity = { module = "androidx.activity:activity", version.ref = "androidx-activity" }
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
||||
androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "androidx-compose-material3" }
|
||||
androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidx-compose-ui" }
|
||||
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "androidx-compose-ui" }
|
||||
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "androidx-compose-ui" }
|
||||
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" }
|
||||
androidx-core = { module = "androidx.core:core", version.ref = "androidx-core" }
|
||||
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt" }
|
||||
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "androidx-hilt" }
|
||||
androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidx-hilt" }
|
||||
androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime", version.ref = "androidx-lifecycle" }
|
||||
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" }
|
||||
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||
androidx-media3-common = { module = "androidx.media3:media3-common", version.ref = "androidx-media3" }
|
||||
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" }
|
||||
androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" }
|
||||
|
@ -45,14 +56,20 @@ androidx-media3-session = { module = "androidx.media3:media3-session", version.r
|
|||
androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment", version.ref = "androidx-navigation" }
|
||||
androidx-navigation-ui = { module = "androidx.navigation:navigation-ui", version.ref = "androidx-navigation" }
|
||||
androidx-paging = { module = "androidx.paging:paging-runtime", version.ref = "androidx-paging" }
|
||||
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" }
|
||||
androidx-preference = { module = "androidx.preference:preference", version.ref = "androidx-preference" }
|
||||
androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "androidx-recyclerview" }
|
||||
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" }
|
||||
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" }
|
||||
androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "androidx-swiperefreshlayout" }
|
||||
androidx-work = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" }
|
||||
androidx-work = { module = "androidx.work:work-runtime", version.ref = "androidx-work" }
|
||||
androidx-tv-foundation = { module = "androidx.tv:tv-foundation", version.ref = "androidx-tv" }
|
||||
androidx-tv-material = { module = "androidx.tv:tv-material", version.ref = "androidx-tv" }
|
||||
coil = { module = "io.coil-kt:coil", version.ref = "coil" }
|
||||
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
|
||||
coil-svg = { module = "io.coil-kt:coil-svg", version.ref = "coil" }
|
||||
compose-destinations-core = { module = "io.github.raamcosta.compose-destinations:core", version.ref = "compose-destinations" }
|
||||
compose-destinations-ksp = { module = "io.github.raamcosta.compose-destinations:ksp", version.ref = "compose-destinations" }
|
||||
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
|
||||
jellyfin-core = { module = "org.jellyfin.sdk:jellyfin-core", version.ref = "jellyfin" }
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue