diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4f46fbd7..9a207f5f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -59,6 +59,8 @@ dependencies { implementation("androidx.core:core-ktx:1.7.0") implementation("androidx.core:core-splashscreen:1.0.0-alpha02") implementation("androidx.appcompat:appcompat:1.3.1") + implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") + // Material implementation("com.google.android.material:material:1.4.0") diff --git a/app/src/main/java/dev/jdtech/jellyfin/adapters/ViewListAdapter.kt b/app/src/main/java/dev/jdtech/jellyfin/adapters/ViewListAdapter.kt index 0e720fd8..04078bc5 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/adapters/ViewListAdapter.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/adapters/ViewListAdapter.kt @@ -10,7 +10,7 @@ import dev.jdtech.jellyfin.databinding.NextUpSectionBinding import dev.jdtech.jellyfin.databinding.ViewItemBinding import dev.jdtech.jellyfin.models.HomeSection import dev.jdtech.jellyfin.models.View -import java.util.* +import java.util.UUID private const val ITEM_VIEW_TYPE_NEXT_UP = 0 private const val ITEM_VIEW_TYPE_VIEW = 1 @@ -51,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 { @@ -106,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/fragments/HomeFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt index 171799ca..2260629f 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/fragments/HomeFragment.kt @@ -7,8 +7,12 @@ 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.lifecycleScope import androidx.navigation.fragment.findNavController import dagger.hilt.android.AndroidEntryPoint import dev.jdtech.jellyfin.R @@ -17,8 +21,15 @@ 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 dev.jdtech.jellyfin.viewmodels.HomeViewModel.Loading +import dev.jdtech.jellyfin.viewmodels.HomeViewModel.LoadingError import org.jellyfin.sdk.model.api.BaseItemDto @AndroidEntryPoint @@ -57,49 +68,79 @@ class HomeFragment : Fragment() { 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) - } - "Movie" -> { - navigateToMediaInfoFragment(item) - } - } - }) + setupView() + bindState() - viewModel.finishedLoading.observe(viewLifecycleOwner, { - binding.loadingIndicator.visibility = if (it) View.GONE else View.VISIBLE - }) + return binding.root + } - 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 - } - }) + override fun onResume() { + super.onResume() - binding.errorLayout.errorRetryButton.setOnClickListener { - viewModel.loadData() + viewModel.refreshData() + } + + private fun setupView() { + binding.refreshLayout.setOnRefreshListener { + viewModel.refreshData() } + 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() + } + }) + } + + private fun bindState() { + viewModel.onStateUpdate(lifecycleScope) { state -> + when (state) { + is Loading -> bindLoading(state) + is LoadingError -> bindError(state) + } + } + } + + private fun bindError(state: LoadingError) { + checkIfLoginRequired(state.message) + binding.errorLayout.errorPanel.isVisible = true + binding.viewsRecyclerView.isVisible = false + binding.loadingIndicator.isVisible = false + binding.refreshLayout.isRefreshing = false + binding.errorLayout.errorDetailsButton.setOnClickListener { - ErrorDialogFragment(viewModel.error.value ?: getString(R.string.unknown_error)).show( + ErrorDialogFragment(state.message).show( parentFragmentManager, "errordialog" ) } - return binding.root + binding.errorLayout.errorRetryButton.setOnClickListener { + viewModel.refreshData() + } + } + + private fun bindLoading(state: Loading) { + binding.errorLayout.errorPanel.isVisible = false + binding.viewsRecyclerView.isVisible = true + + binding.loadingIndicator.visibility = when { + state.inProgress && binding.refreshLayout.isRefreshing -> View.GONE + state.inProgress -> View.VISIBLE + else -> { + binding.refreshLayout.isRefreshing = false + View.GONE + } + } } private fun navigateToLibraryFragment(view: dev.jdtech.jellyfin.models.View) { @@ -113,12 +154,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 { @@ -126,7 +167,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/models/CollectionType.kt b/app/src/main/java/dev/jdtech/jellyfin/models/CollectionType.kt new file mode 100644 index 00000000..7cedc412 --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/models/CollectionType.kt @@ -0,0 +1,19 @@ +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 + +enum class CollectionType (val type: String) { + HomeVideos("homevideos"), + Music("music"), + Playlists("playlists"), + Books("books"), + LiveTv("livetv") +} + +fun unsupportedCollections() = listOf( + HomeVideos, Music, Playlists, Books, LiveTv +) \ 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 index f06b5a80..aaeb6731 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/models/ContentType.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/models/ContentType.kt @@ -3,5 +3,6 @@ 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/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/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/tv/ui/HomeFragment.kt b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/HomeFragment.kt index 4a27456d..60b4f3fb 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/tv/ui/HomeFragment.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/tv/ui/HomeFragment.kt @@ -48,7 +48,7 @@ internal class HomeFragment : BrowseSupportFragment() { setOnClickListener { navigateToSettingsFragment() } } - viewModel.views.observe(viewLifecycleOwner) { homeItems -> + viewModel.views().observe(viewLifecycleOwner) { homeItems -> rowsAdapter.clear() homeItems.map { section -> rowsAdapter.add(section.toListRow()) } } diff --git a/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt b/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt index d12d4af4..b76a8f53 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/utils/DownloadUtilities.kt @@ -16,13 +16,11 @@ import dev.jdtech.jellyfin.models.DownloadMetadata import dev.jdtech.jellyfin.models.DownloadRequestItem import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.repository.JellyfinRepository -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.UserItemDataDto import timber.log.Timber import java.io.File -import java.util.* +import java.util.UUID fun requestDownload(uri: Uri, downloadRequestItem: DownloadRequestItem, context: Fragment) { // Storage permission for downloads isn't necessary from Android 10 onwards @@ -209,7 +207,7 @@ fun parseMetadataFile(metadataFile: List) : DownloadMetadata { suspend fun syncPlaybackProgress(jellyfinRepository: JellyfinRepository, context: Context) { val items = loadDownloadedEpisodes(context) - items.forEach(){ + items.forEach{ try { val localPlaybackProgress = it.metadata?.playbackPosition val localPlayedPercentage = it.metadata?.playedPercentage diff --git a/app/src/main/java/dev/jdtech/jellyfin/utils/ViewExtensions.kt b/app/src/main/java/dev/jdtech/jellyfin/utils/ViewExtensions.kt new file mode 100644 index 00000000..2da7c0ec --- /dev/null +++ b/app/src/main/java/dev/jdtech/jellyfin/utils/ViewExtensions.kt @@ -0,0 +1,8 @@ +package dev.jdtech.jellyfin.utils + +import android.view.View +import androidx.core.view.isVisible + +fun View.toggleVisibility() { + isVisible = !isVisible +} diff --git a/app/src/main/java/dev/jdtech/jellyfin/utils/extensions.kt b/app/src/main/java/dev/jdtech/jellyfin/utils/extensions.kt index f74bf2d0..69d04439 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/utils/extensions.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/utils/extensions.kt @@ -22,6 +22,7 @@ fun BaseItemDto.toView(): View { fun BaseItemDto.contentType() = when (type) { "Movie" -> ContentType.MOVIE "Series" -> ContentType.TVSHOW + "Episode" -> ContentType.EPISODE else -> ContentType.UNKNOWN } @@ -32,5 +33,6 @@ fun Fragment.checkIfLoginRequired(error: String) { } } + inline fun Context.toast(@StringRes text: Int, duration: Int = Toast.LENGTH_SHORT) = Toast.makeText(this, text, duration).show() \ No newline at end of file diff --git a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/HomeViewModel.kt b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/HomeViewModel.kt index eb843019..19248ada 100644 --- a/app/src/main/java/dev/jdtech/jellyfin/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/dev/jdtech/jellyfin/viewmodels/HomeViewModel.kt @@ -1,6 +1,7 @@ package dev.jdtech.jellyfin.viewmodels import android.app.Application +import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -8,108 +9,104 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dev.jdtech.jellyfin.R import dev.jdtech.jellyfin.adapters.HomeItem +import dev.jdtech.jellyfin.adapters.HomeItem.Section +import dev.jdtech.jellyfin.adapters.HomeItem.ViewItem import dev.jdtech.jellyfin.models.HomeSection -import dev.jdtech.jellyfin.models.View +import dev.jdtech.jellyfin.models.unsupportedCollections import dev.jdtech.jellyfin.repository.JellyfinRepository import dev.jdtech.jellyfin.utils.syncPlaybackProgress import dev.jdtech.jellyfin.utils.toView import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.jellyfin.sdk.model.api.BaseItemDto import timber.log.Timber -import java.util.* import javax.inject.Inject @HiltViewModel -class HomeViewModel -@Inject -constructor( +class HomeViewModel @Inject internal constructor( private val application: Application, - private val jellyfinRepository: JellyfinRepository + private val repository: JellyfinRepository ) : ViewModel() { + private val views = MutableLiveData>() + private val state = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + init { + loadData(updateCapabilities = true) + } + private val continueWatchingString = application.resources.getString(R.string.continue_watching) private val nextUpString = application.resources.getString(R.string.next_up) - private val _views = MutableLiveData>() - val views: LiveData> = _views + fun views(): LiveData> = views - private val _items = MutableLiveData>() - val items: LiveData> = _items - - private val _finishedLoading = MutableLiveData() - val finishedLoading: LiveData = _finishedLoading - - private val _error = MutableLiveData() - val error: LiveData = _error - - init { - loadData() + fun onStateUpdate( + scope: LifecycleCoroutineScope, + collector: (State) -> Unit + ) { + scope.launch { state.collect { collector(it) } } } - fun loadData() { - _error.value = null - _finishedLoading.value = false + fun refreshData() = loadData(updateCapabilities = false) + + private fun loadData(updateCapabilities: Boolean) { + state.tryEmit(Loading(inProgress = true)) + viewModelScope.launch { try { - jellyfinRepository.postCapabilities() + if (updateCapabilities) repository.postCapabilities() - val items = mutableListOf() + val updated = loadDynamicItems() + loadViews() + views.postValue(updated) withContext(Dispatchers.Default) { - - val resumeItems = jellyfinRepository.getResumeItems() - val resumeSection = - HomeSection(UUID.randomUUID(), continueWatchingString, resumeItems) - - if (!resumeItems.isNullOrEmpty()) { - items.add(HomeItem.Section(resumeSection)) - } - - val nextUpItems = jellyfinRepository.getNextUp() - val nextUpSection = HomeSection(UUID.randomUUID(), nextUpString, nextUpItems) - - if (!nextUpItems.isNullOrEmpty()) { - items.add(HomeItem.Section(nextUpSection)) - } + syncPlaybackProgress(repository, application) } - - val views: MutableList = mutableListOf() - - withContext(Dispatchers.Default) { - val userViews = jellyfinRepository.getUserViews() - - for (view in userViews) { - Timber.d("Collection type: ${view.collectionType}") - if (view.collectionType == "homevideos" || - view.collectionType == "music" || - view.collectionType == "playlists" || - view.collectionType == "books" || - view.collectionType == "livetv" - ) continue - val latestItems = jellyfinRepository.getLatestMedia(view.id) - if (latestItems.isEmpty()) continue - val v = view.toView() - v.items = latestItems - views.add(v) - } - } - - withContext(Dispatchers.Default) { - syncPlaybackProgress(jellyfinRepository, application) - } - - _views.value = items + views.map { HomeItem.ViewItem(it) } - - + state.tryEmit(Loading(inProgress = false)) } catch (e: Exception) { Timber.e(e) - _error.value = e.toString() + state.tryEmit(LoadingError(e.toString())) } - _finishedLoading.value = true } } + + private suspend fun loadDynamicItems() = withContext(Dispatchers.IO) { + val resumeItems = repository.getResumeItems() + val nextUpItems = repository.getNextUp() + + val items = mutableListOf() + if (resumeItems.isNotEmpty()) { + items.add(HomeSection(continueWatchingString, resumeItems)) + } + + if (nextUpItems.isNotEmpty()) { + items.add(HomeSection(nextUpString, nextUpItems)) + } + + items.map { Section(it) } + } + + private suspend fun loadViews() = withContext(Dispatchers.IO) { + repository + .getUserViews() + .filter { view -> unsupportedCollections().none { it.type == view.collectionType } } + .map { view -> view to repository.getLatestMedia(view.id) } + .filter { (_, latest) -> latest.isNotEmpty() } + .map { (view, latest) -> view.toView().apply { items = latest } } + .map { ViewItem(it) } + } + + sealed class State + + data class LoadingError(val message: String) : State() + data class Loading(val inProgress: Boolean) : State() } diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index f0e22609..9abb4f55 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -1,48 +1,63 @@ - + xmlns:tools="http://schemas.android.com/tools" + > + type="dev.jdtech.jellyfin.viewmodels.HomeViewModel" + /> - - + > - + - + - - + + + + + +