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, + private val viewModel: PlayerActivityViewModel, + private val trackType: String, + private val dismissWindow: () -> Unit +) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackSelectorViewHolder { + return TrackSelectorViewHolder( + LayoutInflater.from(parent.context).inflate(R.layout.track_item, parent, false) + ) + } + + override fun onBindViewHolder(holder: TrackSelectorViewHolder, position: Int) { + holder.bind(items[position], viewModel, trackType, dismissWindow) + } + + override fun getItemCount(): Int = items.size + + class TrackSelectorViewHolder(private val view: View) : RecyclerView.ViewHolder(view) { + + fun bind( + item: Track, + viewModel: PlayerActivityViewModel, + trackType: String, + dismissWindow: () -> Unit + ) { + view.findViewById