Merge branch 'main' into main

This commit is contained in:
Freya 2024-01-11 20:03:49 +00:00 committed by GitHub
commit a972832aae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
146 changed files with 7571 additions and 733 deletions

View file

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View file

@ -14,7 +14,7 @@ jobs:
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
- name: Set up JDK 17
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
java-version: 17
distribution: temurin
@ -31,7 +31,7 @@ jobs:
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
- name: Set up JDK 17
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
java-version: 17
distribution: temurin
@ -40,28 +40,43 @@ jobs:
- name: Build with Gradle
run: ./gradlew assembleDebug
# Upload all build artifacts in separate steps. This can be shortened once https://github.com/actions/upload-artifact/pull/354 is merged.
- name: Upload artifact phone-libre-universal-debug.apk
uses: actions/upload-artifact@v3
with:
name: phone-libre-universal-debug.apk
path: ./app/phone/build/outputs/apk/libre/debug/phone-libre-universal-debug.apk
- name: Upload artifact phone-libre-arm64-v8a-debug.apk
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: phone-libre-arm64-v8a-debug.apk
path: ./app/phone/build/outputs/apk/libre/debug/phone-libre-arm64-v8a-debug.apk
- name: Upload artifact phone-libre-armeabi-v7a-debug.apk
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: phone-libre-armeabi-v7a-debug.apk
path: ./app/phone/build/outputs/apk/libre/debug/phone-libre-armeabi-v7a-debug.apk
- name: Upload artifact phone-libre-x86_64-debug.apk
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: phone-libre-x86_64-debug.apk
path: ./app/phone/build/outputs/apk/libre/debug/phone-libre-x86_64-debug.apk
- name: Upload artifact phone-libre-x86-debug.apk
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: phone-libre-x86-debug.apk
path: ./app/phone/build/outputs/apk/libre/debug/phone-libre-x86-debug.apk
- name: Upload artifact tv-libre-arm64-v8a-debug.apk
uses: actions/upload-artifact@v4
with:
name: tv-libre-arm64-v8a-debug.apk
path: ./app/tv/build/outputs/apk/libre/debug/tv-libre-arm64-v8a-debug.apk
- name: Upload artifact tv-libre-armeabi-v7a-debug.apk
uses: actions/upload-artifact@v4
with:
name: tv-libre-armeabi-v7a-debug.apk
path: ./app/tv/build/outputs/apk/libre/debug/tv-libre-armeabi-v7a-debug.apk
- name: Upload artifact tv-libre-x86_64-debug.apk
uses: actions/upload-artifact@v4
with:
name: tv-libre-x86_64-debug.apk
path: ./app/tv/build/outputs/apk/libre/debug/tv-libre-x86_64-debug.apk
- name: Upload artifact tv-libre-x86-debug.apk
uses: actions/upload-artifact@v4
with:
name: tv-libre-x86-debug.apk
path: ./app/tv/build/outputs/apk/libre/debug/tv-libre-x86-debug.apk

View file

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

View file

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

View file

@ -27,18 +27,15 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.C
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerView
import androidx.media3.ui.TrackSelectionDialogBuilder
import androidx.navigation.navArgs
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.ActivityPlayerBinding
import dev.jdtech.jellyfin.dialogs.SpeedSelectionDialogFragment
import dev.jdtech.jellyfin.dialogs.TrackSelectionDialogFragment
import dev.jdtech.jellyfin.mpv.MPVPlayer
import dev.jdtech.jellyfin.mpv.TrackType
import dev.jdtech.jellyfin.utils.PlayerGestureHelper
import dev.jdtech.jellyfin.utils.PreviewScrubListener
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
@ -46,7 +43,6 @@ import dev.jdtech.jellyfin.viewmodels.PlayerEvents
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import dev.jdtech.jellyfin.player.video.R as PlayerVideoR
var isControlsLocked: Boolean = false
@ -201,36 +197,10 @@ class PlayerActivity : BasePlayerActivity() {
}
audioButton.setOnClickListener {
when (viewModel.player) {
is MPVPlayer -> {
TrackSelectionDialogFragment(TrackType.AUDIO, viewModel).show(
supportFragmentManager,
"trackselectiondialog",
)
}
is ExoPlayer -> {
val mappedTrackInfo =
viewModel.trackSelector.currentMappedTrackInfo ?: return@setOnClickListener
var audioRenderer: Int? = null
for (i in 0 until mappedTrackInfo.rendererCount) {
if (isRendererType(mappedTrackInfo, i, C.TRACK_TYPE_AUDIO)) {
audioRenderer = i
}
}
if (audioRenderer == null) return@setOnClickListener
val trackSelectionDialogBuilder = TrackSelectionDialogBuilder(
this,
resources.getString(PlayerVideoR.string.select_audio_track),
viewModel.player,
C.TRACK_TYPE_AUDIO,
)
val trackSelectionDialog = trackSelectionDialogBuilder.build()
trackSelectionDialog.show()
}
}
TrackSelectionDialogFragment(C.TRACK_TYPE_AUDIO, viewModel).show(
supportFragmentManager,
"trackselectiondialog",
)
}
val exoPlayerControlView = findViewById<FrameLayout>(R.id.player_controls)
@ -251,38 +221,10 @@ class PlayerActivity : BasePlayerActivity() {
}
subtitleButton.setOnClickListener {
when (viewModel.player) {
is MPVPlayer -> {
TrackSelectionDialogFragment(TrackType.SUBTITLE, viewModel).show(
supportFragmentManager,
"trackselectiondialog",
)
}
is ExoPlayer -> {
val mappedTrackInfo =
viewModel.trackSelector.currentMappedTrackInfo ?: return@setOnClickListener
var subtitleRenderer: Int? = null
for (i in 0 until mappedTrackInfo.rendererCount) {
if (isRendererType(mappedTrackInfo, i, C.TRACK_TYPE_TEXT)) {
subtitleRenderer = i
}
}
if (subtitleRenderer == null) return@setOnClickListener
val trackSelectionDialogBuilder = TrackSelectionDialogBuilder(
this,
resources.getString(PlayerVideoR.string.select_subtile_track),
viewModel.player,
C.TRACK_TYPE_TEXT,
)
trackSelectionDialogBuilder.setShowDisableOption(true)
val trackSelectionDialog = trackSelectionDialogBuilder.build()
trackSelectionDialog.show()
}
}
TrackSelectionDialogFragment(C.TRACK_TYPE_TEXT, viewModel).show(
supportFragmentManager,
"trackselectiondialog",
)
}
speedButton.setOnClickListener {

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

View file

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

View file

@ -0,0 +1,70 @@
package dev.jdtech.jellyfin
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import com.ramcosta.composedestinations.DestinationsNavHost
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.database.ServerDatabaseDao
import dev.jdtech.jellyfin.destinations.AddServerScreenDestination
import dev.jdtech.jellyfin.destinations.LoginScreenDestination
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.viewmodels.MainViewModel
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
@Inject
lateinit var database: ServerDatabaseDao
@Inject
lateinit var appPreferences: AppPreferences
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
var startRoute = NavGraphs.root.startRoute
if (checkServersEmpty()) {
startRoute = AddServerScreenDestination
} else if (checkUser()) {
startRoute = LoginScreenDestination
}
setContent {
FindroidTheme {
DestinationsNavHost(
navGraph = NavGraphs.root,
startRoute = startRoute,
)
}
}
}
private fun checkServersEmpty(): Boolean {
if (!viewModel.startDestinationChanged) {
val nServers = database.getServersCount()
if (nServers < 1) {
viewModel.startDestinationChanged = true
return true
}
}
return false
}
private fun checkUser(): Boolean {
if (!viewModel.startDestinationChanged) {
appPreferences.currentServer?.let {
val currentUser = database.getServerCurrentUser(it)
if (currentUser == null) {
viewModel.startDestinationChanged = true
return true
}
}
}
return false
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,157 @@
package dev.jdtech.jellyfin.ui.dialogs
import android.os.Parcelable
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.media3.common.C
import androidx.tv.foundation.lazy.list.TvLazyColumn
import androidx.tv.foundation.lazy.list.items
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ClickableSurfaceScale
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.RadioButton
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.result.EmptyResultBackNavigator
import com.ramcosta.composedestinations.result.ResultBackNavigator
import dev.jdtech.jellyfin.models.Track
import dev.jdtech.jellyfin.ui.theme.FindroidTheme
import dev.jdtech.jellyfin.ui.theme.spacings
import kotlinx.parcelize.Parcelize
import dev.jdtech.jellyfin.core.R as CoreR
import dev.jdtech.jellyfin.player.video.R as PlayerVideoR
@Parcelize
data class VideoPlayerTrackSelectorDialogResult(
val trackType: @C.TrackType Int,
val index: Int,
) : Parcelable
@OptIn(ExperimentalTvMaterial3Api::class)
@Destination(style = BaseDialogStyle::class)
@Composable
fun VideoPlayerTrackSelectorDialog(
trackType: @C.TrackType Int,
tracks: Array<Track>,
resultNavigator: ResultBackNavigator<VideoPlayerTrackSelectorDialogResult>,
) {
val dialogTitle = when (trackType) {
C.TRACK_TYPE_AUDIO -> PlayerVideoR.string.select_audio_track
C.TRACK_TYPE_TEXT -> PlayerVideoR.string.select_subtile_track
else -> CoreR.string.unknown_error
}
Surface {
Column(
modifier = Modifier.padding(MaterialTheme.spacings.medium),
) {
Text(
text = stringResource(id = dialogTitle),
style = MaterialTheme.typography.headlineMedium,
)
Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium))
TvLazyColumn(
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium - MaterialTheme.spacings.extraSmall),
contentPadding = PaddingValues(vertical = MaterialTheme.spacings.extraSmall),
) {
items(tracks) { track ->
Surface(
onClick = {
resultNavigator.navigateBack(result = VideoPlayerTrackSelectorDialogResult(trackType, track.id))
},
enabled = track.supported,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
BorderStroke(
4.dp,
Color.White,
),
shape = RoundedCornerShape(10.dp),
),
),
scale = ClickableSurfaceScale.None,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(MaterialTheme.spacings.extraSmall),
) {
RadioButton(
selected = track.selected,
onClick = null,
enabled = true,
)
Spacer(modifier = Modifier.width(MaterialTheme.spacings.medium))
Text(
text = listOf(track.label, track.language, track.codec)
.mapNotNull { it }
.joinToString(" - ")
.ifEmpty { stringResource(id = PlayerVideoR.string.none) },
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
}
}
}
}
@Preview
@Composable
private fun VideoPlayerTrackSelectorDialogPreview() {
FindroidTheme {
VideoPlayerTrackSelectorDialog(
trackType = C.TRACK_TYPE_AUDIO,
tracks = arrayOf(
Track(
id = 0,
label = null,
language = "English",
codec = "flac",
selected = true,
supported = true,
),
Track(
id = 0,
label = null,
language = "Japanese",
codec = "flac",
selected = false,
supported = true,
),
Track(
id = 0,
label = null,
language = "English",
codec = "truehd",
selected = false,
supported = false,
),
),
resultNavigator = EmptyResultBackNavigator(),
)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,58 @@
package dev.jdtech.jellyfin.ui.theme
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.NonInteractiveSurfaceDefaults
import androidx.tv.material3.Surface
import androidx.tv.material3.MaterialTheme as MaterialThemeTv
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun FindroidTheme(
content: @Composable BoxScope.() -> Unit,
) {
MaterialTheme(
colorScheme = ColorScheme,
typography = Typography,
shapes = shapes,
) {
CompositionLocalProvider(
LocalSpacings provides Spacings(),
) {
MaterialThemeTv(
colorScheme = ColorSchemeTv,
typography = TypographyTv,
shapes = shapesTv,
content = {
Surface(
colors = NonInteractiveSurfaceDefaults.colors(
containerColor = androidx.tv.material3.MaterialTheme.colorScheme.background,
),
shape = RectangleShape,
) {
Box(
modifier = Modifier.background(
Brush.linearGradient(
listOf(
Color.Black,
Color(0xFF001721),
),
),
),
content = content,
)
}
},
)
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -120,7 +120,6 @@
<string name="theme_dark">Oscuro</string>
<string name="settings_category_network">Red</string>
<string name="pref_player_mpv_hwdec">Decodificación por hardware</string>
<string name="pref_player_mpv_hwdec_codecs">Códecs de decodificación por hardware</string>
<string name="pref_player_mpv_vo">Salida de vídeo</string>
<string name="pref_player_mpv_ao">Salida de audio</string>
<string name="addresses">Direcciones</string>

View file

@ -108,7 +108,6 @@
<string name="sort_by_options_1">Classificação IMDB</string>
<string name="sort_by_options_3">data adicionada</string>
<string name="seek_back_increment">Buscar incremento de volta (ms)</string>
<string name="pref_player_mpv_hwdec_codecs">Codecs de decodificação de hardware</string>
<string name="pref_player_mpv_vo">Saida de video</string>
<string name="pref_player_intro_skipper_summary">Requer que o plugin Confused Polar Bears Intro Skipper esteja instalado no servidor</string>
<string name="remove_user">Remover usuário</string>

View file

@ -134,7 +134,6 @@
<string name="pref_player_intro_skipper_summary">Benötigt ConfusedPolarBears Intro Skipper Plugin installiert auf dem Server</string>
<string name="theme_system">Übernehme Systemeinstellung</string>
<string name="pref_player_mpv_hwdec">Hardware-Dekodierung</string>
<string name="pref_player_mpv_hwdec_codecs">Hardware-Dekodierung Codecs</string>
<string name="pref_player_mpv_vo">Videoausgang</string>
<string name="pref_player_mpv_ao">Audioausgang</string>
<string name="pref_player_trick_play">Trickspiel</string>

View file

@ -128,7 +128,6 @@
<string name="users">Usuarios</string>
<string name="add_user">Agregar usuario</string>
<string name="pref_player_mpv_hwdec">Decodificación por hardware</string>
<string name="pref_player_mpv_hwdec_codecs">Codecs con decodificación por hardware</string>
<string name="pref_player_mpv_vo">Salida de video</string>
<string name="pref_player_mpv_ao">Salida de audio</string>
<string name="addresses">Direcciones</string>

View file

@ -125,7 +125,6 @@
<string name="settings_socket_timeout">Esperar zócalo (ms)</string>
<string name="users">Usuarios</string>
<string name="add_user">Agregar usuario</string>
<string name="pref_player_mpv_hwdec_codecs">Codecs de decodificación por hardware</string>
<string name="pref_player_mpv_vo">Salida de video</string>
<string name="pref_player_mpv_ao">Salida de audio</string>
<string name="remove_user">Quitar usuario</string>

View file

@ -123,7 +123,6 @@
<string name="remove_user">Supprimer l\'utilisateur</string>
<string name="remove_user_dialog_text">Voulez-vous vraiment supprimer l\'utilisateur %1$s</string>
<string name="users">Utilisateurs</string>
<string name="pref_player_mpv_hwdec_codecs">Décodages matériels supportés</string>
<string name="quick_connect">Connexion rapide</string>
<string name="pref_player_intro_skipper">Passer l\'introduction</string>
<string name="pref_player_intro_skipper_summary">Le plugin Intro Skipper de ConfusedPolarBear doit être installé sur le serveur</string>

View file

@ -128,7 +128,6 @@
<string name="settings_connect_timeout">Csatlakozási időtúllépés (ms)</string>
<string name="add_user">Felhasználó hozzádása</string>
<string name="pref_player_mpv_hwdec">Hardveres dekódolás</string>
<string name="pref_player_mpv_hwdec_codecs">Hardveres dekódolási kodekek</string>
<string name="settings_socket_timeout">Socket időtúllépése (ms)</string>
<string name="settings_request_timeout">Lekérdezési idő túllépés (ms)</string>
<string name="dynamic_colors">Dinamikus színek</string>
@ -176,4 +175,14 @@
<string name="picture_in_picture">Kép a képben</string>
<string name="picture_in_picture_gesture">Kép a képben otthoni gesztus</string>
<string name="picture_in_picture_gesture_summary">A home gomb vagy a gesztus használatával lépjen be a kép-a-képbe a videó lejátszása közben</string>
<string name="no_users_found">Nincs felhasználó</string>
<string name="select_user">Válassz felhasználót</string>
<string name="no_servers_found">Nem található szerver</string>
<string name="unmark_as_played">Megjelölés nem megtekintettként</string>
<string name="add_to_favorites">Hozzáadás kedvencekhez</string>
<string name="remove_from_favorites">Eltávolítás kedvencekből</string>
<string name="mark_as_played">Jelölés megtekintettként</string>
<string name="live_tv">Élő TV</string>
<string name="play">Lejátszás</string>
<string name="watch_trailer">Előzetes megtekintése</string>
</resources>

View file

@ -28,7 +28,7 @@
<string name="director">Regista</string>
<string name="cast_amp_crew">Cast</string>
<string name="seasons">Stagioni</string>
<string name="play_button_description">Riproduci</string>
<string name="play_button_description">Riproduci i contenuti multimediali</string>
<string name="check_button_description">Segna come visto o non visto</string>
<string name="favorite_button_description">Preferito</string>
<string name="episode_watched_indicator">Indicatore episodio visto</string>
@ -128,7 +128,6 @@
<string name="users">Utenti</string>
<string name="add_user">Aggiungi utente</string>
<string name="pref_player_mpv_hwdec">Decodifica hardware</string>
<string name="pref_player_mpv_hwdec_codecs">Codec di decodifica hardware</string>
<string name="pref_player_mpv_vo">Output video</string>
<string name="addresses">Indirizzi</string>
<string name="add_address">Aggiungi indirizzo</string>
@ -176,4 +175,14 @@
<string name="picture_in_picture">Immagine nell\'immagine</string>
<string name="picture_in_picture_gesture">Gesto/pulsante home</string>
<string name="picture_in_picture_gesture_summary">Usa il gesto/pulsante Home durante la riproduzione del video per attivare la modalità immagine nell\'immagine</string>
<string name="no_servers_found">Nessun server trovato</string>
<string name="no_users_found">Nessun utente trovato</string>
<string name="select_user">Seleziona utente</string>
<string name="watch_trailer">Guarda Trailer</string>
<string name="add_to_favorites">Aggiungi ai preferiti</string>
<string name="mark_as_played">Segna come visto</string>
<string name="unmark_as_played">Segna come non visto</string>
<string name="live_tv">Diretta TV</string>
<string name="play">Riproduci</string>
<string name="remove_from_favorites">Rimuovi dai preferiti</string>
</resources>

View file

@ -117,7 +117,6 @@
<string name="subtitle">כתוביות</string>
<string name="extra_info">הצג מידע נוסף</string>
<string name="settings_socket_timeout">זמן קצוב ל-Socket (מילי שניות)</string>
<string name="pref_player_mpv_hwdec_codecs">מפענחי חומרה</string>
<string name="pref_player_mpv_vo">יציאת וידאו</string>
<string name="pref_player_mpv_ao">יציאת שמע</string>
<string name="pref_player_intro_skipper">מדלג פתיחים</string>

View file

@ -128,7 +128,6 @@
<string name="add_user">유저 추가</string>
<string name="pref_player_mpv_hwdec">하드웨어 디코딩</string>
<string name="add_server_address">서버 주소 추가</string>
<string name="pref_player_mpv_hwdec_codecs">하드웨어 디코딩 코덱</string>
<string name="pref_player_mpv_vo">비디오 출력</string>
<string name="pref_player_mpv_ao">오디오 출력</string>
<string name="addresses">주소</string>

View file

@ -70,7 +70,6 @@
<string name="seek_forward_increment">Zoek vooruitstap (ms)</string>
<string name="select_video_version_title">Selecteer versie</string>
<string name="pref_player_mpv_hwdec">Hardware decoding</string>
<string name="pref_player_mpv_hwdec_codecs">Hardware decodering codecs</string>
<string name="pref_player_mpv_vo">Video uitvoer</string>
<string name="pref_player_mpv_ao">Audio uitvoer</string>
<string name="libraries">Bibliotheken</string>

View file

@ -122,7 +122,6 @@
<string name="theme_dark">Ciemny</string>
<string name="episodes_label">Odcinki</string>
<string name="add_user">Dodaj użytkownika</string>
<string name="pref_player_mpv_hwdec_codecs">Sprzętowe kodeki do dekodowania</string>
<string name="pref_player_mpv_vo">Wyjście wideo</string>
<string name="pref_player_mpv_ao">Wyjście audio</string>
<string name="seek_back_increment">Krok przesuwania wstecznego (ms)</string>

View file

@ -115,7 +115,6 @@
<string name="users">Usuários</string>
<string name="add_user">Adicionar usuário</string>
<string name="pref_player_mpv_hwdec">Decodificação de hardware</string>
<string name="pref_player_mpv_hwdec_codecs">Codecs de decodificação de hardware</string>
<string name="sort_by_options_3">Data de Adição</string>
<string name="sort_by_options_4">Data de Reprodução</string>
<string name="ascending">Crescente</string>
@ -176,4 +175,14 @@
<string name="picture_in_picture">Picture-in-picture</string>
<string name="picture_in_picture_gesture">Picture-in-Picture</string>
<string name="picture_in_picture_gesture_summary">Use o botão home ou gestos para entrar no modo picture-in-picture enquanto o vídeo está sendo reproduzido</string>
<string name="no_servers_found">Nenhum servidor encontrado</string>
<string name="mark_as_played">Marcar como reproduzido</string>
<string name="unmark_as_played">Desmarcar como reproduzido</string>
<string name="add_to_favorites">Adicionar aos favoritos</string>
<string name="remove_from_favorites">Remover dos favoritos</string>
<string name="no_users_found">Usuários não encontrados</string>
<string name="select_user">Selecione o usuário</string>
<string name="live_tv">TV ao vivo</string>
<string name="play">Reproduzir</string>
<string name="watch_trailer">Assista o trailer</string>
</resources>

View file

@ -124,7 +124,6 @@
<string name="remove_user_dialog_text">Tem certeza de que deseja remover o usuário %1$s</string>
<string name="pref_player_mpv_vo">Saida de video</string>
<string name="pref_player_mpv_ao">Saída de áudio</string>
<string name="pref_player_mpv_hwdec_codecs">Codecs de decodificação de hardware</string>
<string name="add_address">Adicionar endereço</string>
<string name="pref_player_trick_play">Jogo de truque</string>
<string name="episode_name">%1$d. %2$s</string>

View file

@ -77,7 +77,6 @@
<string name="users">Utilizatori</string>
<string name="add_user">Adaugă un utilizator</string>
<string name="pref_player_mpv_hwdec">Decodare hardware</string>
<string name="pref_player_mpv_hwdec_codecs">Codecuri de decodare hardware</string>
<string name="pref_player_mpv_vo">Ieșire video</string>
<string name="pref_player_mpv_ao">Ieșire audio</string>
<string name="pref_player_intro_skipper_summary">Necesită ca pluginul IntroSkipper de ConfusedPolarBear să fie instalat pe server</string>

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