Add refresh (#59)

* Add refresh to home fragment

* Remove forgotten code

* Remove unnecessary condition and fix HomeSection equality check

* Make HomeFragment fragment view model again

* Add order dependent check for home items equality

* Fix loading state overwriting error state on home refresh

* Revert to older swiperefreshlayout version

* Fixing error and loading state

Co-authored-by: Jarne Demeulemeester <32322857+jarnedemeulemeester@users.noreply.github.com>
This commit is contained in:
lsrom 2021-11-14 18:44:33 +01:00 committed by GitHub
parent 98cb038c24
commit d7a47b0a3e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 234 additions and 152 deletions

View file

@ -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")

View file

@ -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<HomeItem>() {
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<UUID>
}

View file

@ -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 {
setupView()
bindState()
return binding.root
}
override fun onResume() {
super.onResume()
viewModel.refreshData()
}
private fun setupView() {
binding.refreshLayout.setOnRefreshListener {
viewModel.refreshData()
}
binding.viewsRecyclerView.adapter = ViewListAdapter(
onClickListener = ViewListAdapter.OnClickListener { navigateToLibraryFragment(it) },
onItemClickListener = ViewItemListAdapter.OnClickListener {
navigateToMediaInfoFragment(it)
}, HomeEpisodeListAdapter.OnClickListener { item ->
when (item.type) {
"Episode" -> {
navigateToEpisodeBottomSheetFragment(item)
}
"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
},
onNextUpClickListener = HomeEpisodeListAdapter.OnClickListener { item ->
when (item.contentType()) {
EPISODE -> navigateToEpisodeBottomSheetFragment(item)
MOVIE -> navigateToMediaInfoFragment(item)
else -> Toast.makeText(requireContext(), R.string.unknown_error, LENGTH_LONG)
.show()
}
})
binding.errorLayout.errorRetryButton.setOnClickListener {
viewModel.loadData()
}
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
)
)
}

View file

@ -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
)

View file

@ -3,5 +3,6 @@ package dev.jdtech.jellyfin.models
enum class ContentType(val type: String) {
MOVIE("Movie"),
TVSHOW("Series"),
EPISODE("Episode"),
UNKNOWN("")
}

View file

@ -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<BaseItemDto>? = null
val name: String,
var items: List<BaseItemDto>
)

View file

@ -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,

View file

@ -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()) }
}

View file

@ -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<String>) : 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

View file

@ -0,0 +1,8 @@
package dev.jdtech.jellyfin.utils
import android.view.View
import androidx.core.view.isVisible
fun View.toggleVisibility() {
isVisible = !isVisible
}

View file

@ -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()

View file

@ -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<List<HomeItem>>()
private val state = MutableSharedFlow<State>(
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<List<HomeItem>>()
val views: LiveData<List<HomeItem>> = _views
fun views(): LiveData<List<HomeItem>> = views
private val _items = MutableLiveData<List<BaseItemDto>>()
val items: LiveData<List<BaseItemDto>> = _items
private val _finishedLoading = MutableLiveData<Boolean>()
val finishedLoading: LiveData<Boolean> = _finishedLoading
private val _error = MutableLiveData<String>()
val error: LiveData<String> = _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<HomeItem>()
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))
syncPlaybackProgress(repository, application)
}
val nextUpItems = jellyfinRepository.getNextUp()
val nextUpSection = HomeSection(UUID.randomUUID(), nextUpString, nextUpItems)
if (!nextUpItems.isNullOrEmpty()) {
items.add(HomeItem.Section(nextUpSection))
}
}
val views: MutableList<View> = 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()
}
_finishedLoading.value = true
state.tryEmit(LoadingError(e.toString()))
}
}
}
private suspend fun loadDynamicItems() = withContext(Dispatchers.IO) {
val resumeItems = repository.getResumeItems()
val nextUpItems = repository.getNextUp()
val items = mutableListOf<HomeSection>()
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()
}

View file

@ -1,20 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
xmlns:tools="http://schemas.android.com/tools"
>
<data>
<variable
name="viewModel"
type="dev.jdtech.jellyfin.viewmodels.HomeViewModel" />
type="dev.jdtech.jellyfin.viewmodels.HomeViewModel"
/>
</data>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragments.HomeFragment">
tools:context=".fragments.HomeFragment"
>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/loading_indicator"
@ -23,11 +32,14 @@
android:indeterminate="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintTop_toTopOf="parent"
android:visibility="gone"
/>
<include
android:id="@+id/error_layout"
layout="@layout/error_panel" />
layout="@layout/error_panel"
/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/views_recycler_view"
@ -40,9 +52,12 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:views="@{viewModel.views}"
app:views="@{viewModel.views()}"
tools:itemCount="4"
tools:listitem="@layout/view_item" />
tools:listitem="@layout/view_item"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</layout>