diff --git a/.idea/gradle.properties b/.idea/gradle.properties
new file mode 100644
index 00000000..e69de29b
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 938ceeb1..4742ed1a 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -54,15 +54,24 @@ android {
}
dependencies {
- implementation("androidx.core:core-ktx:1.6.0")
- implementation("androidx.core:core-splashscreen:1.0.0-alpha01")
- implementation("androidx.appcompat:appcompat:1.3.1")
+ implementation("androidx.leanback:leanback:1.2.0-alpha02")
+
+ implementation("androidx.core:core-ktx:1.7.0")
+ implementation("androidx.core:core-splashscreen:1.0.0-alpha02")
+ implementation("androidx.appcompat:appcompat:1.4.0")
+ implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
+
// Material
implementation("com.google.android.material:material:1.4.0")
// ConstraintLayout
- implementation("androidx.constraintlayout:constraintlayout:2.1.0")
+ implementation("androidx.constraintlayout:constraintlayout:2.1.2")
+
+ // Lifecycle
+ val lifecycleVersion = "2.4.0"
+ implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion")
// Navigation
val navigationVersion = "2.3.5"
@@ -84,8 +93,8 @@ dependencies {
implementation("androidx.preference:preference-ktx:$preferenceVersion")
// Jellyfin
- val jellyfinVersion = "1.0.3"
- implementation("org.jellyfin.sdk:jellyfin-platform-android:$jellyfinVersion")
+ val jellyfinVersion = "1.1.1"
+ implementation("org.jellyfin.sdk:jellyfin-core:$jellyfinVersion")
// Glide
val glideVersion = "4.12.0"
@@ -93,12 +102,12 @@ dependencies {
kapt("com.github.bumptech.glide:compiler:$glideVersion")
// Hilt
- val hiltVersion = "2.38.1"
+ val hiltVersion = "2.40.2"
implementation("com.google.dagger:hilt-android:$hiltVersion")
kapt("com.google.dagger:hilt-compiler:$hiltVersion")
// ExoPlayer
- val exoplayerVersion = "2.15.0"
+ val exoplayerVersion = "2.15.1"
implementation("com.google.android.exoplayer:exoplayer-core:$exoplayerVersion")
implementation("com.google.android.exoplayer:exoplayer-ui:$exoplayerVersion")
implementation(files("libs/extension-ffmpeg-release.aar"))
@@ -110,7 +119,7 @@ dependencies {
val timberVersion = "5.0.1"
implementation("com.jakewharton.timber:timber:$timberVersion")
- val aboutLibrariesVersion = "8.9.1"
+ val aboutLibrariesVersion = "8.9.4"
implementation("com.mikepenz:aboutlibraries-core:$aboutLibrariesVersion")
implementation("com.mikepenz:aboutlibraries:$aboutLibrariesVersion")
diff --git a/app/src/debug/ic_launcher-playstore.png b/app/src/debug/ic_launcher-playstore.png
new file mode 100644
index 00000000..89993b46
Binary files /dev/null and b/app/src/debug/ic_launcher-playstore.png differ
diff --git a/app/src/debug/res/drawable/ic_launcher_foreground.xml b/app/src/debug/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 00000000..f3cabe78
--- /dev/null
+++ b/app/src/debug/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..7353dbd1
--- /dev/null
+++ b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..7353dbd1
--- /dev/null
+++ b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher.png b/app/src/debug/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..9ed18f99
Binary files /dev/null and b/app/src/debug/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 00000000..1f2cceab
Binary files /dev/null and b/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher.png b/app/src/debug/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..1a18e884
Binary files /dev/null and b/app/src/debug/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 00000000..225f0e87
Binary files /dev/null and b/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..254f5a13
Binary files /dev/null and b/app/src/debug/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..55e0dfbf
Binary files /dev/null and b/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..f6149931
Binary files /dev/null and b/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..555fcdc5
Binary files /dev/null and b/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..24f2ca73
Binary files /dev/null and b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..63b57bce
Binary files /dev/null and b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/debug/res/values/ic_launcher_background.xml b/app/src/debug/res/values/ic_launcher_background.xml
new file mode 100644
index 00000000..beab31f7
--- /dev/null
+++ b/app/src/debug/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #000000
+
\ No newline at end of file
diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml
new file mode 100644
index 00000000..63e4945f
--- /dev/null
+++ b/app/src/debug/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Findroid Debug
+
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8afba6f1..81895a50 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,28 +4,55 @@
+
+
+
+
+
+
+
+ android:theme="@style/Theme.FindroidSplashScreen"
+ android:windowSoftInputMode="adjustResize">
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/dev/jdtech/jellyfin/BasePlayerActivity.kt b/app/src/main/java/dev/jdtech/jellyfin/BasePlayerActivity.kt
new file mode 100644
index 00000000..806fcf3b
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/BasePlayerActivity.kt
@@ -0,0 +1,69 @@
+package dev.jdtech.jellyfin
+
+import android.os.Build
+import android.view.View
+import android.view.WindowManager
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.updatePadding
+import com.google.android.exoplayer2.trackselection.MappingTrackSelector
+import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
+
+abstract class BasePlayerActivity: AppCompatActivity() {
+
+ abstract val viewModel: PlayerActivityViewModel
+
+ override fun onPause() {
+ super.onPause()
+ viewModel.playWhenReady = viewModel.player.playWhenReady == true
+ viewModel.player.playWhenReady = false
+ }
+
+ override fun onResume() {
+ super.onResume()
+ viewModel.player.playWhenReady = viewModel.playWhenReady
+ hideSystemUI()
+ }
+
+ @Suppress("DEPRECATION")
+ protected fun hideSystemUI() {
+ // These methods are deprecated but we still use them because the new WindowInsetsControllerCompat has a bug which makes the action bar reappear
+ window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
+ View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
+ View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
+
+ window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ window.attributes.layoutInDisplayCutoutMode =
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
+ }
+ }
+
+ protected fun isRendererType(
+ mappedTrackInfo: MappingTrackSelector.MappedTrackInfo,
+ rendererIndex: Int,
+ type: Int
+ ): Boolean {
+ val trackGroupArray = mappedTrackInfo.getTrackGroups(rendererIndex)
+ if (trackGroupArray.length == 0) {
+ return false
+ }
+ val trackType = mappedTrackInfo.getRendererType(rendererIndex)
+ return type == trackType
+ }
+
+ protected fun configureInsets(playerControls: View) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ playerControls
+ .setOnApplyWindowInsetsListener { _, windowInsets ->
+ val cutout = windowInsets.displayCutout
+ playerControls.updatePadding(
+ left = cutout?.safeInsetLeft ?: 0,
+ top = cutout?.safeInsetTop ?: 0,
+ right = cutout?.safeInsetRight ?: 0,
+ bottom = cutout?.safeInsetBottom ?: 0,
+ )
+ return@setOnApplyWindowInsetsListener windowInsets
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/BindingAdapters.kt b/app/src/main/java/dev/jdtech/jellyfin/BindingAdapters.kt
index c336aeb1..42a7de62 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/BindingAdapters.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/BindingAdapters.kt
@@ -1,18 +1,25 @@
package dev.jdtech.jellyfin
+import android.view.View
import android.widget.ImageView
import androidx.databinding.BindingAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
-import dev.jdtech.jellyfin.adapters.*
+import dev.jdtech.jellyfin.adapters.DownloadsListAdapter
+import dev.jdtech.jellyfin.adapters.HomeEpisodeListAdapter
+import dev.jdtech.jellyfin.adapters.HomeItem
+import dev.jdtech.jellyfin.adapters.PersonListAdapter
+import dev.jdtech.jellyfin.adapters.ServerGridAdapter
+import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
+import dev.jdtech.jellyfin.adapters.ViewListAdapter
import dev.jdtech.jellyfin.api.JellyfinApi
import dev.jdtech.jellyfin.database.Server
-import dev.jdtech.jellyfin.models.FavoriteSection
+import dev.jdtech.jellyfin.models.DownloadSection
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.ImageType
-import java.util.*
+import java.util.UUID
@BindingAdapter("servers")
fun bindServers(recyclerView: RecyclerView, data: List?) {
@@ -20,12 +27,6 @@ fun bindServers(recyclerView: RecyclerView, data: List?) {
adapter.submitList(data)
}
-@BindingAdapter("views")
-fun bindViews(recyclerView: RecyclerView, data: List?) {
- val adapter = recyclerView.adapter as ViewListAdapter
- adapter.submitList(data)
-}
-
@BindingAdapter("items")
fun bindItems(recyclerView: RecyclerView, data: List?) {
val adapter = recyclerView.adapter as ViewItemListAdapter
@@ -34,50 +35,26 @@ fun bindItems(recyclerView: RecyclerView, data: List?) {
@BindingAdapter("itemImage")
fun bindItemImage(imageView: ImageView, item: BaseItemDto) {
- val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
-
val itemId =
if (item.type == "Episode" || item.type == "Season" && item.imageTags.isNullOrEmpty()) item.seriesId else item.id
- Glide
- .with(imageView.context)
- .load(jellyfinApi.api.baseUrl.plus("/items/${itemId}/Images/${ImageType.PRIMARY}"))
- .transition(DrawableTransitionOptions.withCrossFade())
- .placeholder(R.color.neutral_800)
- .into(imageView)
-
- imageView.contentDescription = "${item.name} poster"
+ imageView
+ .loadImage("/items/$itemId/Images/${ImageType.PRIMARY}")
+ .posterDescription(item.name)
}
@BindingAdapter("itemBackdropImage")
fun bindItemBackdropImage(imageView: ImageView, item: BaseItemDto?) {
if (item == null) return
- val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
- Glide
- .with(imageView.context)
- .load(jellyfinApi.api.baseUrl.plus("/items/${item.id}/Images/${ImageType.BACKDROP}"))
- .transition(DrawableTransitionOptions.withCrossFade())
- .into(imageView)
-
- imageView.contentDescription = "${item.name} backdrop"
+ imageView
+ .loadImage("/items/${item.id}/Images/${ImageType.BACKDROP}")
+ .backdropDescription(item.name)
}
@BindingAdapter("itemBackdropById")
fun bindItemBackdropById(imageView: ImageView, itemId: UUID) {
- val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
-
- Glide
- .with(imageView.context)
- .load(jellyfinApi.api.baseUrl.plus("/items/${itemId}/Images/${ImageType.BACKDROP}"))
- .transition(DrawableTransitionOptions.withCrossFade())
- .into(imageView)
-}
-
-@BindingAdapter("collections")
-fun bindCollections(recyclerView: RecyclerView, data: List?) {
- val adapter = recyclerView.adapter as CollectionListAdapter
- adapter.submitList(data)
+ imageView.loadImage("/items/$itemId/Images/${ImageType.BACKDROP}")
}
@BindingAdapter("people")
@@ -88,22 +65,9 @@ fun bindPeople(recyclerView: RecyclerView, data: List?) {
@BindingAdapter("personImage")
fun bindPersonImage(imageView: ImageView, person: BaseItemPerson) {
- val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
-
- Glide
- .with(imageView.context)
- .load(jellyfinApi.api.baseUrl.plus("/items/${person.id}/Images/${ImageType.PRIMARY}"))
- .transition(DrawableTransitionOptions.withCrossFade())
- .placeholder(R.color.neutral_800)
- .into(imageView)
-
- imageView.contentDescription = "${person.name} poster"
-}
-
-@BindingAdapter("episodes")
-fun bindEpisodes(recyclerView: RecyclerView, data: List?) {
- val adapter = recyclerView.adapter as EpisodeListAdapter
- adapter.submitList(data)
+ imageView
+ .loadImage("/items/${person.id}/Images/${ImageType.PRIMARY}")
+ .posterDescription(person.name)
}
@BindingAdapter("homeEpisodes")
@@ -116,12 +80,10 @@ fun bindHomeEpisodes(recyclerView: RecyclerView, data: List?) {
fun bindBaseItemImage(imageView: ImageView, episode: BaseItemDto?) {
if (episode == null) return
- val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
-
var imageItemId = episode.id
var imageType = ImageType.PRIMARY
- if (!episode.imageTags.isNullOrEmpty()) {
+ if (!episode.imageTags.isNullOrEmpty()) { //TODO: Downloadmetadata currently does not store imagetags, so it always uses the backdrop
when (episode.type) {
"Movie" -> {
if (!episode.backdropImageTags.isNullOrEmpty()) {
@@ -141,30 +103,33 @@ fun bindBaseItemImage(imageView: ImageView, episode: BaseItemDto?) {
}
}
- Glide
- .with(imageView.context)
- .load(jellyfinApi.api.baseUrl.plus("/items/${imageItemId}/Images/$imageType"))
- .transition(DrawableTransitionOptions.withCrossFade())
- .placeholder(R.color.neutral_800)
- .into(imageView)
-
- imageView.contentDescription = "${episode.name} poster"
+ imageView
+ .loadImage("/items/${imageItemId}/Images/$imageType")
+ .posterDescription(episode.name)
}
@BindingAdapter("seasonPoster")
fun bindSeasonPoster(imageView: ImageView, seasonId: UUID) {
- val jellyfinApi = JellyfinApi.getInstance(imageView.context.applicationContext, "")
-
- Glide
- .with(imageView.context)
- .load(jellyfinApi.api.baseUrl.plus("/items/${seasonId}/Images/${ImageType.PRIMARY}"))
- .transition(DrawableTransitionOptions.withCrossFade())
- .placeholder(R.color.neutral_800)
- .into(imageView)
+ imageView.loadImage("/items/${seasonId}/Images/${ImageType.PRIMARY}")
}
-@BindingAdapter("favoriteSections")
-fun bindFavoriteSections(recyclerView: RecyclerView, data: List?) {
- val adapter = recyclerView.adapter as FavoritesListAdapter
- adapter.submitList(data)
+private fun ImageView.loadImage(url: String, errorPlaceHolderId: Int? = null): View {
+ val api = JellyfinApi.getInstance(context.applicationContext)
+
+ return Glide
+ .with(context)
+ .load("${api.api.baseUrl}$url")
+ .transition(DrawableTransitionOptions.withCrossFade())
+ .placeholder(R.color.neutral_800)
+ .also { if (errorPlaceHolderId != null) error(errorPlaceHolderId) }
+ .into(this)
+ .view
+}
+
+private fun View.posterDescription(name: String?) {
+ contentDescription = String.format(context.resources.getString(R.string.image_description_poster), name)
+}
+
+private fun View.backdropDescription(name: String?) {
+ contentDescription = String.format(context.resources.getString(R.string.image_description_backdrop), name)
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt b/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
index ca3969bd..0e38ca67 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
@@ -3,29 +3,30 @@ package dev.jdtech.jellyfin
import android.os.Bundle
import android.view.View
import androidx.activity.viewModels
-import com.google.android.material.bottomnavigation.BottomNavigationView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
+import com.google.android.material.bottomnavigation.BottomNavigationView
import dagger.hilt.android.AndroidEntryPoint
-import dev.jdtech.jellyfin.databinding.ActivityMainBinding
-import dev.jdtech.jellyfin.fragments.InitializingFragmentDirections
+import dev.jdtech.jellyfin.databinding.ActivityMainAppBinding
+import dev.jdtech.jellyfin.fragments.HomeFragmentDirections
+import dev.jdtech.jellyfin.utils.loadDownloadLocation
import dev.jdtech.jellyfin.viewmodels.MainViewModel
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
- private lateinit var binding: ActivityMainBinding
+ private lateinit var binding: ActivityMainAppBinding
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
- binding = ActivityMainBinding.inflate(layoutInflater)
+ binding = ActivityMainAppBinding.inflate(layoutInflater)
setContentView(binding.root)
@@ -42,7 +43,7 @@ class MainActivity : AppCompatActivity() {
// menu should be considered as top level destinations.
val appBarConfiguration = AppBarConfiguration(
setOf(
- R.id.homeFragment, R.id.mediaFragment, R.id.favoriteFragment
+ R.id.homeFragment, R.id.mediaFragment, R.id.favoriteFragment, R.id.downloadFragment
)
)
@@ -57,20 +58,14 @@ class MainActivity : AppCompatActivity() {
if (destination.id == R.id.about_libraries_dest) binding.mainToolbar.title = getString(R.string.app_info)
}
+ loadDownloadLocation(applicationContext)
+
viewModel.navigateToAddServer.observe(this, {
if (it) {
- navController.navigate(InitializingFragmentDirections.actionInitializingFragmentToAddServerFragment3())
+ navController.navigate(HomeFragmentDirections.actionHomeFragmentToAddServerFragment())
viewModel.doneNavigateToAddServer()
}
})
-
- viewModel.doneLoading.observe(this, {
- if (it) {
- if (navController.currentDestination!!.id == R.id.initializingFragment) {
- navController.navigate(InitializingFragmentDirections.actionInitializingFragmentToNavigationHome())
- }
- }
- })
}
override fun onSupportNavigateUp(): Boolean {
diff --git a/app/src/main/java/dev/jdtech/jellyfin/MainActivityTv.kt b/app/src/main/java/dev/jdtech/jellyfin/MainActivityTv.kt
new file mode 100644
index 00000000..f67222bf
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/MainActivityTv.kt
@@ -0,0 +1,37 @@
+package dev.jdtech.jellyfin
+
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.fragment.app.FragmentActivity
+import androidx.navigation.fragment.NavHostFragment
+import dagger.hilt.android.AndroidEntryPoint
+import dev.jdtech.jellyfin.databinding.ActivityMainTvBinding
+import dev.jdtech.jellyfin.tv.ui.HomeFragmentDirections
+import dev.jdtech.jellyfin.utils.loadDownloadLocation
+import dev.jdtech.jellyfin.viewmodels.MainViewModel
+
+@AndroidEntryPoint
+internal class MainActivityTv : FragmentActivity() {
+
+ private lateinit var binding: ActivityMainTvBinding
+ private val viewModel: MainViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ binding = ActivityMainTvBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ val navHostFragment =
+ supportFragmentManager.findFragmentById(R.id.tv_nav_host) as NavHostFragment
+ val navController = navHostFragment.navController
+
+ loadDownloadLocation(applicationContext)
+
+ viewModel.navigateToAddServer.observe(this, {
+ if (it) {
+ navController.navigate(HomeFragmentDirections.actionHomeFragmentToAddServerFragment())
+ viewModel.doneNavigateToAddServer()
+ }
+ })
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt b/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt
index 321223ad..8cf8accb 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt
@@ -1,36 +1,39 @@
package dev.jdtech.jellyfin
-import android.os.Build
-import androidx.appcompat.app.AppCompatActivity
+import android.content.Context
+import android.media.AudioManager
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import android.widget.ImageButton
import android.widget.TextView
import androidx.activity.viewModels
-import androidx.core.view.updatePadding
import androidx.navigation.navArgs
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.SimpleExoPlayer
-import com.google.android.exoplayer2.trackselection.MappingTrackSelector
import com.google.android.exoplayer2.ui.TrackSelectionDialogBuilder
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.viewmodels.PlayerActivityViewModel
import timber.log.Timber
@AndroidEntryPoint
-class PlayerActivity : AppCompatActivity() {
- private lateinit var binding: ActivityPlayerBinding
- private val viewModel: PlayerActivityViewModel by viewModels()
+class PlayerActivity : BasePlayerActivity() {
+
+ lateinit var binding: ActivityPlayerBinding
+ private lateinit var playerGestureHelper: PlayerGestureHelper
+ override val viewModel: PlayerActivityViewModel by viewModels()
private val args: PlayerActivityArgs by navArgs()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Timber.d("Creating player activity")
+
binding = ActivityPlayerBinding.inflate(layoutInflater)
setContentView(binding.root)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@@ -39,19 +42,9 @@ class PlayerActivity : AppCompatActivity() {
val playerControls = binding.playerView.findViewById(R.id.player_controls)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- binding.playerView.findViewById(R.id.player_controls)
- .setOnApplyWindowInsetsListener { _, windowInsets ->
- val cutout = windowInsets.displayCutout
- playerControls.updatePadding(
- left = cutout?.safeInsetLeft ?: 0,
- top = cutout?.safeInsetTop ?: 0,
- right = cutout?.safeInsetRight ?: 0,
- bottom = cutout?.safeInsetBottom ?: 0,
- )
- return@setOnApplyWindowInsetsListener windowInsets
- }
- }
+ configureInsets(playerControls)
+
+ playerGestureHelper = PlayerGestureHelper(this, binding.playerView, getSystemService(Context.AUDIO_SERVICE) as AudioManager)
binding.playerView.findViewById(R.id.back_button).setOnClickListener {
onBackPressed()
@@ -65,6 +58,7 @@ class PlayerActivity : AppCompatActivity() {
val audioButton = binding.playerView.findViewById(R.id.btn_audio_track)
val subtitleButton = binding.playerView.findViewById(R.id.btn_subtitle)
+ val speedButton = binding.playerView.findViewById(R.id.btn_speed)
audioButton.isEnabled = false
audioButton.imageAlpha = 75
@@ -72,6 +66,9 @@ class PlayerActivity : AppCompatActivity() {
subtitleButton.isEnabled = false
subtitleButton.imageAlpha = 75
+ speedButton.isEnabled = false
+ speedButton.imageAlpha = 75
+
audioButton.setOnClickListener {
when (viewModel.player) {
is MPVPlayer -> {
@@ -134,12 +131,21 @@ class PlayerActivity : AppCompatActivity() {
}
}
+ speedButton.setOnClickListener {
+ SpeedSelectionDialogFragment(viewModel).show(
+ supportFragmentManager,
+ "speedselectiondialog"
+ )
+ }
+
viewModel.fileLoaded.observe(this, {
if (it) {
audioButton.isEnabled = true
audioButton.imageAlpha = 255
subtitleButton.isEnabled = true
subtitleButton.imageAlpha = 255
+ speedButton.isEnabled = true
+ speedButton.imageAlpha = 255
}
})
@@ -152,44 +158,5 @@ class PlayerActivity : AppCompatActivity() {
viewModel.initializePlayer(args.items)
hideSystemUI()
}
-
- override fun onPause() {
- super.onPause()
- viewModel.playWhenReady = viewModel.player.playWhenReady == true
- viewModel.player.playWhenReady = false
- }
-
- override fun onResume() {
- super.onResume()
- viewModel.player.playWhenReady = viewModel.playWhenReady
- hideSystemUI()
- }
-
- @Suppress("DEPRECATION")
- private fun hideSystemUI() {
- // These methods are deprecated but we still use them because the new WindowInsetsControllerCompat has a bug which makes the action bar reappear
- window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
- View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
- View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
-
- window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- window.attributes.layoutInDisplayCutoutMode =
- WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
- }
- }
-
- private fun isRendererType(
- mappedTrackInfo: MappingTrackSelector.MappedTrackInfo,
- rendererIndex: Int,
- type: Int
- ): Boolean {
- val trackGroupArray = mappedTrackInfo.getTrackGroups(rendererIndex)
- if (trackGroupArray.length == 0) {
- return false
- }
- val trackType = mappedTrackInfo.getRendererType(rendererIndex)
- return type == trackType
- }
}
diff --git a/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadEpisodeListAdapter.kt b/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadEpisodeListAdapter.kt
new file mode 100644
index 00000000..be1c8527
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadEpisodeListAdapter.kt
@@ -0,0 +1,68 @@
+package dev.jdtech.jellyfin.adapters
+
+import android.util.TypedValue
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import dev.jdtech.jellyfin.databinding.HomeEpisodeItemBinding
+import dev.jdtech.jellyfin.models.PlayerItem
+import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
+import timber.log.Timber
+
+class DownloadEpisodeListAdapter(private val onClickListener: OnClickListener) : ListAdapter(DiffCallback) {
+ class EpisodeViewHolder(private var binding: HomeEpisodeItemBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ fun bind(episode: PlayerItem) {
+ val metadata = episode.metadata!!
+ binding.episode = downloadMetadataToBaseItemDto(episode.metadata)
+ if (metadata.playedPercentage != null) {
+ binding.progressBar.layoutParams.width = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
+ (metadata.playedPercentage.times(2.24)).toFloat(), binding.progressBar.context.resources.displayMetrics).toInt()
+ binding.progressBar.visibility = View.VISIBLE
+ }
+ if (metadata.type == "Movie") {
+ binding.primaryName.text = metadata.name
+ Timber.d(metadata.name)
+ binding.secondaryName.visibility = View.GONE
+ } else if (metadata.type == "Episode") {
+ binding.primaryName.text = metadata.seriesName
+ }
+ binding.executePendingBindings()
+ }
+ }
+
+ companion object DiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean {
+ return oldItem.itemId == newItem.itemId
+ }
+
+ override fun areContentsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean {
+ return oldItem == newItem
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EpisodeViewHolder {
+ return EpisodeViewHolder(
+ HomeEpisodeItemBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ )
+ }
+
+ override fun onBindViewHolder(holder: EpisodeViewHolder, position: Int) {
+ val item = getItem(position)
+ holder.itemView.setOnClickListener {
+ onClickListener.onClick(item)
+ }
+ holder.bind(item)
+ }
+
+ class OnClickListener(val clickListener: (item: PlayerItem) -> Unit) {
+ fun onClick(item: PlayerItem) = clickListener(item)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadViewItemListAdapter.kt b/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadViewItemListAdapter.kt
new file mode 100644
index 00000000..b1c4ee4d
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadViewItemListAdapter.kt
@@ -0,0 +1,67 @@
+package dev.jdtech.jellyfin.adapters
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.databinding.BaseItemBinding
+import dev.jdtech.jellyfin.models.PlayerItem
+import dev.jdtech.jellyfin.utils.downloadMetadataToBaseItemDto
+
+class DownloadViewItemListAdapter(
+ private val onClickListener: OnClickListener,
+ private val fixedWidth: Boolean = false,
+ ) :
+ ListAdapter(DiffCallback) {
+
+ class ItemViewHolder(private var binding: BaseItemBinding, private val parent: ViewGroup) :
+ RecyclerView.ViewHolder(binding.root) {
+ fun bind(item: PlayerItem, fixedWidth: Boolean) {
+ val metadata = item.metadata!!
+ binding.item = downloadMetadataToBaseItemDto(metadata)
+ binding.itemName.text = if (metadata.type == "Episode") metadata.seriesName else item.name
+ binding.itemCount.visibility = View.GONE
+ if (fixedWidth) {
+ binding.itemLayout.layoutParams.width =
+ parent.resources.getDimension(R.dimen.overview_media_width).toInt()
+ (binding.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0
+ }
+ binding.executePendingBindings()
+ }
+ }
+
+ companion object DiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean {
+ return oldItem.itemId == newItem.itemId
+ }
+
+ override fun areContentsTheSame(oldItem: PlayerItem, newItem: PlayerItem): Boolean {
+ return oldItem == newItem
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
+ return ItemViewHolder(
+ BaseItemBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ ), parent
+ )
+ }
+
+ override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
+ val item = getItem(position)
+ holder.itemView.setOnClickListener {
+ onClickListener.onClick(item)
+ }
+ holder.bind(item, fixedWidth)
+ }
+
+ class OnClickListener(val clickListener: (item: PlayerItem) -> Unit) {
+ fun onClick(item: PlayerItem) = clickListener(item)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadsListAdapter.kt b/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadsListAdapter.kt
new file mode 100644
index 00000000..9a0866b9
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/adapters/DownloadsListAdapter.kt
@@ -0,0 +1,63 @@
+package dev.jdtech.jellyfin.adapters
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import dev.jdtech.jellyfin.databinding.DownloadSectionBinding
+import dev.jdtech.jellyfin.models.DownloadSection
+
+class DownloadsListAdapter(
+ private val onClickListener: DownloadViewItemListAdapter.OnClickListener,
+ private val onEpisodeClickListener: DownloadEpisodeListAdapter.OnClickListener
+) : ListAdapter(DiffCallback) {
+ class SectionViewHolder(private var binding: DownloadSectionBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ fun bind(
+ section: DownloadSection,
+ onClickListener: DownloadViewItemListAdapter.OnClickListener,
+ onEpisodeClickListener: DownloadEpisodeListAdapter.OnClickListener
+ ) {
+ binding.section = section
+ if (section.name == "Movies" || section.name == "Shows") {
+ binding.itemsRecyclerView.adapter =
+ DownloadViewItemListAdapter(onClickListener, fixedWidth = true)
+ (binding.itemsRecyclerView.adapter as DownloadViewItemListAdapter).submitList(section.items)
+ } else if (section.name == "Episodes") {
+ binding.itemsRecyclerView.adapter =
+ DownloadEpisodeListAdapter(onEpisodeClickListener)
+ (binding.itemsRecyclerView.adapter as DownloadEpisodeListAdapter).submitList(section.items)
+ }
+ binding.executePendingBindings()
+ }
+ }
+
+ companion object DiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: DownloadSection, newItem: DownloadSection): Boolean {
+ return oldItem.id == newItem.id
+ }
+
+ override fun areContentsTheSame(
+ oldItem: DownloadSection,
+ newItem: DownloadSection
+ ): Boolean {
+ return oldItem == newItem
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionViewHolder {
+ return SectionViewHolder(
+ DownloadSectionBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ )
+ }
+
+ override fun onBindViewHolder(holder: SectionViewHolder, position: Int) {
+ val collection = getItem(position)
+ holder.bind(collection, onClickListener, onEpisodeClickListener)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/adapters/PersonListAdapter.kt b/app/src/main/java/dev/jdtech/jellyfin/adapters/PersonListAdapter.kt
index b1654b53..1c85cfb1 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/adapters/PersonListAdapter.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/adapters/PersonListAdapter.kt
@@ -8,7 +8,8 @@ import androidx.recyclerview.widget.RecyclerView
import dev.jdtech.jellyfin.databinding.PersonItemBinding
import org.jellyfin.sdk.model.api.BaseItemPerson
-class PersonListAdapter :ListAdapter(DiffCallback) {
+class PersonListAdapter(private val clickListener: (item: BaseItemPerson) -> Unit) :ListAdapter(DiffCallback) {
+
class PersonViewHolder(private var binding: PersonItemBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(person: BaseItemPerson) {
@@ -40,5 +41,6 @@ class PersonListAdapter :ListAdapter(DiffCallback) {
+
class ViewViewHolder(private var binding: ViewItemBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(
@@ -50,7 +51,8 @@ class ViewListAdapter(
companion object DiffCallback : DiffUtil.ItemCallback() {
override fun areItemsTheSame(oldItem: HomeItem, newItem: HomeItem): Boolean {
- return oldItem.id == newItem.id
+ return oldItem.ids.size == newItem.ids.size
+ && oldItem.ids.mapIndexed { i, old -> old == newItem.ids[i] }.all { it }
}
override fun areContentsTheSame(oldItem: HomeItem, newItem: HomeItem): Boolean {
@@ -105,12 +107,12 @@ class ViewListAdapter(
sealed class HomeItem {
data class Section(val homeSection: HomeSection) : HomeItem() {
- override val id = homeSection.id
+ override val ids = homeSection.items.map { it.id }
}
data class ViewItem(val view: View) : HomeItem() {
- override val id = view.id
+ override val ids = view.items?.map { it.id }.orEmpty()
}
- abstract val id: UUID
+ abstract val ids: List
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/api/JellyfinApi.kt b/app/src/main/java/dev/jdtech/jellyfin/api/JellyfinApi.kt
index 31df12b2..c0086fad 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/api/JellyfinApi.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/api/JellyfinApi.kt
@@ -2,75 +2,62 @@ package dev.jdtech.jellyfin.api
import android.content.Context
import dev.jdtech.jellyfin.BuildConfig
-import org.jellyfin.sdk.Jellyfin
-import org.jellyfin.sdk.android
-import org.jellyfin.sdk.api.operations.*
+import org.jellyfin.sdk.api.client.extensions.devicesApi
+import org.jellyfin.sdk.api.client.extensions.itemsApi
+import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
+import org.jellyfin.sdk.api.client.extensions.playStateApi
+import org.jellyfin.sdk.api.client.extensions.sessionApi
+import org.jellyfin.sdk.api.client.extensions.systemApi
+import org.jellyfin.sdk.api.client.extensions.tvShowsApi
+import org.jellyfin.sdk.api.client.extensions.userApi
+import org.jellyfin.sdk.api.client.extensions.userLibraryApi
+import org.jellyfin.sdk.api.client.extensions.userViewsApi
+import org.jellyfin.sdk.api.client.extensions.videosApi
+import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
-import java.util.*
-
+import java.util.UUID
/**
* Jellyfin API class using org.jellyfin.sdk:jellyfin-platform-android
*
- * @param context The context
+ * @param androidContext The context
* @param baseUrl The url of the server
* @constructor Creates a new [JellyfinApi] instance
*/
-class JellyfinApi(context: Context, baseUrl: String) {
- val jellyfin = Jellyfin {
+class JellyfinApi(androidContext: Context) {
+ val jellyfin = createJellyfin {
clientInfo =
- ClientInfo(name = context.applicationInfo.loadLabel(context.packageManager).toString(), version = BuildConfig.VERSION_NAME)
- android(context)
+ ClientInfo(name = androidContext.applicationInfo.loadLabel(androidContext.packageManager).toString(), version = BuildConfig.VERSION_NAME)
+ context = androidContext
}
- val api = jellyfin.createApi(baseUrl = baseUrl)
+ val api = jellyfin.createApi()
var userId: UUID? = null
- val systemApi = SystemApi(api)
- val userApi = UserApi(api)
- val viewsApi = UserViewsApi(api)
- val itemsApi = ItemsApi(api)
- val userLibraryApi = UserLibraryApi(api)
- val showsApi = TvShowsApi(api)
- val sessionApi = SessionApi(api)
- val videosApi = VideosApi(api)
- val mediaInfoApi = MediaInfoApi(api)
- val playStateApi = PlayStateApi(api)
+ val devicesApi = api.devicesApi
+ val systemApi = api.systemApi
+ val userApi = api.userApi
+ val viewsApi = api.userViewsApi
+ val itemsApi = api.itemsApi
+ val userLibraryApi = api.userLibraryApi
+ val showsApi = api.tvShowsApi
+ val sessionApi = api.sessionApi
+ val videosApi = api.videosApi
+ val mediaInfoApi = api.mediaInfoApi
+ val playStateApi = api.playStateApi
companion object {
@Volatile
private var INSTANCE: JellyfinApi? = null
- /**
- * Creates or gets a new instance of [JellyfinApi]
- *
- * If there already is an instance, it will return that instance and ignore the [baseUrl] parameter
- *
- * @param context The context
- * @param baseUrl The url of the server
- */
- fun getInstance(context: Context, baseUrl: String): JellyfinApi {
+ fun getInstance(context: Context): JellyfinApi {
synchronized(this) {
var instance = INSTANCE
if (instance == null) {
- instance = JellyfinApi(context.applicationContext, baseUrl)
+ instance = JellyfinApi(context.applicationContext)
INSTANCE = instance
}
return instance
}
}
-
- /**
- * Create a new [JellyfinApi] instance
- *
- * @param context The context
- * @param baseUrl The url of the server
- */
- fun newInstance(context: Context, baseUrl: String): JellyfinApi {
- synchronized(this) {
- val instance = JellyfinApi(context.applicationContext, baseUrl)
- INSTANCE = instance
- return instance
- }
- }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/database/ServerDatabaseDao.kt b/app/src/main/java/dev/jdtech/jellyfin/database/ServerDatabaseDao.kt
index f7f3efef..9f3b0a98 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/database/ServerDatabaseDao.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/database/ServerDatabaseDao.kt
@@ -12,7 +12,7 @@ interface ServerDatabaseDao {
fun update(server: Server)
@Query("select * from servers where id = :id")
- fun get(id: String): Server
+ fun get(id: String): Server?
@Query("delete from servers")
fun clear()
diff --git a/app/src/main/java/dev/jdtech/jellyfin/di/ApiModule.kt b/app/src/main/java/dev/jdtech/jellyfin/di/ApiModule.kt
index 944b0fe6..8be4797c 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/di/ApiModule.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/di/ApiModule.kt
@@ -1,12 +1,15 @@
package dev.jdtech.jellyfin.di
import android.content.Context
+import android.content.SharedPreferences
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dev.jdtech.jellyfin.api.JellyfinApi
+import dev.jdtech.jellyfin.database.ServerDatabaseDao
+import java.util.UUID
import javax.inject.Singleton
@Module
@@ -14,7 +17,23 @@ import javax.inject.Singleton
object ApiModule {
@Singleton
@Provides
- fun provideJellyfinApi(@ApplicationContext application: Context): JellyfinApi {
- return JellyfinApi.getInstance(application, "")
+ fun provideJellyfinApi(
+ @ApplicationContext application: Context,
+ sharedPreferences: SharedPreferences,
+ serverDatabase: ServerDatabaseDao
+ ): JellyfinApi {
+ val jellyfinApi = JellyfinApi.getInstance(application)
+
+ val serverId = sharedPreferences.getString("selectedServer", null)
+ if (serverId != null) {
+ val server = serverDatabase.get(serverId) ?: return jellyfinApi
+ jellyfinApi.apply {
+ api.baseUrl = server.address
+ api.accessToken = server.accessToken
+ userId = UUID.fromString(server.userId)
+ }
+ }
+
+ return jellyfinApi
}
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/di/DatabaseModule.kt b/app/src/main/java/dev/jdtech/jellyfin/di/DatabaseModule.kt
index 8932892f..9f13b4e5 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/di/DatabaseModule.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/di/DatabaseModule.kt
@@ -23,6 +23,7 @@ object DatabaseModule {
"servers"
)
.fallbackToDestructiveMigration()
+ .allowMainThreadQueries()
.build()
.serverDatabaseDao
}
diff --git a/app/src/main/java/dev/jdtech/jellyfin/di/GlideModule.kt b/app/src/main/java/dev/jdtech/jellyfin/di/GlideModule.kt
new file mode 100644
index 00000000..05dc9ad0
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/di/GlideModule.kt
@@ -0,0 +1,51 @@
+package dev.jdtech.jellyfin.di
+
+import android.content.Context
+import androidx.preference.PreferenceManager
+import com.bumptech.glide.GlideBuilder
+import com.bumptech.glide.annotation.GlideModule
+import com.bumptech.glide.load.engine.DiskCacheStrategy.NONE
+import com.bumptech.glide.load.engine.DiskCacheStrategy.RESOURCE
+import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
+import com.bumptech.glide.module.AppGlideModule
+import com.bumptech.glide.request.RequestOptions
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+private const val cacheDefaultSize = 250
+
+@GlideModule
+class GlideModule : AppGlideModule() {
+
+ override fun applyOptions(context: Context, builder: GlideBuilder) {
+ val preferences = PreferenceManager.getDefaultSharedPreferences(context)
+ val use = preferences.getBoolean("use_image_cache", false)
+
+ if (use) {
+ val sizeMb = preferences.getString("image_cache_size", "$cacheDefaultSize")?.toInt()!!
+ val sizeB = 1024L * 1024 * sizeMb
+ Timber.d("Setting image cache to use $sizeMb MB of disk space")
+
+ builder.setDiskCache(InternalCacheDiskCacheFactory(context, sizeB))
+ builder.caching(enabled = true)
+ } else {
+ builder.caching(enabled = false)
+ Timber.d("Image cache disabled. Clearing all persisted data.")
+
+ MainScope().launch {
+ GlideApp.getPhotoCacheDir(context)?.also {
+ if (it.exists()) it.deleteRecursively()
+ }
+ }
+ }
+ }
+
+ private fun GlideBuilder.caching(enabled: Boolean) {
+ setDefaultRequestOptions(
+ RequestOptions().diskCacheStrategy(
+ if (enabled) RESOURCE else NONE
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/dialogs/DeleteServerDialogFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/dialogs/DeleteServerDialogFragment.kt
index 53bedbd5..73dfb56e 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/dialogs/DeleteServerDialogFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/dialogs/DeleteServerDialogFragment.kt
@@ -1,9 +1,9 @@
package dev.jdtech.jellyfin.dialogs
-import android.app.AlertDialog
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.database.Server
import dev.jdtech.jellyfin.viewmodels.ServerSelectViewModel
@@ -12,7 +12,7 @@ import java.lang.IllegalStateException
class DeleteServerDialogFragment(private val viewModel: ServerSelectViewModel, val server: Server) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return activity?.let {
- val builder = AlertDialog.Builder(it)
+ val builder = MaterialAlertDialogBuilder(it)
builder.setTitle(getString(R.string.remove_server))
.setMessage(getString(R.string.remove_server_dialog_text, server.name))
.setPositiveButton(getString(R.string.remove)) { _, _ ->
diff --git a/app/src/main/java/dev/jdtech/jellyfin/dialogs/ErrorDialogFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/dialogs/ErrorDialogFragment.kt
index 6f3ead62..35f386f5 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/dialogs/ErrorDialogFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/dialogs/ErrorDialogFragment.kt
@@ -14,9 +14,9 @@ class ErrorDialogFragment(private val errorMessage: String) : DialogFragment() {
val builder = MaterialAlertDialogBuilder(it, R.style.ErrorDialogStyle)
builder
.setMessage(errorMessage)
- .setPositiveButton("close") { _, _ ->
+ .setPositiveButton(getString(R.string.close)) { _, _ ->
}
- .setNeutralButton("share") { _, _ ->
+ .setNeutralButton(getString(R.string.share)) { _, _ ->
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, errorMessage)
diff --git a/app/src/main/java/dev/jdtech/jellyfin/dialogs/SortDialogFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/dialogs/SortDialogFragment.kt
new file mode 100644
index 00000000..50232b99
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/dialogs/SortDialogFragment.kt
@@ -0,0 +1,87 @@
+package dev.jdtech.jellyfin.dialogs
+
+import android.app.Dialog
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import androidx.preference.PreferenceManager
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.utils.SortBy
+import dev.jdtech.jellyfin.viewmodels.LibraryViewModel
+import org.jellyfin.sdk.model.api.SortOrder
+import java.lang.IllegalStateException
+import java.util.*
+
+class SortDialogFragment(
+ private val parentId: UUID,
+ private val libraryType: String?,
+ private val viewModel: LibraryViewModel,
+ private val sortType: String
+) : DialogFragment() {
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ return activity?.let {
+ val sp = PreferenceManager.getDefaultSharedPreferences(it.applicationContext)
+ val builder = MaterialAlertDialogBuilder(it)
+
+ // Current sort by
+ val currentSortByString = sp.getString("sortBy", SortBy.defaultValue.name)!!
+ val currentSortBy = SortBy.fromString(currentSortByString)
+
+ // Current sort order
+ val currentSortOrderString = sp.getString("sortOrder", SortOrder.ASCENDING.name)!!
+ val currentSortOrder = try {
+ SortOrder.valueOf(currentSortOrderString)
+ } catch (e: java.lang.IllegalArgumentException) {
+ SortOrder.ASCENDING
+ }
+
+ when (sortType) {
+ "sortBy" -> {
+ val sortByOptions = resources.getStringArray(R.array.sort_by_options)
+ val sortByValues = SortBy.values()
+ builder
+ .setTitle(getString(R.string.sort_by))
+ .setSingleChoiceItems(
+ sortByOptions, currentSortBy.ordinal
+ ) { dialog, which ->
+ sp.edit().putString("sortBy", sortByValues[which].name).apply()
+ viewModel.loadItems(
+ parentId,
+ libraryType,
+ sortBy = sortByValues[which],
+ sortOrder = currentSortOrder
+ )
+ dialog.dismiss()
+ }
+ }
+ "sortOrder" -> {
+ val sortByOptions = resources.getStringArray(R.array.sort_order_options)
+ val sortOrderValues = SortOrder.values()
+
+ builder
+ .setTitle(getString(R.string.sort_order))
+ .setSingleChoiceItems(
+ sortByOptions, currentSortOrder.ordinal
+ ) { dialog, which ->
+ sp.edit().putString("sortOrder", sortOrderValues[which].name).apply()
+
+ val sortOrder = try {
+ sortOrderValues[which]
+ } catch (e: IllegalArgumentException) {
+ SortOrder.ASCENDING
+ }
+
+ viewModel.loadItems(
+ parentId,
+ libraryType,
+ sortBy = currentSortBy,
+ sortOrder = sortOrder
+ )
+ dialog.dismiss()
+ }
+ }
+ }
+ builder.create()
+ } ?: throw IllegalStateException("Activity cannot be null")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/dialogs/SpeedSelectionDialogFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/dialogs/SpeedSelectionDialogFragment.kt
new file mode 100644
index 00000000..c651d1b9
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/dialogs/SpeedSelectionDialogFragment.kt
@@ -0,0 +1,35 @@
+package dev.jdtech.jellyfin.dialogs
+
+import android.app.Dialog
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
+import java.lang.IllegalStateException
+
+class SpeedSelectionDialogFragment(
+ private val viewModel: PlayerActivityViewModel
+) : DialogFragment() {
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val speedTexts = listOf("0.5x", "0.75x", "1x", "1.25x", "1.5x", "1.75x", "2x")
+ val speedNumbers = listOf(0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f)
+
+ return activity?.let { activity ->
+ val builder = MaterialAlertDialogBuilder(activity)
+ builder.setTitle(getString(R.string.select_playback_speed))
+ .setSingleChoiceItems(
+ speedTexts.toTypedArray(),
+ speedNumbers.indexOf(viewModel.playbackSpeed)
+ ) { dialog, which ->
+ viewModel.selectSpeed(
+ speedNumbers[which]
+ )
+ dialog.dismiss()
+ }
+ builder.create()
+ } ?: throw IllegalStateException("Activity cannot be null")
+
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/dialogs/TrackSelectionDialogFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/dialogs/TrackSelectionDialogFragment.kt
index be2edf9a..e16a9515 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/dialogs/TrackSelectionDialogFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/dialogs/TrackSelectionDialogFragment.kt
@@ -1,9 +1,10 @@
package dev.jdtech.jellyfin.dialogs
-import android.app.AlertDialog
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.mpv.TrackType
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
import java.lang.IllegalStateException
@@ -24,15 +25,16 @@ class TrackSelectionDialogFragment(
}
}
return activity?.let { activity ->
- val builder = AlertDialog.Builder(activity)
- builder.setTitle("Select audio track")
+ val builder = MaterialAlertDialogBuilder(activity)
+ builder.setTitle(getString(R.string.select_audio_track))
.setSingleChoiceItems(
trackNames.toTypedArray(),
- viewModel.currentAudioTracks.indexOfFirst { it.selected }) { _, which ->
+ viewModel.currentAudioTracks.indexOfFirst { it.selected }) { dialog, which ->
viewModel.switchToTrack(
TrackType.AUDIO,
viewModel.currentAudioTracks[which]
)
+ dialog.dismiss()
}
builder.create()
} ?: throw IllegalStateException("Activity cannot be null")
@@ -46,27 +48,22 @@ class TrackSelectionDialogFragment(
}
}
return activity?.let { activity ->
- val builder = AlertDialog.Builder(activity)
- builder.setTitle("Select subtitle track")
+ val builder = MaterialAlertDialogBuilder(activity)
+ builder.setTitle(getString(R.string.select_subtile_track))
.setSingleChoiceItems(
trackNames.toTypedArray(),
- viewModel.currentSubtitleTracks.indexOfFirst { it.selected }) { _, which ->
+ viewModel.currentSubtitleTracks.indexOfFirst { it.selected }) { dialog, which ->
viewModel.switchToTrack(
TrackType.SUBTITLE,
viewModel.currentSubtitleTracks[which]
)
+ dialog.dismiss()
}
builder.create()
} ?: throw IllegalStateException("Activity cannot be null")
}
else -> {
- trackNames = listOf()
- return activity?.let {
- val builder = AlertDialog.Builder(it)
- builder.setTitle("Select ? track")
- .setMessage("Unknown track type")
- builder.create()
- } ?: throw IllegalStateException("Activity cannot be null")
+ throw IllegalStateException("TrackType must be AUDIO or SUBTITLE")
}
}
}
diff --git a/app/src/main/java/dev/jdtech/jellyfin/dialogs/VideoVersionDialogFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/dialogs/VideoVersionDialogFragment.kt
index 84caf7bf..11377734 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/dialogs/VideoVersionDialogFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/dialogs/VideoVersionDialogFragment.kt
@@ -1,24 +1,27 @@
package dev.jdtech.jellyfin.dialogs
-import android.app.AlertDialog
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
-import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.lang.IllegalStateException
+import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
+import org.jellyfin.sdk.model.api.BaseItemDto
class VideoVersionDialogFragment(
- private val viewModel: MediaInfoViewModel
+ private val item: BaseItemDto,
+ private val viewModel: PlayerViewModel
) : DialogFragment() {
+
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val items = viewModel.item.value?.mediaSources?.map { it.name }
- return activity?.let {
- val builder = AlertDialog.Builder(it)
- builder.setTitle("Select a version")
- .setItems(items?.toTypedArray()) { _, which ->
- viewModel.preparePlayerItems(which)
- }
- builder.create()
+ val items = item.mediaSources?.map { it.name }?.toTypedArray()
+ return activity?.let { activity ->
+ MaterialAlertDialogBuilder(activity)
+ .setTitle(R.string.select_a_version)
+ .setItems(items) { _, which ->
+ viewModel.loadPlayerItems(item, which)
+ }.create()
} ?: throw IllegalStateException("Activity cannot be null")
}
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/AddServerFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/AddServerFragment.kt
index 10fbcddc..9cada6c0 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/AddServerFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/AddServerFragment.kt
@@ -1,15 +1,22 @@
package dev.jdtech.jellyfin.fragments
import android.os.Bundle
-import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.view.inputmethod.EditorInfo
+import androidx.core.view.isVisible
+import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.FragmentAddServerBinding
import dev.jdtech.jellyfin.viewmodels.AddServerViewModel
+import kotlinx.coroutines.launch
+import timber.log.Timber
@AndroidEntryPoint
class AddServerFragment : Fragment() {
@@ -23,35 +30,62 @@ class AddServerFragment : Fragment() {
): View {
binding = FragmentAddServerBinding.inflate(inflater)
- binding.lifecycleOwner = viewLifecycleOwner
- binding.viewModel = viewModel
-
- binding.buttonConnect.setOnClickListener {
- val serverAddress = binding.editTextServerAddress.text.toString()
- if (serverAddress.isNotBlank()) {
- viewModel.checkServer(serverAddress)
- binding.progressCircular.visibility = View.VISIBLE
- } else {
- binding.editTextServerAddressLayout.error = "Empty server address"
+ binding.editTextServerAddress.setOnEditorActionListener { _, actionId, _ ->
+ return@setOnEditorActionListener when (actionId) {
+ EditorInfo.IME_ACTION_GO -> {
+ connectToServer()
+ true
+ }
+ else -> false
}
}
- viewModel.navigateToLogin.observe(viewLifecycleOwner, {
- if (it) {
- navigateToLoginFragment()
- }
- binding.progressCircular.visibility = View.GONE
- })
+ binding.buttonConnect.setOnClickListener {
+ connectToServer()
+ }
- viewModel.error.observe(viewLifecycleOwner, {
- binding.editTextServerAddressLayout.error = it
- })
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
+ Timber.d("$uiState")
+ when (uiState) {
+ is AddServerViewModel.UiState.Normal -> bindUiStateNormal()
+ is AddServerViewModel.UiState.Error -> bindUiStateError(uiState)
+ is AddServerViewModel.UiState.Loading -> bindUiStateLoading()
+ }
+ }
+ viewModel.onNavigateToLogin(viewLifecycleOwner.lifecycleScope) {
+ Timber.d("Navigate to login: $it")
+ if (it) {
+ navigateToLoginFragment()
+ }
+ }
+ }
+ }
return binding.root
}
+ private fun bindUiStateNormal() {
+ binding.progressCircular.isVisible = false
+ }
+
+ private fun bindUiStateError(uiState: AddServerViewModel.UiState.Error) {
+ binding.progressCircular.isVisible = false
+ binding.editTextServerAddressLayout.error = uiState.message
+ }
+
+ private fun bindUiStateLoading() {
+ binding.progressCircular.isVisible = true
+ binding.editTextServerAddressLayout.error = null
+ }
+
+ private fun connectToServer() {
+ val serverAddress = binding.editTextServerAddress.text.toString()
+ viewModel.checkServer(serverAddress)
+ }
+
private fun navigateToLoginFragment() {
findNavController().navigate(AddServerFragmentDirections.actionAddServerFragment3ToLoginFragment2())
- viewModel.onNavigateToLoginDone()
}
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/DownloadFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/DownloadFragment.kt
new file mode 100644
index 00000000..6f638f84
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/DownloadFragment.kt
@@ -0,0 +1,118 @@
+package dev.jdtech.jellyfin.fragments
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.isVisible
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.fragment.findNavController
+import dagger.hilt.android.AndroidEntryPoint
+import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.adapters.*
+import dev.jdtech.jellyfin.databinding.FragmentDownloadBinding
+import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
+import dev.jdtech.jellyfin.models.PlayerItem
+import dev.jdtech.jellyfin.utils.checkIfLoginRequired
+import dev.jdtech.jellyfin.viewmodels.DownloadViewModel
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.util.*
+
+@AndroidEntryPoint
+class DownloadFragment : Fragment() {
+
+ private lateinit var binding: FragmentDownloadBinding
+ private val viewModel: DownloadViewModel by viewModels()
+
+ private lateinit var errorDialog: ErrorDialogFragment
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ binding = FragmentDownloadBinding.inflate(inflater, container, false)
+
+ binding.downloadsRecyclerView.adapter = DownloadsListAdapter(
+ DownloadViewItemListAdapter.OnClickListener { item ->
+ navigateToMediaInfoFragment(item)
+ }, DownloadEpisodeListAdapter.OnClickListener { item ->
+ navigateToEpisodeBottomSheetFragment(item)
+ })
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
+ Timber.d("$uiState")
+ when (uiState) {
+ is DownloadViewModel.UiState.Normal -> bindUiStateNormal(uiState)
+ is DownloadViewModel.UiState.Loading -> bindUiStateLoading()
+ is DownloadViewModel.UiState.Error -> bindUiStateError(uiState)
+ }
+ }
+ }
+ }
+
+ binding.errorLayout.errorRetryButton.setOnClickListener {
+ viewModel.loadData()
+ }
+
+ binding.errorLayout.errorDetailsButton.setOnClickListener {
+ errorDialog.show(parentFragmentManager, "errordialog")
+ }
+
+ return binding.root
+ }
+
+ private fun bindUiStateNormal(uiState: DownloadViewModel.UiState.Normal) {
+ uiState.apply {
+ binding.noDownloadsText.isVisible = downloadSections.isEmpty()
+
+ val adapter = binding.downloadsRecyclerView.adapter as DownloadsListAdapter
+ adapter.submitList(downloadSections)
+ }
+ binding.loadingIndicator.isVisible = false
+ binding.downloadsRecyclerView.isVisible = true
+ binding.errorLayout.errorPanel.isVisible = false
+ }
+
+ private fun bindUiStateLoading() {
+ binding.loadingIndicator.isVisible = true
+ binding.errorLayout.errorPanel.isVisible = false
+ }
+
+ private fun bindUiStateError(uiState: DownloadViewModel.UiState.Error) {
+ val error = uiState.message ?: resources.getString(R.string.unknown_error)
+ errorDialog = ErrorDialogFragment(error)
+ binding.loadingIndicator.isVisible = false
+ binding.downloadsRecyclerView.isVisible = false
+ binding.errorLayout.errorPanel.isVisible = true
+ checkIfLoginRequired(error)
+ }
+
+ private fun navigateToMediaInfoFragment(item: PlayerItem) {
+ findNavController().navigate(
+ DownloadFragmentDirections.actionDownloadFragmentToMediaInfoFragment(
+ UUID.randomUUID(),
+ item.name,
+ item.metadata?.type ?: "Unknown",
+ item,
+ isOffline = true
+ )
+ )
+ }
+
+ private fun navigateToEpisodeBottomSheetFragment(episode: PlayerItem) {
+ findNavController().navigate(
+ DownloadFragmentDirections.actionDownloadFragmentToEpisodeBottomSheetFragment(
+ UUID.randomUUID(),
+ episode,
+ isOffline = true
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt
index e4fadaf3..7c71a9d4 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt
@@ -6,16 +6,26 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
+import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.bindBaseItemImage
import dev.jdtech.jellyfin.databinding.EpisodeBottomSheetBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel
+import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
+import kotlinx.coroutines.launch
+import org.jellyfin.sdk.model.api.LocationType
+import timber.log.Timber
+import java.util.*
@AndroidEntryPoint
class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
@@ -23,6 +33,7 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
private lateinit var binding: EpisodeBottomSheetBinding
private val viewModel: EpisodeBottomSheetViewModel by viewModels()
+ private val playerViewModel: PlayerViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
@@ -31,102 +42,173 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() {
): View {
binding = EpisodeBottomSheetBinding.inflate(inflater, container, false)
- binding.lifecycleOwner = viewLifecycleOwner
- binding.viewModel = viewModel
-
binding.playButton.setOnClickListener {
binding.playButton.setImageResource(android.R.color.transparent)
- binding.progressCircular.visibility = View.VISIBLE
- viewModel.preparePlayerItems()
- }
-
- binding.checkButton.setOnClickListener {
- when (viewModel.played.value) {
- true -> viewModel.markAsUnplayed(args.episodeId)
- false -> viewModel.markAsPlayed(args.episodeId)
+ binding.progressCircular.isVisible = true
+ viewModel.item?.let {
+ if (!args.isOffline) {
+ playerViewModel.loadPlayerItems(it)
+ } else {
+ playerViewModel.loadOfflinePlayerItems(viewModel.playerItems[0])
+ }
}
}
- binding.favoriteButton.setOnClickListener {
- when (viewModel.favorite.value) {
- true -> viewModel.unmarkAsFavorite(args.episodeId)
- false -> viewModel.markAsFavorite(args.episodeId)
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
+ Timber.d("$uiState")
+ when (uiState) {
+ is EpisodeBottomSheetViewModel.UiState.Normal -> bindUiStateNormal(uiState)
+ is EpisodeBottomSheetViewModel.UiState.Loading -> bindUiStateLoading()
+ is EpisodeBottomSheetViewModel.UiState.Error -> bindUiStateError(uiState)
+ }
+ }
}
}
- viewModel.item.observe(viewLifecycleOwner, { episode ->
+ playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
+ when (playerItems) {
+ is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
+ is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems)
+ }
+ }
+
+ if(!args.isOffline) {
+ val episodeId: UUID = args.episodeId
+
+ binding.checkButton.setOnClickListener {
+ when (viewModel.played) {
+ true -> {
+ viewModel.markAsUnplayed(episodeId)
+ binding.checkButton.setImageResource(R.drawable.ic_check)
+ }
+ false -> {
+ viewModel.markAsPlayed(episodeId)
+ binding.checkButton.setImageResource(R.drawable.ic_check_filled)
+ }
+ }
+ }
+
+ binding.favoriteButton.setOnClickListener {
+ when (viewModel.favorite) {
+ true -> {
+ viewModel.unmarkAsFavorite(episodeId)
+ binding.favoriteButton.setImageResource(R.drawable.ic_heart)
+ }
+ false -> {
+ viewModel.markAsFavorite(episodeId)
+ binding.favoriteButton.setImageResource(R.drawable.ic_heart_filled)
+ }
+ }
+ }
+
+ binding.downloadButton.setOnClickListener {
+ binding.downloadButton.isEnabled = false
+ viewModel.loadDownloadRequestItem(episodeId)
+ binding.downloadButton.setImageResource(android.R.color.transparent)
+ binding.progressDownload.isVisible = true
+ }
+
+ binding.deleteButton.isVisible = false
+
+ viewModel.loadEpisode(episodeId)
+ } else {
+ val playerItem = args.playerItem!!
+ viewModel.loadEpisode(playerItem)
+
+ binding.deleteButton.setOnClickListener {
+ viewModel.deleteEpisode()
+ dismiss()
+ findNavController().navigate(R.id.downloadFragment)
+ }
+
+ binding.checkButton.isVisible = false
+ binding.favoriteButton.isVisible = false
+ binding.downloadButtonWrapper.isVisible = false
+ }
+
+ return binding.root
+ }
+
+ private fun bindUiStateNormal(uiState: EpisodeBottomSheetViewModel.UiState.Normal) {
+ uiState.apply {
if (episode.userData?.playedPercentage != null) {
binding.progressBar.layoutParams.width = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
(episode.userData?.playedPercentage?.times(1.26))!!.toFloat(),
context?.resources?.displayMetrics
).toInt()
- binding.progressBar.visibility = View.VISIBLE
+ binding.progressBar.isVisible = true
}
- binding.communityRating.visibility = when (episode.communityRating != null) {
- false -> View.GONE
- true -> View.VISIBLE
- }
- })
- viewModel.played.observe(viewLifecycleOwner, {
- val drawable = when (it) {
+ // Check icon
+ val checkDrawable = when (played) {
true -> R.drawable.ic_check_filled
false -> R.drawable.ic_check
}
+ binding.checkButton.setImageResource(checkDrawable)
- binding.checkButton.setImageResource(drawable)
- })
-
- viewModel.favorite.observe(viewLifecycleOwner, {
- val drawable = when (it) {
+ // Favorite icon
+ val favoriteDrawable = when (favorite) {
true -> R.drawable.ic_heart_filled
false -> R.drawable.ic_heart
}
+ binding.favoriteButton.setImageResource(favoriteDrawable)
- binding.favoriteButton.setImageResource(drawable)
- })
-
- viewModel.navigateToPlayer.observe(viewLifecycleOwner, {
- if (it) {
- navigateToPlayerActivity(
- viewModel.playerItems.toTypedArray(),
- )
- viewModel.doneNavigateToPlayer()
- binding.playButton.setImageDrawable(
- ContextCompat.getDrawable(
- requireActivity(),
- R.drawable.ic_play
- )
- )
- binding.progressCircular.visibility = View.INVISIBLE
+ // Download icon
+ val downloadDrawable = when (downloaded) {
+ true -> R.drawable.ic_download_filled
+ false -> R.drawable.ic_download
}
- })
+ binding.downloadButton.setImageResource(downloadDrawable)
- viewModel.playerItemsError.observe(viewLifecycleOwner, { errorMessage ->
- if (errorMessage != null) {
- binding.playerItemsError.visibility = View.VISIBLE
- binding.playButton.setImageDrawable(
- ContextCompat.getDrawable(
- requireActivity(),
- R.drawable.ic_play
- )
- )
- binding.progressCircular.visibility = View.INVISIBLE
- } else {
- binding.playerItemsError.visibility = View.GONE
- }
- })
-
- binding.playerItemsErrorDetails.setOnClickListener {
- ErrorDialogFragment(
- viewModel.playerItemsError.value ?: getString(R.string.unknown_error)
- ).show(parentFragmentManager, "errordialog")
+ binding.episodeName.text = String.format(getString(R.string.episode_name_extended), episode.parentIndexNumber, episode.indexNumber, episode.name)
+ binding.overview.text = episode.overview
+ binding.year.text = dateString
+ binding.playtime.text = runTime
+ binding.communityRating.isVisible = episode.communityRating != null
+ binding.communityRating.text = episode.communityRating.toString()
+ binding.missingIcon.isVisible = episode.locationType == LocationType.VIRTUAL
+ bindBaseItemImage(binding.episodeImage, episode)
}
+ binding.loadingIndicator.isVisible = false
+ }
- viewModel.loadEpisode(args.episodeId)
+ private fun bindUiStateLoading() {
+ binding.loadingIndicator.isVisible = true
+ }
- return binding.root
+ private fun bindUiStateError(uiState: EpisodeBottomSheetViewModel.UiState.Error) {
+ binding.loadingIndicator.isVisible = false
+ binding.overview.text = uiState.message
+ }
+
+ private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
+ navigateToPlayerActivity(items.items.toTypedArray())
+ binding.playButton.setImageDrawable(
+ ContextCompat.getDrawable(
+ requireActivity(),
+ R.drawable.ic_play
+ )
+ )
+ binding.progressCircular.visibility = View.INVISIBLE
+ }
+
+ private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
+ Timber.e(error.message)
+
+ binding.playerItemsError.isVisible = true
+ binding.playButton.setImageDrawable(
+ ContextCompat.getDrawable(
+ requireActivity(),
+ R.drawable.ic_play
+ )
+ )
+ binding.progressCircular.visibility = View.INVISIBLE
+ binding.playerItemsErrorDetails.setOnClickListener {
+ ErrorDialogFragment(error.message).show(parentFragmentManager, "errordialog")
+ }
}
private fun navigateToPlayerActivity(
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/FavoriteFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/FavoriteFragment.kt
index 768fd6a2..9bef3f8f 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/FavoriteFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/FavoriteFragment.kt
@@ -5,7 +5,11 @@ import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
@@ -16,7 +20,9 @@ import dev.jdtech.jellyfin.databinding.FragmentFavoriteBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.FavoriteViewModel
+import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
+import timber.log.Timber
@AndroidEntryPoint
class FavoriteFragment : Fragment() {
@@ -24,14 +30,14 @@ class FavoriteFragment : Fragment() {
private lateinit var binding: FragmentFavoriteBinding
private val viewModel: FavoriteViewModel by viewModels()
+ private lateinit var errorDialog: ErrorDialogFragment
+
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentFavoriteBinding.inflate(inflater, container, false)
- binding.lifecycleOwner = viewLifecycleOwner
- binding.viewModel = viewModel
binding.favoritesRecyclerView.adapter = FavoritesListAdapter(
ViewItemListAdapter.OnClickListener { item ->
navigateToMediaInfoFragment(item)
@@ -39,40 +45,56 @@ class FavoriteFragment : Fragment() {
navigateToEpisodeBottomSheetFragment(item)
})
- viewModel.finishedLoading.observe(viewLifecycleOwner, { isFinished ->
- binding.loadingIndicator.visibility = if (isFinished) View.GONE else View.VISIBLE
- })
-
- viewModel.error.observe(viewLifecycleOwner, { error ->
- if (error != null) {
- checkIfLoginRequired(error)
- binding.errorLayout.errorPanel.visibility = View.VISIBLE
- binding.favoritesRecyclerView.visibility = View.GONE
- } else {
- binding.errorLayout.errorPanel.visibility = View.GONE
- binding.favoritesRecyclerView.visibility = View.VISIBLE
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
+ Timber.d("$uiState")
+ when (uiState) {
+ is FavoriteViewModel.UiState.Normal -> bindUiStateNormal(uiState)
+ is FavoriteViewModel.UiState.Loading -> bindUiStateLoading()
+ is FavoriteViewModel.UiState.Error -> bindUiStateError(uiState)
+ }
+ }
}
- })
+ }
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData()
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
- ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog")
+ errorDialog.show(parentFragmentManager, "errordialog")
}
- viewModel.favoriteSections.observe(viewLifecycleOwner, { sections ->
- if (sections.isEmpty()) {
- binding.noFavoritesText.visibility = View.VISIBLE
- } else {
- binding.noFavoritesText.visibility = View.GONE
- }
- })
-
return binding.root
}
+ private fun bindUiStateNormal(uiState: FavoriteViewModel.UiState.Normal) {
+ uiState.apply {
+ binding.noFavoritesText.isVisible = favoriteSections.isEmpty()
+
+ val adapter = binding.favoritesRecyclerView.adapter as FavoritesListAdapter
+ adapter.submitList(favoriteSections)
+ }
+ binding.loadingIndicator.isVisible = false
+ binding.favoritesRecyclerView.isVisible = true
+ binding.errorLayout.errorPanel.isVisible = false
+ }
+
+ private fun bindUiStateLoading() {
+ binding.loadingIndicator.isVisible = true
+ binding.errorLayout.errorPanel.isVisible = false
+ }
+
+ private fun bindUiStateError(uiState: FavoriteViewModel.UiState.Error) {
+ val error = uiState.message ?: resources.getString(R.string.unknown_error)
+ errorDialog = ErrorDialogFragment(error)
+ binding.loadingIndicator.isVisible = false
+ binding.favoritesRecyclerView.isVisible = false
+ binding.errorLayout.errorPanel.isVisible = true
+ checkIfLoginRequired(error)
+ }
+
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
findNavController().navigate(
FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment(
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt
index 2752ef5b..be0e9790 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt
@@ -1,9 +1,20 @@
package dev.jdtech.jellyfin.fragments
import android.os.Bundle
-import android.view.*
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import android.widget.Toast.LENGTH_LONG
+import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
@@ -12,9 +23,16 @@ import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
import dev.jdtech.jellyfin.adapters.ViewListAdapter
import dev.jdtech.jellyfin.databinding.FragmentHomeBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
+import dev.jdtech.jellyfin.models.ContentType
+import dev.jdtech.jellyfin.models.ContentType.EPISODE
+import dev.jdtech.jellyfin.models.ContentType.MOVIE
+import dev.jdtech.jellyfin.models.ContentType.TVSHOW
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
+import dev.jdtech.jellyfin.utils.contentType
import dev.jdtech.jellyfin.viewmodels.HomeViewModel
+import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
+import timber.log.Timber
@AndroidEntryPoint
class HomeFragment : Fragment() {
@@ -22,6 +40,8 @@ class HomeFragment : Fragment() {
private lateinit var binding: FragmentHomeBinding
private val viewModel: HomeViewModel by viewModels()
+ private lateinit var errorDialog: ErrorDialogFragment
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
@@ -50,51 +70,85 @@ class HomeFragment : Fragment() {
): View {
binding = FragmentHomeBinding.inflate(inflater, container, false)
- binding.lifecycleOwner = viewLifecycleOwner
- binding.viewModel = viewModel
- binding.viewsRecyclerView.adapter = ViewListAdapter(ViewListAdapter.OnClickListener {
- navigateToLibraryFragment(it)
- }, ViewItemListAdapter.OnClickListener {
- navigateToMediaInfoFragment(it)
- }, HomeEpisodeListAdapter.OnClickListener { item ->
- when (item.type) {
- "Episode" -> {
- navigateToEpisodeBottomSheetFragment(item)
+ setupView()
+ bindState()
+
+ return binding.root
+ }
+
+ override fun onResume() {
+ super.onResume()
+
+ viewModel.refreshData()
+ }
+
+ private fun setupView() {
+ binding.refreshLayout.setOnRefreshListener {
+ viewModel.refreshData()
+ // binding.refreshLayout.isRefreshing = false
+ }
+
+ binding.viewsRecyclerView.adapter = ViewListAdapter(
+ onClickListener = ViewListAdapter.OnClickListener { navigateToLibraryFragment(it) },
+ onItemClickListener = ViewItemListAdapter.OnClickListener {
+ navigateToMediaInfoFragment(it)
+ },
+ onNextUpClickListener = HomeEpisodeListAdapter.OnClickListener { item ->
+ when (item.contentType()) {
+ EPISODE -> navigateToEpisodeBottomSheetFragment(item)
+ MOVIE -> navigateToMediaInfoFragment(item)
+ else -> Toast.makeText(requireContext(), R.string.unknown_error, LENGTH_LONG)
+ .show()
}
- "Movie" -> {
- navigateToMediaInfoFragment(item)
- }
- }
-
- })
-
- viewModel.finishedLoading.observe(viewLifecycleOwner, {
- binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE
- })
-
- viewModel.error.observe(viewLifecycleOwner, { error ->
- if (error != null) {
- checkIfLoginRequired(error)
- binding.errorLayout.errorPanel.visibility = View.VISIBLE
- binding.viewsRecyclerView.visibility = View.GONE
- } else {
- binding.errorLayout.errorPanel.visibility = View.GONE
- binding.viewsRecyclerView.visibility = View.VISIBLE
- }
- })
+ })
binding.errorLayout.errorRetryButton.setOnClickListener {
- viewModel.loadData()
+ viewModel.refreshData()
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
- ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(
- parentFragmentManager,
- "errordialog"
- )
+ errorDialog.show(parentFragmentManager, "errordialog")
}
+ }
- return binding.root
+ private fun bindState() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
+ Timber.d("$uiState")
+ when (uiState) {
+ is HomeViewModel.UiState.Normal -> bindUiStateNormal(uiState)
+ is HomeViewModel.UiState.Loading -> bindUiStateLoading()
+ is HomeViewModel.UiState.Error -> bindUiStateError(uiState)
+ }
+ }
+ }
+ }
+ }
+
+ private fun bindUiStateNormal(uiState: HomeViewModel.UiState.Normal) {
+ uiState.apply {
+ val adapter = binding.viewsRecyclerView.adapter as ViewListAdapter
+ adapter.submitList(uiState.homeItems)
+ }
+ binding.loadingIndicator.isVisible = false
+ binding.refreshLayout.isRefreshing = false
+ binding.viewsRecyclerView.isVisible = true
+ }
+
+ private fun bindUiStateLoading() {
+ binding.loadingIndicator.isVisible = true
+ binding.errorLayout.errorPanel.isVisible = false
+ }
+
+ private fun bindUiStateError(uiState: HomeViewModel.UiState.Error) {
+ val error = uiState.message ?: getString(R.string.unknown_error)
+ errorDialog = ErrorDialogFragment(error)
+ binding.loadingIndicator.isVisible = false
+ binding.refreshLayout.isRefreshing = false
+ binding.viewsRecyclerView.isVisible = false
+ binding.errorLayout.errorPanel.isVisible = true
+ checkIfLoginRequired(error)
}
private fun navigateToLibraryFragment(view: dev.jdtech.jellyfin.models.View) {
@@ -108,12 +162,12 @@ class HomeFragment : Fragment() {
}
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
- if (item.type == "Episode") {
+ if (item.contentType() == EPISODE) {
findNavController().navigate(
HomeFragmentDirections.actionNavigationHomeToMediaInfoFragment(
item.seriesId!!,
item.seriesName,
- "Series"
+ TVSHOW.type
)
)
} else {
@@ -121,7 +175,7 @@ class HomeFragment : Fragment() {
HomeFragmentDirections.actionNavigationHomeToMediaInfoFragment(
item.id,
item.name,
- item.type ?: "Unknown"
+ item.type ?: ContentType.UNKNOWN.type
)
)
}
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/InitializingFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/InitializingFragment.kt
deleted file mode 100644
index 99bd8191..00000000
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/InitializingFragment.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package dev.jdtech.jellyfin.fragments
-
-import android.os.Bundle
-import androidx.fragment.app.Fragment
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import dev.jdtech.jellyfin.R
-
-
-class InitializingFragment : Fragment() {
-
- override fun onCreateView(
- inflater: LayoutInflater, container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View? {
- // Inflate the layout for this fragment
- return inflater.inflate(R.layout.fragment_initializing, container, false)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt
index 739f00f9..68b9f75e 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/LibraryFragment.kt
@@ -1,11 +1,14 @@
package dev.jdtech.jellyfin.fragments
+import android.content.SharedPreferences
import android.os.Bundle
+import android.view.*
+import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint
@@ -14,60 +17,127 @@ import dev.jdtech.jellyfin.viewmodels.LibraryViewModel
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
import dev.jdtech.jellyfin.databinding.FragmentLibraryBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
+import dev.jdtech.jellyfin.dialogs.SortDialogFragment
+import dev.jdtech.jellyfin.utils.SortBy
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
+import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
+import org.jellyfin.sdk.model.api.SortOrder
+import java.lang.IllegalArgumentException
+import javax.inject.Inject
@AndroidEntryPoint
class LibraryFragment : Fragment() {
private lateinit var binding: FragmentLibraryBinding
private val viewModel: LibraryViewModel by viewModels()
-
private val args: LibraryFragmentArgs by navArgs()
+ private lateinit var errorDialog: ErrorDialogFragment
+
+ @Inject
+ lateinit var sp: SharedPreferences
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setHasOptionsMenu(true)
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
+ super.onCreateOptionsMenu(menu, inflater)
+ inflater.inflate(R.menu.library_menu, menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ R.id.action_sort_by -> {
+ SortDialogFragment(args.libraryId, args.libraryType, viewModel, "sortBy").show(
+ parentFragmentManager,
+ "sortdialog"
+ )
+ true
+ }
+ R.id.action_sort_order -> {
+ SortDialogFragment(args.libraryId, args.libraryType, viewModel, "sortOrder").show(
+ parentFragmentManager,
+ "sortdialog"
+ )
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentLibraryBinding.inflate(inflater, container, false)
-
- binding.lifecycleOwner = viewLifecycleOwner
-
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- binding.viewModel = viewModel
-
- viewModel.error.observe(viewLifecycleOwner, { error ->
- if (error != null) {
- checkIfLoginRequired(error)
- binding.errorLayout.errorPanel.visibility = View.VISIBLE
- binding.itemsRecyclerView.visibility = View.GONE
- } else {
- binding.errorLayout.errorPanel.visibility = View.GONE
- binding.itemsRecyclerView.visibility = View.VISIBLE
- }
- })
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadItems(args.libraryId, args.libraryType)
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
- ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog")
+ errorDialog.show(
+ parentFragmentManager,
+ "errordialog"
+ )
}
- viewModel.finishedLoading.observe(viewLifecycleOwner, {
- binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE
- })
-
binding.itemsRecyclerView.adapter =
ViewItemListAdapter(ViewItemListAdapter.OnClickListener { item ->
navigateToMediaInfoFragment(item)
})
- viewModel.loadItems(args.libraryId, args.libraryType)
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
+ when (uiState) {
+ is LibraryViewModel.UiState.Normal -> bindUiStateNormal(uiState)
+ is LibraryViewModel.UiState.Loading -> bindUiStateLoading()
+ is LibraryViewModel.UiState.Error -> bindUiStateError(uiState)
+ }
+ }
+
+ // Sorting options
+ val sortBy = SortBy.fromString(sp.getString("sortBy", SortBy.defaultValue.name)!!)
+ val sortOrder = try {
+ SortOrder.valueOf(sp.getString("sortOrder", SortOrder.ASCENDING.name)!!)
+ } catch (e: IllegalArgumentException) {
+ SortOrder.ASCENDING
+ }
+
+ viewModel.loadItems(args.libraryId, args.libraryType, sortBy = sortBy, sortOrder = sortOrder)
+ }
+ }
+ }
+
+ private fun bindUiStateNormal(uiState: LibraryViewModel.UiState.Normal) {
+ val adapter = binding.itemsRecyclerView.adapter as ViewItemListAdapter
+ adapter.submitList(uiState.items)
+ binding.loadingIndicator.isVisible = false
+ binding.itemsRecyclerView.isVisible = true
+ binding.errorLayout.errorPanel.isVisible = false
+ }
+
+ private fun bindUiStateLoading() {
+ binding.loadingIndicator.isVisible = true
+ binding.errorLayout.errorPanel.isVisible = false
+ }
+
+ private fun bindUiStateError(uiState: LibraryViewModel.UiState.Error) {
+ val error = uiState.message ?: getString(R.string.unknown_error)
+ errorDialog = ErrorDialogFragment(error)
+ binding.loadingIndicator.isVisible = false
+ binding.itemsRecyclerView.isVisible = false
+ binding.errorLayout.errorPanel.isVisible = true
+ checkIfLoginRequired(error)
}
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/LoginFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/LoginFragment.kt
index 61d9e6aa..09edae33 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/LoginFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/LoginFragment.kt
@@ -4,50 +4,90 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.view.inputmethod.EditorInfo
+import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.FragmentLoginBinding
import dev.jdtech.jellyfin.viewmodels.LoginViewModel
+import kotlinx.coroutines.launch
+import timber.log.Timber
@AndroidEntryPoint
class LoginFragment : Fragment() {
+ private lateinit var binding: FragmentLoginBinding
private val viewModel: LoginViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
- val binding = FragmentLoginBinding.inflate(inflater)
- binding.lifecycleOwner = viewLifecycleOwner
- binding.viewModel = viewModel
+ binding = FragmentLoginBinding.inflate(inflater)
- binding.buttonLogin.setOnClickListener {
- val username = binding.editTextUsername.text.toString()
- val password = binding.editTextPassword.text.toString()
-
- binding.progressCircular.visibility = View.VISIBLE
- viewModel.login(username, password)
+ binding.editTextPassword.setOnEditorActionListener { _, actionId, _ ->
+ return@setOnEditorActionListener when (actionId) {
+ EditorInfo.IME_ACTION_GO -> {
+ login()
+ true
+ }
+ else -> false
+ }
}
- viewModel.error.observe(viewLifecycleOwner, {
- binding.progressCircular.visibility = View.GONE
- binding.editTextUsernameLayout.error = it
- })
+ binding.buttonLogin.setOnClickListener {
+ login()
+ }
- viewModel.navigateToMain.observe(viewLifecycleOwner, {
- if (it) {
- navigateToMainActivity()
+ viewLifecycleOwner.lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
+ Timber.d("$uiState")
+ when(uiState) {
+ is LoginViewModel.UiState.Normal -> bindUiStateNormal()
+ is LoginViewModel.UiState.Error -> bindUiStateError(uiState)
+ is LoginViewModel.UiState.Loading -> bindUiStateLoading()
+ }
+ }
+ viewModel.onNavigateToMain(viewLifecycleOwner.lifecycleScope) {
+ Timber.d("Navigate to MainActivity: $it")
+ if (it) {
+ navigateToMainActivity()
+ }
+ }
}
- })
+ }
return binding.root
}
+ private fun bindUiStateNormal() {
+ binding.progressCircular.isVisible = false
+ }
+
+ private fun bindUiStateError(uiState: LoginViewModel.UiState.Error) {
+ binding.progressCircular.isVisible = false
+ binding.editTextUsernameLayout.error = uiState.message
+ }
+
+ private fun bindUiStateLoading() {
+ binding.progressCircular.isVisible = true
+ binding.editTextUsernameLayout.error = null
+ }
+
+ private fun login() {
+ val username = binding.editTextUsername.text.toString()
+ val password = binding.editTextPassword.text.toString()
+ binding.progressCircular.visibility = View.VISIBLE
+ viewModel.login(username, password)
+ }
+
private fun navigateToMainActivity() {
findNavController().navigate(LoginFragmentDirections.actionLoginFragment2ToNavigationHome())
- viewModel.doneNavigatingToMain()
}
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaFragment.kt
index c0ed3b2b..ea8d94e0 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaFragment.kt
@@ -3,8 +3,12 @@ package dev.jdtech.jellyfin.fragments
import android.os.Bundle
import android.view.*
import androidx.appcompat.widget.SearchView
+import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
@@ -13,7 +17,9 @@ import dev.jdtech.jellyfin.databinding.FragmentMediaBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.MediaViewModel
+import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
+import timber.log.Timber
@AndroidEntryPoint
class MediaFragment : Fragment() {
@@ -21,6 +27,10 @@ class MediaFragment : Fragment() {
private lateinit var binding: FragmentMediaBinding
private val viewModel: MediaViewModel by viewModels()
+ private var originalSoftInputMode: Int? = null
+
+ private lateinit var errorDialog: ErrorDialogFragment
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
@@ -31,9 +41,9 @@ class MediaFragment : Fragment() {
val search = menu.findItem(R.id.action_search)
val searchView = search.actionView as SearchView
- searchView.queryHint = "Search movies, shows, episodes..."
+ searchView.queryHint = getString(R.string.search_hint)
- searchView.setOnQueryTextListener(object: SearchView.OnQueryTextListener {
+ searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(p0: String?): Boolean {
if (p0 != null) {
navigateToSearchResultFragment(p0)
@@ -54,39 +64,71 @@ class MediaFragment : Fragment() {
): View {
binding = FragmentMediaBinding.inflate(inflater, container, false)
- binding.lifecycleOwner = viewLifecycleOwner
- binding.viewModel = viewModel
binding.viewsRecyclerView.adapter =
CollectionListAdapter(CollectionListAdapter.OnClickListener { library ->
navigateToLibraryFragment(library)
})
- viewModel.finishedLoading.observe(viewLifecycleOwner, {
- binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE
- })
-
- viewModel.error.observe(viewLifecycleOwner, { error ->
- if (error != null) {
- checkIfLoginRequired(error)
- binding.errorLayout.errorPanel.visibility = View.VISIBLE
- binding.viewsRecyclerView.visibility = View.GONE
- } else {
- binding.errorLayout.errorPanel.visibility = View.GONE
- binding.viewsRecyclerView.visibility = View.VISIBLE
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
+ Timber.d("$uiState")
+ when (uiState) {
+ is MediaViewModel.UiState.Normal -> bindUiStateNormal(uiState)
+ is MediaViewModel.UiState.Loading -> bindUiStateLoading()
+ is MediaViewModel.UiState.Error -> bindUiStateError(uiState)
+ }
+ }
}
- })
+ }
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData()
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
- ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog")
+ errorDialog.show(parentFragmentManager, "errordialog")
}
return binding.root
}
+ override fun onStart() {
+ super.onStart()
+ requireActivity().window.let {
+ originalSoftInputMode = it.attributes?.softInputMode
+ it.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN)
+ }
+ }
+
+ override fun onStop() {
+ super.onStop()
+ originalSoftInputMode?.let { activity?.window?.setSoftInputMode(it) }
+ }
+
+ private fun bindUiStateNormal(uiState: MediaViewModel.UiState.Normal) {
+ binding.loadingIndicator.isVisible = false
+ binding.viewsRecyclerView.isVisible = true
+ binding.errorLayout.errorPanel.isVisible = false
+ val adapter = binding.viewsRecyclerView.adapter as CollectionListAdapter
+ adapter.submitList(uiState.collections)
+ }
+
+ private fun bindUiStateLoading() {
+ binding.loadingIndicator.isVisible = true
+ binding.errorLayout.errorPanel.isVisible = false
+ }
+
+ private fun bindUiStateError(uiState: MediaViewModel.UiState.Error) {
+ val error = uiState.message ?: resources.getString(R.string.unknown_error)
+ errorDialog = ErrorDialogFragment(error)
+ binding.loadingIndicator.isVisible = false
+ binding.viewsRecyclerView.isVisible = false
+ binding.errorLayout.errorPanel.isVisible = true
+ checkIfLoginRequired(error)
+
+ }
+
private fun navigateToLibraryFragment(library: BaseItemDto) {
findNavController().navigate(
MediaFragmentDirections.actionNavigationMediaToLibraryFragment(
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt
index 8ff29f13..e35c26e3 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/MediaInfoFragment.kt
@@ -6,199 +6,276 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.widget.Toast
import androidx.core.content.ContextCompat
+import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
import dev.jdtech.jellyfin.adapters.PersonListAdapter
import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
+import dev.jdtech.jellyfin.bindBaseItemImage
+import dev.jdtech.jellyfin.bindItemBackdropImage
import dev.jdtech.jellyfin.databinding.FragmentMediaInfoBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment
import dev.jdtech.jellyfin.models.PlayerItem
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
+import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
+import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
+import org.jellyfin.sdk.model.serializer.toUUID
+import timber.log.Timber
+import java.util.UUID
@AndroidEntryPoint
class MediaInfoFragment : Fragment() {
private lateinit var binding: FragmentMediaInfoBinding
private val viewModel: MediaInfoViewModel by viewModels()
-
+ private val playerViewModel: PlayerViewModel by viewModels()
private val args: MediaInfoFragmentArgs by navArgs()
+ lateinit var errorDialog: ErrorDialogFragment
+
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentMediaInfoBinding.inflate(inflater, container, false)
- binding.lifecycleOwner = viewLifecycleOwner
-
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- binding.viewModel = viewModel
-
- viewModel.error.observe(viewLifecycleOwner, { error ->
- if (error != null) {
- checkIfLoginRequired(error)
- binding.errorLayout.errorPanel.visibility = View.VISIBLE
- binding.mediaInfoScrollview.visibility = View.GONE
- } else {
- binding.errorLayout.errorPanel.visibility = View.GONE
- binding.mediaInfoScrollview.visibility = View.VISIBLE
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
+ Timber.d("$uiState")
+ when (uiState) {
+ is MediaInfoViewModel.UiState.Normal -> bindUiStateNormal(uiState)
+ is MediaInfoViewModel.UiState.Loading -> bindUiStateLoading()
+ is MediaInfoViewModel.UiState.Error -> bindUiStateError(uiState)
+ }
+ }
+ if (!args.isOffline) {
+ viewModel.loadData(args.itemId, args.itemType)
+ } else {
+ viewModel.loadData(args.playerItem!!)
+ }
}
- })
+ }
+
+ if(args.itemType != "Movie") {
+ binding.downloadButton.visibility = View.GONE
+ }
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData(args.itemId, args.itemType)
}
- binding.errorLayout.errorDetailsButton.setOnClickListener {
- ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(
- parentFragmentManager,
- "errordialog"
- )
- }
-
- viewModel.item.observe(viewLifecycleOwner, { item ->
- if (item.originalTitle != item.name) {
- binding.originalTitle.visibility = View.VISIBLE
- } else {
- binding.originalTitle.visibility = View.GONE
+ playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
+ when (playerItems) {
+ is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems)
+ is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems)
}
- if (item.remoteTrailers.isNullOrEmpty()) {
- binding.trailerButton.visibility = View.GONE
- }
- binding.communityRating.visibility = when (item.communityRating != null) {
- true -> View.VISIBLE
- false -> View.GONE
- }
- })
-
- viewModel.actors.observe(viewLifecycleOwner, { actors ->
- when (actors.isNullOrEmpty()) {
- false -> binding.actors.visibility = View.VISIBLE
- true -> binding.actors.visibility = View.GONE
- }
- })
-
- viewModel.navigateToPlayer.observe(viewLifecycleOwner, { playerItems ->
- if (playerItems != null) {
- navigateToPlayerActivity(
- playerItems
- )
- viewModel.doneNavigatingToPlayer()
- binding.playButton.setImageDrawable(
- ContextCompat.getDrawable(
- requireActivity(),
- R.drawable.ic_play
- )
- )
- binding.progressCircular.visibility = View.INVISIBLE
- }
- })
-
- viewModel.played.observe(viewLifecycleOwner, {
- val drawable = when (it) {
- true -> R.drawable.ic_check_filled
- false -> R.drawable.ic_check
- }
-
- binding.checkButton.setImageResource(drawable)
- })
-
- viewModel.favorite.observe(viewLifecycleOwner, {
- val drawable = when (it) {
- true -> R.drawable.ic_heart_filled
- false -> R.drawable.ic_heart
- }
-
- binding.favoriteButton.setImageResource(drawable)
- })
-
- viewModel.playerItemsError.observe(viewLifecycleOwner, { errorMessage ->
- if (errorMessage != null) {
- binding.playerItemsError.visibility = View.VISIBLE
- binding.playButton.setImageDrawable(
- ContextCompat.getDrawable(
- requireActivity(),
- R.drawable.ic_play
- )
- )
- binding.progressCircular.visibility = View.INVISIBLE
- } else {
- binding.playerItemsError.visibility = View.GONE
- }
- })
-
- binding.playerItemsErrorDetails.setOnClickListener {
- ErrorDialogFragment(
- viewModel.playerItemsError.value ?: getString(R.string.unknown_error)
- ).show(parentFragmentManager, "errordialog")
}
binding.trailerButton.setOnClickListener {
- if (viewModel.item.value?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener
+ if (viewModel.item?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener
val intent = Intent(
Intent.ACTION_VIEW,
- Uri.parse(viewModel.item.value?.remoteTrailers?.get(0)?.url)
+ Uri.parse(viewModel.item?.remoteTrailers?.get(0)?.url)
)
startActivity(intent)
}
binding.nextUp.setOnClickListener {
- navigateToEpisodeBottomSheetFragment(viewModel.nextUp.value!!)
+ navigateToEpisodeBottomSheetFragment(viewModel.nextUp!!)
}
binding.seasonsRecyclerView.adapter =
ViewItemListAdapter(ViewItemListAdapter.OnClickListener { season ->
navigateToSeasonFragment(season)
}, fixedWidth = true)
- binding.peopleRecyclerView.adapter = PersonListAdapter()
+ binding.peopleRecyclerView.adapter = PersonListAdapter { person ->
+ val uuid = person.id?.toUUID()
+ if (uuid != null) {
+ navigateToPersonDetail(uuid)
+ } else {
+ Toast.makeText(requireContext(), R.string.error_getting_person_id, Toast.LENGTH_SHORT).show()
+ }
+ }
binding.playButton.setOnClickListener {
binding.playButton.setImageResource(android.R.color.transparent)
- binding.progressCircular.visibility = View.VISIBLE
- if (args.itemType == "Movie") {
- if (viewModel.item.value?.mediaSources != null) {
- if (viewModel.item.value?.mediaSources?.size!! > 1) {
- VideoVersionDialogFragment(viewModel).show(
+ binding.progressCircular.isVisible = true
+ viewModel.item?.let { item ->
+ if (!args.isOffline) {
+ playerViewModel.loadPlayerItems(item) {
+ VideoVersionDialogFragment(item, playerViewModel).show(
parentFragmentManager,
"videoversiondialog"
)
- } else {
- viewModel.preparePlayerItems()
+ }
+ } else {
+ playerViewModel.loadOfflinePlayerItems(args.playerItem!!)
+ }
+ }
+ }
+
+ if (!args.isOffline) {
+ binding.errorLayout.errorRetryButton.setOnClickListener {
+ viewModel.loadData(args.itemId, args.itemType)
+ }
+
+ binding.checkButton.setOnClickListener {
+ when (viewModel.played) {
+ true -> {
+ viewModel.markAsUnplayed(args.itemId)
+ binding.checkButton.setImageResource(R.drawable.ic_check)
+ }
+ false -> {
+ viewModel.markAsPlayed(args.itemId)
+ binding.checkButton.setImageResource(R.drawable.ic_check_filled)
}
}
- } else if (args.itemType == "Series") {
- viewModel.preparePlayerItems()
+ }
+
+ binding.favoriteButton.setOnClickListener {
+ when (viewModel.favorite) {
+ true -> {
+ viewModel.unmarkAsFavorite(args.itemId)
+ binding.favoriteButton.setImageResource(R.drawable.ic_heart)
+ }
+ false -> {
+ viewModel.markAsFavorite(args.itemId)
+ binding.favoriteButton.setImageResource(R.drawable.ic_heart_filled)
+ }
+ }
+ }
+
+ binding.downloadButton.setOnClickListener {
+ viewModel.loadDownloadRequestItem(args.itemId)
+ }
+
+ binding.deleteButton.isVisible = false
+ } else {
+ binding.favoriteButton.isVisible = false
+ binding.checkButton.isVisible = false
+ binding.downloadButton.isVisible = false
+
+ binding.deleteButton.setOnClickListener {
+ viewModel.deleteItem()
+ findNavController().navigate(R.id.downloadFragment)
}
}
+ }
- binding.checkButton.setOnClickListener {
- when (viewModel.played.value) {
- true -> viewModel.markAsUnplayed(args.itemId)
- false -> viewModel.markAsPlayed(args.itemId)
+ private fun bindUiStateNormal(uiState: MediaInfoViewModel.UiState.Normal) {
+ uiState.apply {
+ binding.originalTitle.isVisible = item.originalTitle != item.name
+ if (item.remoteTrailers.isNullOrEmpty()) {
+ binding.trailerButton.isVisible = false
}
- }
+ binding.communityRating.isVisible = item.communityRating != null
+ binding.actors.isVisible = actors.isNotEmpty()
- binding.favoriteButton.setOnClickListener {
- when (viewModel.favorite.value) {
- true -> viewModel.unmarkAsFavorite(args.itemId)
- false -> viewModel.markAsFavorite(args.itemId)
+ // Check icon
+ val checkDrawable = when (played) {
+ true -> R.drawable.ic_check_filled
+ false -> R.drawable.ic_check
}
- }
+ binding.checkButton.setImageResource(checkDrawable)
- viewModel.loadData(args.itemId, args.itemType)
+ // Favorite icon
+ val favoriteDrawable = when (favorite) {
+ true -> R.drawable.ic_heart_filled
+ false -> R.drawable.ic_heart
+ }
+ binding.favoriteButton.setImageResource(favoriteDrawable)
+
+ // Download icon
+ val downloadDrawable = when (downloaded) {
+ true -> R.drawable.ic_download_filled
+ false -> R.drawable.ic_download
+ }
+ binding.downloadButton.setImageResource(downloadDrawable)
+ binding.name.text = item.name
+ binding.originalTitle.text = item.originalTitle
+ if (dateString.isEmpty()) {
+ binding.year.isVisible = false
+ } else {
+ binding.year.text = dateString
+ }
+ if (runTime.isEmpty()) {
+ binding.playtime.isVisible = false
+ } else {
+ binding.playtime.text = runTime
+ }
+ binding.officialRating.text = item.officialRating
+ binding.communityRating.text = item.communityRating.toString()
+ binding.genresLayout.isVisible = item.genres?.isNotEmpty() ?: false
+ binding.genres.text = genresString
+ binding.directorLayout.isVisible = director != null
+ binding.director.text = director?.name
+ binding.writersLayout.isVisible = writers.isNotEmpty()
+ binding.writers.text = writersString
+ binding.description.text = item.overview
+ binding.nextUpLayout.isVisible = nextUp != null
+ binding.nextUpName.text = String.format(getString(R.string.episode_name_extended), nextUp?.parentIndexNumber, nextUp?.indexNumber, nextUp?.name)
+ binding.seasonsLayout.isVisible = seasons.isNotEmpty()
+ val seasonsAdapter = binding.seasonsRecyclerView.adapter as ViewItemListAdapter
+ seasonsAdapter.submitList(seasons)
+ val actorsAdapter = binding.peopleRecyclerView.adapter as PersonListAdapter
+ actorsAdapter.submitList(actors)
+ bindItemBackdropImage(binding.itemBanner, item)
+ bindBaseItemImage(binding.nextUpImage, nextUp)
+ }
+ }
+
+ private fun bindUiStateLoading() {}
+
+ private fun bindUiStateError(uiState: MediaInfoViewModel.UiState.Error) {
+ val error = uiState.message ?: getString(R.string.unknown_error)
+ binding.mediaInfoScrollview.isVisible = false
+ binding.errorLayout.errorPanel.isVisible = true
+ checkIfLoginRequired(error)
+ }
+
+ private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) {
+ navigateToPlayerActivity(items.items.toTypedArray())
+ binding.playButton.setImageDrawable(
+ ContextCompat.getDrawable(
+ requireActivity(),
+ R.drawable.ic_play
+ )
+ )
+ binding.progressCircular.visibility = View.INVISIBLE
+ }
+
+ private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) {
+ Timber.e(error.message)
+ binding.playerItemsError.visibility = View.VISIBLE
+ binding.playButton.setImageDrawable(
+ ContextCompat.getDrawable(
+ requireActivity(),
+ R.drawable.ic_play
+ )
+ )
+ binding.progressCircular.visibility = View.INVISIBLE
+ binding.playerItemsErrorDetails.setOnClickListener {
+ ErrorDialogFragment(error.message).show(parentFragmentManager, "errordialog")
+ }
}
private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) {
@@ -225,8 +302,14 @@ class MediaInfoFragment : Fragment() {
) {
findNavController().navigate(
MediaInfoFragmentDirections.actionMediaInfoFragmentToPlayerActivity(
- playerItems,
+ playerItems
)
)
}
+
+ private fun navigateToPersonDetail(personId: UUID) {
+ findNavController().navigate(
+ MediaInfoFragmentDirections.actionMediaInfoFragmentToPersonDetailFragment(personId)
+ )
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/PersonDetailFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/PersonDetailFragment.kt
new file mode 100644
index 00000000..0e9a99eb
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/PersonDetailFragment.kt
@@ -0,0 +1,145 @@
+package dev.jdtech.jellyfin.fragments
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.view.isVisible
+import androidx.core.view.updateLayoutParams
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
+import dagger.hilt.android.AndroidEntryPoint
+import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
+import dev.jdtech.jellyfin.bindItemImage
+import dev.jdtech.jellyfin.databinding.FragmentPersonDetailBinding
+import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
+import dev.jdtech.jellyfin.utils.checkIfLoginRequired
+import dev.jdtech.jellyfin.viewmodels.PersonDetailViewModel
+import kotlinx.coroutines.launch
+import org.jellyfin.sdk.model.api.BaseItemDto
+import timber.log.Timber
+
+@AndroidEntryPoint
+internal class PersonDetailFragment : Fragment() {
+
+ private lateinit var binding: FragmentPersonDetailBinding
+ private val viewModel: PersonDetailViewModel by viewModels()
+
+ private val args: PersonDetailFragmentArgs by navArgs()
+
+ private lateinit var errorDialog: ErrorDialogFragment
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ binding = FragmentPersonDetailBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.moviesList.adapter = adapter()
+ binding.showList.adapter = adapter()
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
+ Timber.d("$uiState")
+ when (uiState) {
+ is PersonDetailViewModel.UiState.Normal -> bindUiStateNormal(uiState)
+ is PersonDetailViewModel.UiState.Loading -> bindUiStateLoading()
+ is PersonDetailViewModel.UiState.Error -> bindUiStateError(uiState)
+ }
+ }
+ viewModel.loadData(args.personId)
+ }
+ }
+
+ binding.errorLayout.errorRetryButton.setOnClickListener {
+ viewModel.loadData(args.personId)
+ }
+
+ binding.errorLayout.errorDetailsButton.setOnClickListener {
+ errorDialog.show(parentFragmentManager, "errordialog")
+ }
+ }
+
+ private fun bindUiStateNormal(uiState: PersonDetailViewModel.UiState.Normal) {
+ uiState.apply {
+ binding.name.text = data.name
+ binding.overview.text = data.overview
+ setupOverviewExpansion()
+ bindItemImage(binding.personImage, data.dto)
+
+ if (starredIn.movies.isNotEmpty()) {
+ binding.movieLabel.isVisible = true
+ val moviesAdapter = binding.moviesList.adapter as ViewItemListAdapter
+ moviesAdapter.submitList(starredIn.movies)
+ }
+ if (starredIn.shows.isNotEmpty()) {
+ binding.showLabel.isVisible = true
+ val showsAdapter = binding.showList.adapter as ViewItemListAdapter
+ showsAdapter.submitList(starredIn.shows)
+ }
+ }
+
+ binding.loadingIndicator.isVisible = false
+ binding.fragmentContent.isVisible = true
+ binding.errorLayout.errorPanel.isVisible = false
+ }
+
+ private fun bindUiStateLoading() {
+ binding.loadingIndicator.isVisible = true
+ binding.errorLayout.errorPanel.isVisible = false
+ }
+
+ private fun bindUiStateError(uiState: PersonDetailViewModel.UiState.Error) {
+ val error = uiState.message ?: resources.getString(R.string.unknown_error)
+ errorDialog = ErrorDialogFragment(error)
+ binding.loadingIndicator.isVisible = false
+ binding.fragmentContent.isVisible = false
+ binding.errorLayout.errorPanel.isVisible = true
+ checkIfLoginRequired(error)
+ }
+
+ private fun adapter() = ViewItemListAdapter(
+ fixedWidth = true,
+ onClickListener = ViewItemListAdapter.OnClickListener { navigateToMediaInfoFragment(it) }
+ )
+
+ private fun setupOverviewExpansion() = binding.overview.post {
+ binding.readAll.setOnClickListener {
+ with(binding.overview) {
+ if (layoutParams.height == ConstraintLayout.LayoutParams.WRAP_CONTENT) {
+ updateLayoutParams { height = ConstraintLayout.LayoutParams.MATCH_CONSTRAINT }
+ binding.readAll.text = getString(R.string.view_all)
+ binding.overviewGradient.isVisible = true
+ } else {
+ updateLayoutParams { height = ConstraintLayout.LayoutParams.WRAP_CONTENT }
+ binding.readAll.text = getString(R.string.hide)
+ binding.overviewGradient.isVisible = false
+ }
+ }
+ }
+ }
+
+ private fun navigateToMediaInfoFragment(item: BaseItemDto) {
+ findNavController().navigate(
+ PersonDetailFragmentDirections.actionPersonDetailFragmentToMediaInfoFragment(
+ itemId = item.id,
+ itemName = item.name,
+ itemType = item.type ?: "Unknown"
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/SearchResultFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/SearchResultFragment.kt
index 35e2fc65..d93872b1 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/SearchResultFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/SearchResultFragment.kt
@@ -5,7 +5,11 @@ import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint
@@ -17,24 +21,25 @@ import dev.jdtech.jellyfin.databinding.FragmentSearchResultBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.SearchResultViewModel
+import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
+import timber.log.Timber
@AndroidEntryPoint
class SearchResultFragment : Fragment() {
private lateinit var binding: FragmentSearchResultBinding
private val viewModel: SearchResultViewModel by viewModels()
-
private val args: SearchResultFragmentArgs by navArgs()
+ private lateinit var errorDialog: ErrorDialogFragment
+
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentSearchResultBinding.inflate(inflater, container, false)
- binding.lifecycleOwner = viewLifecycleOwner
- binding.viewModel = viewModel
binding.searchResultsRecyclerView.adapter = FavoritesListAdapter(
ViewItemListAdapter.OnClickListener { item ->
navigateToMediaInfoFragment(item)
@@ -42,42 +47,58 @@ class SearchResultFragment : Fragment() {
navigateToEpisodeBottomSheetFragment(item)
})
- viewModel.finishedLoading.observe(viewLifecycleOwner, { isFinished ->
- binding.loadingIndicator.visibility = if (isFinished) View.GONE else View.VISIBLE
- })
-
- viewModel.error.observe(viewLifecycleOwner, { error ->
- if (error != null) {
- checkIfLoginRequired(error)
- binding.errorLayout.errorPanel.visibility = View.VISIBLE
- binding.searchResultsRecyclerView.visibility = View.GONE
- } else {
- binding.errorLayout.errorPanel.visibility = View.GONE
- binding.searchResultsRecyclerView.visibility = View.VISIBLE
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
+ Timber.d("$uiState")
+ when (uiState) {
+ is SearchResultViewModel.UiState.Normal -> bindUiStateNormal(uiState)
+ is SearchResultViewModel.UiState.Loading -> bindUiStateLoading()
+ is SearchResultViewModel.UiState.Error -> bindUiStateError(uiState)
+ }
+ }
+ viewModel.loadData(args.query)
}
- })
+ }
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData(args.query)
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
- ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog")
+ errorDialog.show(parentFragmentManager, "errordialog")
}
- viewModel.sections.observe(viewLifecycleOwner, { sections ->
- if (sections.isEmpty()) {
- binding.noSearchResultsText.visibility = View.VISIBLE
- } else {
- binding.noSearchResultsText.visibility = View.GONE
- }
- })
-
- viewModel.loadData(args.query)
return binding.root
}
+ private fun bindUiStateNormal(uiState: SearchResultViewModel.UiState.Normal) {
+ uiState.apply {
+ binding.noSearchResultsText.isVisible = sections.isEmpty()
+
+ val adapter = binding.searchResultsRecyclerView.adapter as FavoritesListAdapter
+ adapter.submitList(uiState.sections)
+ }
+ binding.loadingIndicator.isVisible = false
+ binding.searchResultsRecyclerView.isVisible = true
+ binding.errorLayout.errorPanel.isVisible = false
+ }
+
+ private fun bindUiStateLoading() {
+ binding.loadingIndicator.isVisible = true
+ binding.errorLayout.errorPanel.isVisible = false
+ }
+
+ private fun bindUiStateError(uiState: SearchResultViewModel.UiState.Error) {
+ val error = uiState.message ?: getString(R.string.unknown_error)
+ errorDialog = ErrorDialogFragment(error)
+ binding.loadingIndicator.isVisible = false
+ binding.searchResultsRecyclerView.isVisible = false
+ binding.errorLayout.errorPanel.isVisible = true
+ checkIfLoginRequired(error)
+ }
+
private fun navigateToMediaInfoFragment(item: BaseItemDto) {
findNavController().navigate(
FavoriteFragmentDirections.actionFavoriteFragmentToMediaInfoFragment(
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt
index d680ff90..83b4d45b 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt
@@ -5,7 +5,11 @@ import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint
@@ -15,58 +19,81 @@ import dev.jdtech.jellyfin.databinding.FragmentSeasonBinding
import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment
import dev.jdtech.jellyfin.utils.checkIfLoginRequired
import dev.jdtech.jellyfin.viewmodels.SeasonViewModel
+import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
+import timber.log.Timber
@AndroidEntryPoint
class SeasonFragment : Fragment() {
private lateinit var binding: FragmentSeasonBinding
private val viewModel: SeasonViewModel by viewModels()
-
private val args: SeasonFragmentArgs by navArgs()
+ private lateinit var errorDialog: ErrorDialogFragment
+
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentSeasonBinding.inflate(inflater, container, false)
- binding.lifecycleOwner = viewLifecycleOwner
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- binding.viewModel = viewModel
- viewModel.error.observe(viewLifecycleOwner, { error ->
- if (error != null) {
- checkIfLoginRequired(error)
- binding.errorLayout.errorPanel.visibility = View.VISIBLE
- binding.episodesRecyclerView.visibility = View.GONE
- } else {
- binding.errorLayout.errorPanel.visibility = View.GONE
- binding.episodesRecyclerView.visibility = View.VISIBLE
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
+ Timber.d("$uiState")
+ when (uiState) {
+ is SeasonViewModel.UiState.Normal -> bindUiStateNormal(uiState)
+ is SeasonViewModel.UiState.Loading -> bindUiStateLoading()
+ is SeasonViewModel.UiState.Error -> bindUiStateError(uiState)
+ }
+ }
+ viewModel.loadEpisodes(args.seriesId, args.seasonId)
}
- })
+ }
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadEpisodes(args.seriesId, args.seasonId)
}
binding.errorLayout.errorDetailsButton.setOnClickListener {
- ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show(parentFragmentManager, "errordialog")
+ errorDialog.show(parentFragmentManager, "errordialog")
}
- viewModel.finishedLoading.observe(viewLifecycleOwner, {
- binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE
- })
-
binding.episodesRecyclerView.adapter =
EpisodeListAdapter(EpisodeListAdapter.OnClickListener { episode ->
navigateToEpisodeBottomSheetFragment(episode)
}, args.seriesId, args.seriesName, args.seasonId, args.seasonName)
- viewModel.loadEpisodes(args.seriesId, args.seasonId)
+ }
+
+ private fun bindUiStateNormal(uiState: SeasonViewModel.UiState.Normal) {
+ uiState.apply {
+ val adapter = binding.episodesRecyclerView.adapter as EpisodeListAdapter
+ adapter.submitList(uiState.episodes)
+ }
+ binding.loadingIndicator.isVisible = false
+ binding.episodesRecyclerView.isVisible = true
+ binding.errorLayout.errorPanel.isVisible = false
+ }
+
+ private fun bindUiStateLoading() {
+ binding.loadingIndicator.isVisible = true
+ binding.errorLayout.errorPanel.isVisible = false
+ }
+
+ private fun bindUiStateError(uiState: SeasonViewModel.UiState.Error) {
+ val error = uiState.message ?: getString(R.string.unknown_error)
+ errorDialog = ErrorDialogFragment(error)
+ binding.loadingIndicator.isVisible = false
+ binding.episodesRecyclerView.isVisible = false
+ binding.errorLayout.errorPanel.isVisible = true
+ checkIfLoginRequired(error)
}
private fun navigateToEpisodeBottomSheetFragment(episode: BaseItemDto) {
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/ServerSelectFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/ServerSelectFragment.kt
index 886db38a..5ef7f837 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/ServerSelectFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/ServerSelectFragment.kt
@@ -6,12 +6,16 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.FragmentServerSelectBinding
import dev.jdtech.jellyfin.dialogs.DeleteServerDialogFragment
import dev.jdtech.jellyfin.adapters.ServerGridAdapter
import dev.jdtech.jellyfin.viewmodels.ServerSelectViewModel
+import kotlinx.coroutines.launch
@AndroidEntryPoint
class ServerSelectFragment : Fragment() {
@@ -44,11 +48,15 @@ class ServerSelectFragment : Fragment() {
navigateToAddServerFragment()
}
- viewModel.navigateToMain.observe(viewLifecycleOwner, {
- if (it) {
- navigateToMainActivity()
+ viewLifecycleOwner.lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.onNavigateToMain(viewLifecycleOwner.lifecycleScope) {
+ if (it) {
+ navigateToMainActivity()
+ }
+ }
}
- })
+ }
return binding.root
}
@@ -61,6 +69,5 @@ class ServerSelectFragment : Fragment() {
private fun navigateToMainActivity() {
findNavController().navigate(ServerSelectFragmentDirections.actionServerSelectFragmentToHomeFragment())
- viewModel.doneNavigatingToMain()
}
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/fragments/SettingsFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/SettingsFragment.kt
index 79be8b10..d6885aad 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/fragments/SettingsFragment.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/SettingsFragment.kt
@@ -3,14 +3,26 @@ package dev.jdtech.jellyfin.fragments
import android.content.Intent
import android.net.Uri
import android.os.Bundle
-import androidx.appcompat.app.AppCompatDelegate.*
+import android.text.InputType
+import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
+import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO
+import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES
+import androidx.appcompat.app.AppCompatDelegate.setDefaultNightMode
+import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
+import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
+import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.viewmodels.SettingsViewModel
+
+@AndroidEntryPoint
+class SettingsFragment: PreferenceFragmentCompat() {
+
+ private val viewModel: SettingsViewModel by viewModels()
-class SettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.fragment_settings, rootKey)
@@ -41,5 +53,14 @@ class SettingsFragment : PreferenceFragmentCompat() {
findNavController().navigate(SettingsFragmentDirections.actionSettingsFragmentToAboutLibraries())
true
}
+
+ findPreference("image_cache_size")?.setOnBindEditTextListener { editText ->
+ editText.inputType = InputType.TYPE_CLASS_NUMBER
+ }
+
+ findPreference("deviceName")?.setOnPreferenceChangeListener { _, name ->
+ viewModel.updateDeviceName(name.toString())
+ true
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/CollectionType.kt b/app/src/main/java/dev/jdtech/jellyfin/models/CollectionType.kt
new file mode 100644
index 00000000..250e7498
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/models/CollectionType.kt
@@ -0,0 +1,21 @@
+package dev.jdtech.jellyfin.models
+
+import dev.jdtech.jellyfin.models.CollectionType.Books
+import dev.jdtech.jellyfin.models.CollectionType.HomeVideos
+import dev.jdtech.jellyfin.models.CollectionType.LiveTv
+import dev.jdtech.jellyfin.models.CollectionType.Music
+import dev.jdtech.jellyfin.models.CollectionType.Playlists
+import dev.jdtech.jellyfin.models.CollectionType.BoxSets
+
+enum class CollectionType (val type: String) {
+ HomeVideos("homevideos"),
+ Music("music"),
+ Playlists("playlists"),
+ Books("books"),
+ LiveTv("livetv"),
+ BoxSets("boxsets")
+}
+
+fun unsupportedCollections() = listOf(
+ HomeVideos, Music, Playlists, Books, LiveTv, BoxSets
+)
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/ContentType.kt b/app/src/main/java/dev/jdtech/jellyfin/models/ContentType.kt
new file mode 100644
index 00000000..aaeb6731
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/models/ContentType.kt
@@ -0,0 +1,8 @@
+package dev.jdtech.jellyfin.models
+
+enum class ContentType(val type: String) {
+ MOVIE("Movie"),
+ TVSHOW("Series"),
+ EPISODE("Episode"),
+ UNKNOWN("")
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/DownloadMetadata.kt b/app/src/main/java/dev/jdtech/jellyfin/models/DownloadMetadata.kt
new file mode 100644
index 00000000..49a41d82
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/models/DownloadMetadata.kt
@@ -0,0 +1,20 @@
+package dev.jdtech.jellyfin.models
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import java.util.*
+
+@Parcelize
+data class DownloadMetadata(
+ val id: UUID,
+ val type: String?,
+ val seriesName: String? = null,
+ val name: String? = null,
+ val parentIndexNumber: Int? = null,
+ val indexNumber: Int? = null,
+ val playbackPosition: Long? = null,
+ val playedPercentage: Double? = null,
+ val seriesId: UUID? = null,
+ val played: Boolean? = null,
+ val overview: String? = null
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/DownloadRequestItem.kt b/app/src/main/java/dev/jdtech/jellyfin/models/DownloadRequestItem.kt
new file mode 100644
index 00000000..32ad1e02
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/models/DownloadRequestItem.kt
@@ -0,0 +1,12 @@
+package dev.jdtech.jellyfin.models
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import java.util.*
+
+@Parcelize
+data class DownloadRequestItem(
+ val uri: String,
+ val itemId: UUID,
+ val metadata: DownloadMetadata
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/DownloadSection.kt b/app/src/main/java/dev/jdtech/jellyfin/models/DownloadSection.kt
new file mode 100644
index 00000000..232ca9e6
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/models/DownloadSection.kt
@@ -0,0 +1,9 @@
+package dev.jdtech.jellyfin.models
+
+import java.util.*
+
+data class DownloadSection(
+ val id: UUID,
+ val name: String,
+ var items: List
+)
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/HomeSection.kt b/app/src/main/java/dev/jdtech/jellyfin/models/HomeSection.kt
index dc1d1998..329d8d4c 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/models/HomeSection.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/models/HomeSection.kt
@@ -1,10 +1,8 @@
package dev.jdtech.jellyfin.models
import org.jellyfin.sdk.model.api.BaseItemDto
-import java.util.*
data class HomeSection(
- val id: UUID,
- val name: String?,
- var items: List? = null
+ val name: String,
+ var items: List
)
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/PlayerItem.kt b/app/src/main/java/dev/jdtech/jellyfin/models/PlayerItem.kt
index 0df53cf7..7f48644d 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/models/PlayerItem.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/models/PlayerItem.kt
@@ -2,12 +2,14 @@ package dev.jdtech.jellyfin.models
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
-import java.util.*
+import java.util.UUID
@Parcelize
data class PlayerItem(
val name: String?,
val itemId: UUID,
val mediaSourceId: String,
- val playbackPosition: Long
+ val playbackPosition: Long,
+ val mediaSourceUri: String = "",
+ val metadata: DownloadMetadata? = null
) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/models/View.kt b/app/src/main/java/dev/jdtech/jellyfin/models/View.kt
index 4f624a7f..52ce76dd 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/models/View.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/models/View.kt
@@ -1,7 +1,7 @@
package dev.jdtech.jellyfin.models
import org.jellyfin.sdk.model.api.BaseItemDto
-import java.util.*
+import java.util.UUID
data class View(
val id: UUID,
diff --git a/app/src/main/java/dev/jdtech/jellyfin/mpv/MPVPlayer.kt b/app/src/main/java/dev/jdtech/jellyfin/mpv/MPVPlayer.kt
index b8a9a573..fdd81ba0 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/mpv/MPVPlayer.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/mpv/MPVPlayer.kt
@@ -32,8 +32,10 @@ import kotlinx.parcelize.Parcelize
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
+import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
+import java.lang.IllegalArgumentException
import java.util.concurrent.CopyOnWriteArraySet
@Suppress("SpellCheckingInspection")
@@ -1199,22 +1201,22 @@ class MPVPlayer(
* @param volume The volume to set.
*/
override fun setDeviceVolume(volume: Int) {
- TODO("Not yet implemented")
+ throw IllegalArgumentException("You should use global volume controls. Check out AUDIO_SERVICE.")
}
/** Increases the volume of the device. */
override fun increaseDeviceVolume() {
- TODO("Not yet implemented")
+ throw IllegalArgumentException("You should use global volume controls. Check out AUDIO_SERVICE.")
}
/** Decreases the volume of the device. */
override fun decreaseDeviceVolume() {
- TODO("Not yet implemented")
+ throw IllegalArgumentException("You should use global volume controls. Check out AUDIO_SERVICE.")
}
/** Sets the mute state of the device. */
override fun setDeviceMuted(muted: Boolean) {
- TODO("Not yet implemented")
+ throw IllegalArgumentException("You should use global volume controls. Check out AUDIO_SERVICE.")
}
private class CurrentTrackSelection(
diff --git a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt
index d99de89b..731d19f8 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepository.kt
@@ -1,8 +1,12 @@
package dev.jdtech.jellyfin.repository
+
+import dev.jdtech.jellyfin.models.ContentType
+import dev.jdtech.jellyfin.utils.SortBy
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.MediaSourceInfo
+import org.jellyfin.sdk.model.api.SortOrder
import java.util.*
interface JellyfinRepository {
@@ -13,7 +17,15 @@ interface JellyfinRepository {
suspend fun getItems(
parentId: UUID? = null,
includeTypes: List? = null,
- recursive: Boolean = false
+ recursive: Boolean = false,
+ sortBy: SortBy = SortBy.defaultValue,
+ sortOrder: SortOrder = SortOrder.ASCENDING
+ ): List
+
+ suspend fun getPersonItems(
+ personIds: List,
+ includeTypes: List? = null,
+ recursive: Boolean = true
): List
suspend fun getFavoriteItems(): List
diff --git a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt
index c206fb53..f0e4a9b0 100644
--- a/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt
+++ b/app/src/main/java/dev/jdtech/jellyfin/repository/JellyfinRepositoryImpl.kt
@@ -1,6 +1,8 @@
package dev.jdtech.jellyfin.repository
import dev.jdtech.jellyfin.api.JellyfinApi
+import dev.jdtech.jellyfin.models.ContentType
+import dev.jdtech.jellyfin.utils.SortBy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.*
@@ -12,7 +14,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
val views: List
withContext(Dispatchers.IO) {
views =
- jellyfinApi.viewsApi.getUserViews(jellyfinApi.userId!!).content.items ?: listOf()
+ jellyfinApi.viewsApi.getUserViews(jellyfinApi.userId!!).content.items ?: emptyList()
}
return views
}
@@ -28,16 +30,38 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
override suspend fun getItems(
parentId: UUID?,
includeTypes: List?,
+ recursive: Boolean,
+ sortBy: SortBy,
+ sortOrder: SortOrder
+ ): List {
+ val items: List
+ Timber.d("$sortBy $sortOrder")
+ withContext(Dispatchers.IO) {
+ items = jellyfinApi.itemsApi.getItems(
+ jellyfinApi.userId!!,
+ parentId = parentId,
+ includeItemTypes = includeTypes,
+ recursive = recursive,
+ sortBy = listOf(sortBy.SortString),
+ sortOrder = listOf(sortOrder)
+ ).content.items ?: emptyList()
+ }
+ return items
+ }
+
+ override suspend fun getPersonItems(
+ personIds: List,
+ includeTypes: List?,
recursive: Boolean
): List {
val items: List
withContext(Dispatchers.IO) {
items = jellyfinApi.itemsApi.getItems(
jellyfinApi.userId!!,
- parentId = parentId,
- includeItemTypes = includeTypes,
+ personIds = personIds,
+ includeItemTypes = includeTypes?.map { it.type },
recursive = recursive
- ).content.items ?: listOf()
+ ).content.items ?: emptyList()
}
return items
}
@@ -50,7 +74,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
filters = listOf(ItemFilter.IS_FAVORITE),
includeItemTypes = listOf("Movie", "Series", "Episode"),
recursive = true
- ).content.items ?: listOf()
+ ).content.items ?: emptyList()
}
return items
}
@@ -63,7 +87,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
searchTerm = searchQuery,
includeItemTypes = listOf("Movie", "Series", "Episode"),
recursive = true
- ).content.items ?: listOf()
+ ).content.items ?: emptyList()
}
return items
}
@@ -75,7 +99,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
jellyfinApi.itemsApi.getResumeItems(
jellyfinApi.userId!!,
includeItemTypes = listOf("Movie", "Episode"),
- ).content.items ?: listOf()
+ ).content.items ?: emptyList()
}
return items
}
@@ -95,7 +119,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
val seasons: List
withContext(Dispatchers.IO) {
seasons = jellyfinApi.showsApi.getSeasons(seriesId, jellyfinApi.userId!!).content.items
- ?: listOf()
+ ?: emptyList()
}
return seasons
}
@@ -106,7 +130,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
nextUpItems = jellyfinApi.showsApi.getNextUp(
jellyfinApi.userId!!,
seriesId = seriesId?.toString(),
- ).content.items ?: listOf()
+ ).content.items ?: emptyList()
}
return nextUpItems
}
@@ -125,7 +149,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
seasonId = seasonId,
fields = fields,
startItemId = startItemId
- ).content.items ?: listOf()
+ ).content.items ?: emptyList()
}
return episodes
}
@@ -139,15 +163,15 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
name = "Direct play all",
maxStaticBitrate = 1_000_000_000,
maxStreamingBitrate = 1_000_000_000,
- codecProfiles = listOf(),
- containerProfiles = listOf(),
+ codecProfiles = emptyList(),
+ containerProfiles = emptyList(),
directPlayProfiles = listOf(
DirectPlayProfile(
type = DlnaProfileType.VIDEO
), DirectPlayProfile(type = DlnaProfileType.AUDIO)
),
- transcodingProfiles = listOf(),
- responseProfiles = listOf(),
+ transcodingProfiles = emptyList(),
+ responseProfiles = emptyList(),
enableAlbumArtInDidl = false,
enableMsMediaReceiverRegistrar = false,
enableSingleAlbumArtLimit = false,
@@ -165,7 +189,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
maxStreamingBitrate = 1_000_000_000,
)
)
- mediaSourceInfoList = mediaInfo.mediaSources ?: listOf()
+ mediaSourceInfoList = mediaInfo.mediaSources ?: emptyList()
return mediaSourceInfoList
}
@@ -272,7 +296,7 @@ class JellyfinRepositoryImpl(private val jellyfinApi: JellyfinApi) : JellyfinRep
withContext(Dispatchers.IO) {
intros =
jellyfinApi.userLibraryApi.getIntros(jellyfinApi.userId!!, itemId).content.items
- ?: listOf()
+ ?: emptyList()
}
return intros
}
diff --git a/app/src/main/java/dev/jdtech/jellyfin/tv/TvPlayerActivity.kt b/app/src/main/java/dev/jdtech/jellyfin/tv/TvPlayerActivity.kt
new file mode 100644
index 00000000..5af58b91
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/tv/TvPlayerActivity.kt
@@ -0,0 +1,155 @@
+package dev.jdtech.jellyfin.tv
+
+import android.os.Bundle
+import android.view.Gravity
+import android.view.KeyEvent
+import android.view.LayoutInflater
+import android.view.View
+import android.view.WindowManager
+import android.widget.ImageButton
+import android.widget.PopupWindow
+import android.widget.TextView
+import androidx.activity.viewModels
+import androidx.navigation.navArgs
+import androidx.recyclerview.widget.RecyclerView
+import dagger.hilt.android.AndroidEntryPoint
+import dev.jdtech.jellyfin.BasePlayerActivity
+import dev.jdtech.jellyfin.PlayerActivityArgs
+import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.databinding.ActivityPlayerTvBinding
+import dev.jdtech.jellyfin.mpv.MPVPlayer
+import dev.jdtech.jellyfin.mpv.TrackType.AUDIO
+import dev.jdtech.jellyfin.mpv.TrackType.SUBTITLE
+import dev.jdtech.jellyfin.tv.ui.TrackSelectorAdapter
+import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
+import timber.log.Timber
+
+@AndroidEntryPoint
+internal class TvPlayerActivity : BasePlayerActivity() {
+
+ private lateinit var binding: ActivityPlayerTvBinding
+ override val viewModel: PlayerActivityViewModel by viewModels()
+ private val args: PlayerActivityArgs by navArgs()
+ private var displayedPopup: PopupWindow? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ Timber.d("Player activity created.")
+ super.onCreate(savedInstanceState)
+
+ binding = ActivityPlayerTvBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+
+ binding.playerView.player = viewModel.player
+ val playerControls = binding.playerView.findViewById(R.id.tv_player_controls)
+ configureInsets(playerControls)
+
+ bind()
+ viewModel.initializePlayer(args.items)
+ hideSystemUI()
+ }
+
+ override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
+ return if (!binding.playerView.isControllerVisible) {
+ binding.playerView.showController()
+ true
+ } else {
+ false
+ }
+ }
+
+ private fun bind() = with(binding.playerView) {
+ val videoNameTextView = findViewById(R.id.video_name)
+ viewModel.currentItemTitle.observe(this@TvPlayerActivity, { title ->
+ videoNameTextView.text = title
+ })
+
+ findViewById(R.id.exo_play_pause).apply {
+ setOnClickListener {
+ when {
+ viewModel.player.isPlaying -> {
+ viewModel.player.pause()
+ setImageDrawable(resources.getDrawable(R.drawable.ic_play))
+ }
+ viewModel.player.isLoading -> Unit
+ else -> {
+ viewModel.player.play()
+ setImageDrawable(resources.getDrawable(R.drawable.ic_pause))
+ }
+ }
+ }
+ }
+
+ findViewById(R.id.back_button).setOnClickListener {
+ onBackPressed()
+ }
+
+ bindAudioControl()
+ bindSubtitleControl()
+ }
+
+ private fun bindAudioControl() {
+ val audioBtn = binding.playerView.findViewById(R.id.btn_audio_track)
+
+ audioBtn.setOnFocusChangeListener { v, hasFocus ->
+ displayedPopup = if (hasFocus) {
+ val items = viewModel.currentSubtitleTracks.toUiTrack()
+ audioBtn.showPopupWindowAbove(items, AUDIO)
+ } else {
+ displayedPopup?.dismiss()
+ null
+ }
+ }
+ }
+
+ private fun bindSubtitleControl() {
+ val subtitleBtn = binding.playerView.findViewById(R.id.btn_subtitle)
+
+ subtitleBtn.setOnFocusChangeListener { v, hasFocus ->
+ v.isFocusable = true
+ displayedPopup = if (hasFocus) {
+ val items = viewModel.currentSubtitleTracks.toUiTrack()
+ subtitleBtn.showPopupWindowAbove(items, SUBTITLE)
+ } else {
+ displayedPopup?.dismiss()
+ null
+ }
+ }
+ }
+
+ private fun List.toUiTrack() = map { track ->
+ TrackSelectorAdapter.Track(
+ title = track.title,
+ language = track.lang,
+ codec = track.codec,
+ selected = track.selected,
+ playerTrack = track
+ )
+ }
+
+ private fun View.showPopupWindowAbove(
+ items: List,
+ type: String
+ ): PopupWindow {
+ val popup = PopupWindow(this.context)
+ popup.contentView = LayoutInflater.from(context).inflate(R.layout.track_selector, null)
+ val recyclerView = popup.contentView.findViewById(R.id.track_selector)
+
+ recyclerView.adapter = TrackSelectorAdapter(items, viewModel, type) { popup.dismiss() }
+
+ val startViewCoords = IntArray(2)
+ getLocationInWindow(startViewCoords)
+
+ val itemHeight = resources.getDimension(R.dimen.track_selection_item_height).toInt()
+ val totalHeight = items.size * itemHeight
+
+ popup.showAsDropDown(
+ binding.root,
+ startViewCoords.first(),
+ startViewCoords.last() - totalHeight,
+ Gravity.TOP
+ )
+
+ return popup
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/tv/ui/HomeFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/HomeFragment.kt
new file mode 100644
index 00000000..2de4a6c1
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/HomeFragment.kt
@@ -0,0 +1,122 @@
+package dev.jdtech.jellyfin.tv.ui
+
+import android.os.Bundle
+import android.view.KeyEvent.KEYCODE_DPAD_DOWN
+import android.view.KeyEvent.KEYCODE_DPAD_DOWN_LEFT
+import android.view.View
+import android.widget.ImageButton
+import androidx.fragment.app.viewModels
+import androidx.leanback.app.BrowseSupportFragment
+import androidx.leanback.widget.ArrayObjectAdapter
+import androidx.leanback.widget.HeaderItem
+import androidx.leanback.widget.ListRow
+import androidx.leanback.widget.ListRowPresenter
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.fragment.findNavController
+import dagger.hilt.android.AndroidEntryPoint
+import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.adapters.HomeItem
+import dev.jdtech.jellyfin.viewmodels.HomeViewModel
+import kotlinx.coroutines.launch
+import org.jellyfin.sdk.model.api.BaseItemDto
+import timber.log.Timber
+
+@AndroidEntryPoint
+internal class HomeFragment : BrowseSupportFragment() {
+
+ private val viewModel: HomeViewModel by viewModels()
+
+ private lateinit var rowsAdapter: ArrayObjectAdapter
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ headersState = HEADERS_ENABLED
+ rowsAdapter = ArrayObjectAdapter(ListRowPresenter())
+ adapter = rowsAdapter
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ view.findViewById(R.id.settings).apply {
+ setOnKeyListener { _, keyCode, _ ->
+ if (keyCode == KEYCODE_DPAD_DOWN || keyCode == KEYCODE_DPAD_DOWN_LEFT) {
+ headersSupportFragment.view?.requestFocus()
+ true
+ } else {
+ false
+ }
+ }
+ setOnClickListener { navigateToSettingsFragment() }
+ }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
+ Timber.d("$uiState")
+ when (uiState) {
+ is HomeViewModel.UiState.Normal -> bindUiStateNormal(uiState)
+ is HomeViewModel.UiState.Loading -> Unit
+ is HomeViewModel.UiState.Error -> Unit
+ }
+ }
+ }
+ }
+ }
+
+ private fun bindUiStateNormal(uiState: HomeViewModel.UiState.Normal) {
+ uiState.apply {
+ rowsAdapter.clear()
+ homeItems.map { section -> rowsAdapter.add(section.toListRow()) }
+ }
+ }
+
+ private fun HomeItem.toListRow(): ListRow {
+ return ListRow(
+ toHeader(),
+ toItems()
+ )
+ }
+
+ private fun HomeItem.toHeader(): HeaderItem {
+ return when (this) {
+ is HomeItem.Section -> HeaderItem(homeSection.name)
+ is HomeItem.ViewItem -> HeaderItem(
+ String.format(
+ resources.getString(R.string.latest_library),
+ view.name
+ )
+ )
+ }
+ }
+
+ private fun HomeItem.toItems(): ArrayObjectAdapter {
+ return when (this) {
+ is HomeItem.Section -> ArrayObjectAdapter(DynamicMediaItemPresenter { item ->
+ navigateToMediaDetailFragment(item)
+ }).apply { addAll(0, homeSection.items) }
+ is HomeItem.ViewItem -> ArrayObjectAdapter(MediaItemPresenter { item ->
+ navigateToMediaDetailFragment(item)
+ }).apply { addAll(0, view.items) }
+ }
+ }
+
+ private fun navigateToMediaDetailFragment(item: BaseItemDto) {
+ findNavController().navigate(
+ HomeFragmentDirections.actionHomeFragmentToMediaDetailFragment(
+ item.id,
+ item.seriesName ?: item.name,
+ item.type ?: "Unknown"
+ )
+ )
+ }
+
+ private fun navigateToSettingsFragment() {
+ findNavController().navigate(
+ HomeFragmentDirections.actionNavigationHomeToSettings()
+ )
+ }
+}
diff --git a/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailFragment.kt
new file mode 100644
index 00000000..f12fee34
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailFragment.kt
@@ -0,0 +1,223 @@
+package dev.jdtech.jellyfin.tv.ui
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.core.content.ContextCompat
+import androidx.core.view.isVisible
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
+import dagger.hilt.android.AndroidEntryPoint
+import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.adapters.PersonListAdapter
+import dev.jdtech.jellyfin.adapters.ViewItemListAdapter
+import dev.jdtech.jellyfin.bindBaseItemImage
+import dev.jdtech.jellyfin.databinding.MediaDetailFragmentBinding
+import dev.jdtech.jellyfin.dialogs.VideoVersionDialogFragment
+import dev.jdtech.jellyfin.models.PlayerItem
+import dev.jdtech.jellyfin.viewmodels.MediaInfoViewModel
+import dev.jdtech.jellyfin.viewmodels.PlayerViewModel
+import dev.jdtech.jellyfin.viewmodels.PlayerViewModel.PlayerItemError
+import dev.jdtech.jellyfin.viewmodels.PlayerViewModel.PlayerItems
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+@AndroidEntryPoint
+internal class MediaDetailFragment : Fragment() {
+
+ private lateinit var binding: MediaDetailFragmentBinding
+
+ private val viewModel: MediaInfoViewModel by viewModels()
+ private val playerViewModel: PlayerViewModel by viewModels()
+
+ private val args: MediaDetailFragmentArgs by navArgs()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ viewModel.loadData(args.itemId, args.itemType)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ binding = MediaDetailFragmentBinding.inflate(inflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.onUiState(viewLifecycleOwner.lifecycleScope) { uiState ->
+ Timber.d("$uiState")
+ when (uiState) {
+ is MediaInfoViewModel.UiState.Normal -> bindUiStateNormal(uiState)
+ is MediaInfoViewModel.UiState.Loading -> bindUiStateLoading()
+ is MediaInfoViewModel.UiState.Error -> bindUiStateError(uiState)
+ }
+ }
+ }
+ }
+
+ val seasonsAdapter = ViewItemListAdapter(
+ fixedWidth = true,
+ onClickListener = ViewItemListAdapter.OnClickListener {})
+
+ binding.seasonsRow.gridView.adapter = seasonsAdapter
+ binding.seasonsRow.gridView.verticalSpacing = 25
+
+ val castAdapter = PersonListAdapter { person ->
+ Toast.makeText(requireContext(), "Not yet implemented", Toast.LENGTH_SHORT).show()
+ }
+
+ binding.castRow.gridView.adapter = castAdapter
+ binding.castRow.gridView.verticalSpacing = 25
+
+ playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems ->
+ when (playerItems) {
+ is PlayerItemError -> bindPlayerItemsError(playerItems)
+ is PlayerItems -> bindPlayerItems(playerItems)
+ }
+ }
+
+ binding.playButton.setOnClickListener {
+ binding.playButton.setImageResource(android.R.color.transparent)
+ binding.progressCircular.isVisible = true
+ viewModel.item?.let { item ->
+ playerViewModel.loadPlayerItems(item) {
+ VideoVersionDialogFragment(item, playerViewModel).show(
+ parentFragmentManager,
+ "videoversiondialog"
+ )
+ }
+ }
+ }
+
+ binding.trailerButton.setOnClickListener {
+ if (viewModel.item?.remoteTrailers.isNullOrEmpty()) return@setOnClickListener
+ val intent = Intent(
+ Intent.ACTION_VIEW,
+ Uri.parse(viewModel.item?.remoteTrailers?.get(0)?.url)
+ )
+ startActivity(intent)
+ }
+
+ binding.checkButton.setOnClickListener {
+ when (viewModel.played) {
+ true -> {
+ viewModel.markAsUnplayed(args.itemId)
+ binding.checkButton.setImageResource(R.drawable.ic_check)
+ }
+ false -> {
+ viewModel.markAsPlayed(args.itemId)
+ binding.checkButton.setImageResource(R.drawable.ic_check_filled)
+ }
+ }
+ }
+
+ binding.favoriteButton.setOnClickListener {
+ when (viewModel.favorite) {
+ true -> {
+ viewModel.unmarkAsFavorite(args.itemId)
+ binding.favoriteButton.setImageResource(R.drawable.ic_heart)
+ }
+ false -> {
+ viewModel.markAsFavorite(args.itemId)
+ binding.favoriteButton.setImageResource(R.drawable.ic_heart_filled)
+ }
+ }
+ }
+
+ binding.backButton.setOnClickListener { activity?.onBackPressed() }
+ }
+
+ private fun bindUiStateNormal(uiState: MediaInfoViewModel.UiState.Normal) {
+ uiState.apply {
+ binding.seasonTitle.isVisible = seasons.isNotEmpty()
+ val seasonsAdapter = binding.seasonsRow.gridView.adapter as ViewItemListAdapter
+ seasonsAdapter.submitList(seasons)
+ binding.castTitle.isVisible = actors.isNotEmpty()
+ val actorsAdapter = binding.castRow.gridView.adapter as PersonListAdapter
+ actorsAdapter.submitList(actors)
+
+ // Check icon
+ val checkDrawable = when (played) {
+ true -> R.drawable.ic_check_filled
+ false -> R.drawable.ic_check
+ }
+ binding.checkButton.setImageResource(checkDrawable)
+
+ // Favorite icon
+ val favoriteDrawable = when (favorite) {
+ true -> R.drawable.ic_heart_filled
+ false -> R.drawable.ic_heart
+ }
+ binding.favoriteButton.setImageResource(favoriteDrawable)
+
+ binding.title.text = item.name
+ binding.subtitle.text = item.seriesName
+ item.seriesName.let {
+ binding.subtitle.text = it
+ binding.subtitle.isVisible = true
+ }
+ binding.genres.text = genresString
+ binding.year.text = dateString
+ binding.playtime.text = runTime
+ binding.officialRating.text = item.officialRating
+ binding.communityRating.text = item.communityRating.toString()
+ binding.description.text = item.overview
+ bindBaseItemImage(binding.poster, item)
+ }
+ }
+
+ private fun bindUiStateLoading() {}
+
+ private fun bindUiStateError(uiState: MediaInfoViewModel.UiState.Error) {}
+
+ private fun bindPlayerItems(items: PlayerItems) {
+ navigateToPlayerActivity(items.items.toTypedArray())
+ binding.playButton.setImageDrawable(
+ ContextCompat.getDrawable(
+ requireActivity(),
+ R.drawable.ic_play
+ )
+ )
+ binding.progressCircular.visibility = View.INVISIBLE
+ }
+
+ private fun bindPlayerItemsError(error: PlayerItemError) {
+ Timber.e(error.message)
+
+ binding.errorLayout.errorPanel.isVisible = true
+ binding.playButton.setImageDrawable(
+ ContextCompat.getDrawable(
+ requireActivity(),
+ R.drawable.ic_play
+ )
+ )
+ binding.progressCircular.visibility = View.INVISIBLE
+ }
+
+ private fun navigateToPlayerActivity(
+ playerItems: Array,
+ ) {
+ findNavController().navigate(
+ MediaDetailFragmentDirections.actionMediaDetailFragmentToPlayerActivity(
+ playerItems
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailViewModel.kt
new file mode 100644
index 00000000..35214149
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaDetailViewModel.kt
@@ -0,0 +1,65 @@
+package dev.jdtech.jellyfin.tv.ui
+
+import android.content.res.Resources
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.models.ContentType.MOVIE
+import org.jellyfin.sdk.model.api.BaseItemDto
+import javax.inject.Inject
+
+@HiltViewModel
+internal class MediaDetailViewModel @Inject internal constructor() : ViewModel() {
+
+ fun transformData(
+ data: BaseItemDto,
+ resources: Resources,
+ transformed: (State) -> Unit
+ ): State {
+ return State(
+ dto = data,
+ description = data.overview.orEmpty(),
+ year = data.productionYear.toString(),
+ officialRating = data.officialRating.orEmpty(),
+ communityRating = data.communityRating.toString(),
+ runtimeMinutes = String.format(
+ resources.getString(R.string.runtime_minutes),
+ data.runTimeTicks?.div(600_000_000)
+ ),
+ genres = data.genres?.joinToString(" / ").orEmpty(),
+ trailerUrl = data.remoteTrailers?.firstOrNull()?.url,
+ isPlayed = data.userData?.played == true,
+ isFavorite = data.userData?.isFavorite == true,
+ media = if (data.type == MOVIE.type) {
+ State.Movie(
+ title = data.name.orEmpty()
+ )
+ } else {
+ State.TvShow(
+ episode = data.episodeTitle ?: data.name.orEmpty(),
+ show = data.seriesName.orEmpty()
+ )
+ }
+ ).also(transformed)
+ }
+
+ data class State(
+ val dto: BaseItemDto,
+ val description: String,
+ val year: String,
+ val officialRating: String,
+ val communityRating: String,
+ val runtimeMinutes: String,
+ val genres: String,
+ val trailerUrl: String?,
+ val isPlayed: Boolean,
+ val isFavorite: Boolean,
+ val media: Media
+ ) {
+
+ sealed class Media
+
+ data class Movie(val title: String): Media()
+ data class TvShow(val episode: String, val show: String): Media()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaSectionPresenter.kt b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaSectionPresenter.kt
new file mode 100644
index 00000000..f70a0524
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/MediaSectionPresenter.kt
@@ -0,0 +1,78 @@
+package dev.jdtech.jellyfin.tv.ui
+
+import android.content.Context.LAYOUT_INFLATER_SERVICE
+import android.util.TypedValue
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.isVisible
+import androidx.databinding.DataBindingUtil
+import androidx.leanback.widget.Presenter
+import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.databinding.BaseItemBinding
+import dev.jdtech.jellyfin.databinding.HomeEpisodeItemBinding
+import org.jellyfin.sdk.model.api.BaseItemDto
+
+class MediaItemPresenter(private val onClick: (BaseItemDto) -> Unit) : Presenter() {
+
+ override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
+ val mediaView =
+ BaseItemBinding
+ .inflate(parent.context.getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater)
+ .root
+ return ViewHolder(mediaView)
+ }
+
+ override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
+ if (item is BaseItemDto) {
+ DataBindingUtil.getBinding(viewHolder.view)?.apply {
+ this.item = item
+ this.itemName.text = if (item.type == "Episode") item.seriesName else item.name
+ this.itemCount.visibility =
+ if (item.userData?.unplayedItemCount != null && item.userData?.unplayedItemCount!! > 0) View.VISIBLE else View.GONE
+ this.itemLayout.layoutParams.width =
+ this.itemLayout.resources.getDimension(R.dimen.overview_media_width).toInt()
+ (this.itemLayout.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = 0
+ viewHolder.view.setOnClickListener { onClick(item) }
+ }
+ }
+ }
+
+ override fun onUnbindViewHolder(viewHolder: ViewHolder) = Unit
+}
+
+class DynamicMediaItemPresenter(private val onClick: (BaseItemDto) -> Unit) : Presenter() {
+
+ override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
+ val mediaView =
+ HomeEpisodeItemBinding
+ .inflate(parent.context.getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater)
+ .root
+ return ViewHolder(mediaView)
+ }
+
+ override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
+ if (item is BaseItemDto) {
+ DataBindingUtil.getBinding(viewHolder.view)?.apply {
+ episode = item
+ item.userData?.playedPercentage?.toInt()?.let {
+ progressBar.layoutParams.width = TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ (it.times(2.24)).toFloat(), progressBar.context.resources.displayMetrics).toInt()
+ progressBar.isVisible = true
+ }
+
+ if (item.type == "Movie") {
+ primaryName.text = item.name
+ secondaryName.visibility = View.GONE
+ } else if (item.type == "Episode") {
+ primaryName.text = item.seriesName
+ }
+
+ viewHolder.view.setOnClickListener { onClick(item) }
+ }
+ }
+ }
+
+ override fun onUnbindViewHolder(viewHolder: ViewHolder) = Unit
+}
\ No newline at end of file
diff --git a/app/src/main/java/dev/jdtech/jellyfin/tv/ui/TrackSelectorAdapter.kt b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/TrackSelectorAdapter.kt
new file mode 100644
index 00000000..600c93ec
--- /dev/null
+++ b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/TrackSelectorAdapter.kt
@@ -0,0 +1,71 @@
+package dev.jdtech.jellyfin.tv.ui
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import androidx.recyclerview.widget.RecyclerView
+import dev.jdtech.jellyfin.R
+import dev.jdtech.jellyfin.mpv.MPVPlayer
+import dev.jdtech.jellyfin.mpv.TrackType
+import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
+
+class TrackSelectorAdapter(
+ private val items: List