From 6e42cd3c656296d9ddbd9f29f40a05a670a1e81a Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 2 Oct 2025 22:43:05 -0400 Subject: [PATCH] Better loading & error handling --- .../dolphin/ui/components/CircularProgress.kt | 18 ++ .../dolphin/ui/components/ErrorMessage.kt | 53 ++++ .../ui/detail/CollectionFolderDetails.kt | 247 +++++++++++------- .../dolphin/ui/detail/EpisodeDetails.kt | 37 +-- .../dolphin/ui/detail/ItemViewModel.kt | 84 ++++-- .../dolphin/ui/detail/MovieDetails.kt | 90 ++++--- .../dolphin/ui/detail/SeasonDetails.kt | 5 +- .../dolphin/ui/detail/SeriesDetails.kt | 213 ++++++++------- .../dolphin/ui/detail/VideoDetails.kt | 90 ++++--- .../ui/detail/series/SeriesOverview.kt | 138 +++++----- .../ui/detail/series/SeriesOverviewContent.kt | 7 +- .../damontecres/dolphin/ui/main/MainPage.kt | 15 +- .../dolphin/ui/main/MainViewModel.kt | 10 +- .../dolphin/util/LoadingExceptionHandler.kt | 43 +++ .../damontecres/dolphin/util/LoadingState.kt | 2 +- 15 files changed, 677 insertions(+), 375 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/components/ErrorMessage.kt create mode 100644 app/src/main/java/com/github/damontecres/dolphin/util/LoadingExceptionHandler.kt diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/CircularProgress.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CircularProgress.kt index 8204eed5..243044eb 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/CircularProgress.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CircularProgress.kt @@ -2,10 +2,12 @@ package com.github.damontecres.dolphin.ui.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import com.github.damontecres.dolphin.ui.ifElse @@ -21,3 +23,19 @@ fun CircularProgress( ) } } + +@Composable +fun LoadingPage() { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = + Modifier + .align(Alignment.Center) + .size(48.dp), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/ErrorMessage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/ErrorMessage.kt new file mode 100644 index 00000000..ac8d7fc5 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/ErrorMessage.kt @@ -0,0 +1,53 @@ +package com.github.damontecres.dolphin.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.dolphin.util.LoadingState + +@Composable +fun ErrorMessage( + message: String?, + exception: Throwable?, + modifier: Modifier = Modifier, +) { + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier, + ) { + message?.let { + item { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.titleLarge, + ) + } + } + exception?.localizedMessage?.let { + item { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.titleMedium, + ) + } + } + } +} + +@Composable +fun ErrorMessage( + error: LoadingState.Error, + modifier: Modifier = Modifier, +) = ErrorMessage( + message = error.message, + exception = error.exception, + modifier = modifier, +) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderDetails.kt index ebdf223e..6adc5bd6 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderDetails.kt @@ -1,27 +1,41 @@ package com.github.damontecres.dolphin.ui.detail +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.Library import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect +import com.github.damontecres.dolphin.ui.components.ErrorMessage +import com.github.damontecres.dolphin.ui.components.LoadingPage import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.NavigationManager +import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.util.ApiRequestPager -import com.github.damontecres.dolphin.util.ExceptionHandler import com.github.damontecres.dolphin.util.GetItemsRequestHandler +import com.github.damontecres.dolphin.util.LoadingExceptionHandler +import com.github.damontecres.dolphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType @@ -39,56 +53,67 @@ class CollectionFolderViewModel constructor( api: ApiClient, ) : ItemViewModel(api) { + val loading = MutableLiveData(LoadingState.Loading) val pager = MutableLiveData?>() - override fun init( + fun init( itemId: UUID, potential: BaseItem?, ): Job = - viewModelScope.launch(ExceptionHandler()) { - super.init(itemId, potential)?.join() + viewModelScope.launch( + LoadingExceptionHandler( + loading, + "Error loading collection $itemId", + ) + Dispatchers.IO, + ) { + fetchItem(itemId, potential) setup() } - private suspend fun setup() { - if (!pager.isInitialized) { - item.value?.let { item -> - val includeItemTypes = - when (item.data.collectionType) { - CollectionType.UNKNOWN -> TODO() - CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE) - CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES) - CollectionType.MUSIC -> TODO() - CollectionType.MUSICVIDEOS -> TODO() - CollectionType.TRAILERS -> TODO() - CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO) - CollectionType.BOXSETS -> TODO() - CollectionType.BOOKS -> TODO() - CollectionType.PHOTOS -> TODO() - CollectionType.LIVETV -> TODO() - CollectionType.PLAYLISTS -> TODO() - CollectionType.FOLDERS -> TODO() - null -> TODO() - } - val request = - GetItemsRequest( - parentId = item.id, - isSeries = true, - mediaTypes = null, + private suspend fun setup() = + withContext(Dispatchers.IO) { + if (!pager.isInitialized) { + item.value?.let { item -> + val includeItemTypes = + when (item.data.collectionType) { + CollectionType.UNKNOWN -> TODO() + CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE) + CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES) + CollectionType.MUSIC -> TODO() + CollectionType.MUSICVIDEOS -> TODO() + CollectionType.TRAILERS -> TODO() + CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO) + CollectionType.BOXSETS -> TODO() + CollectionType.BOOKS -> TODO() + CollectionType.PHOTOS -> TODO() + CollectionType.LIVETV -> TODO() + CollectionType.PLAYLISTS -> TODO() + CollectionType.FOLDERS -> TODO() + null -> TODO() + } + val request = + GetItemsRequest( + parentId = item.id, + isSeries = true, + mediaTypes = null, // recursive = true, - enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), - includeItemTypes = includeItemTypes, - sortBy = listOf(ItemSortBy.SORT_NAME), - sortOrder = listOf(SortOrder.ASCENDING), - fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO), - ) - val newPager = - ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) - newPager.init() - pager.value = newPager + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + includeItemTypes = includeItemTypes, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO), + ) + val newPager = + ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) + newPager.init() + newPager.getBlocking(0) + withContext(Dispatchers.Main) { + pager.value = newPager + loading.value = LoadingState.Success + } + } } } - } } @Composable @@ -102,57 +127,62 @@ fun CollectionFolderDetails( OneTimeLaunchedEffect { viewModel.init(destination.itemId, destination.item) } + val loading by viewModel.loading.observeAsState(LoadingState.Loading) val item by viewModel.item.observeAsState() val library by viewModel.model.observeAsState() val pager by viewModel.pager.observeAsState() - if (library == null) { - Text("Loading library...") - } else { - pager?.let { pager -> - when (library!!.collectionType) { - CollectionType.UNKNOWN -> TODO() - // TODO? - CollectionType.MOVIES -> - TVShowCollectionDetails( - preferences, - navigationManager, - library!!, - item!!, - pager, - modifier, - ) - CollectionType.TVSHOWS -> { - TVShowCollectionDetails( - preferences, - navigationManager, - library!!, - item!!, - pager, - modifier, - ) + when (val state = loading) { + is LoadingState.Error -> ErrorMessage(state) + LoadingState.Loading -> LoadingPage() + LoadingState.Success -> { + pager?.let { pager -> + when (library!!.collectionType) { + CollectionType.UNKNOWN -> TODO() + + // TODO? + CollectionType.MOVIES -> + TVShowCollectionDetails( + preferences, + navigationManager, + library!!, + item!!, + pager, + modifier, + ) + + CollectionType.TVSHOWS -> { + TVShowCollectionDetails( + preferences, + navigationManager, + library!!, + item!!, + pager, + modifier, + ) + } + + // TODO? + CollectionType.HOMEVIDEOS -> + TVShowCollectionDetails( + preferences, + navigationManager, + library!!, + item!!, + pager, + modifier, + ) + + CollectionType.MUSIC -> TODO() + CollectionType.MUSICVIDEOS -> TODO() + CollectionType.TRAILERS -> TODO() + CollectionType.BOXSETS -> TODO() + CollectionType.BOOKS -> TODO() + CollectionType.PHOTOS -> TODO() + CollectionType.LIVETV -> TODO() + CollectionType.PLAYLISTS -> TODO() + CollectionType.FOLDERS -> TODO() } - - CollectionType.MUSIC -> TODO() - CollectionType.MUSICVIDEOS -> TODO() - CollectionType.TRAILERS -> TODO() - - // TODO? - CollectionType.HOMEVIDEOS -> - TVShowCollectionDetails( - preferences, - navigationManager, - library!!, - item!!, - pager, - modifier, - ) - CollectionType.BOXSETS -> TODO() - CollectionType.BOOKS -> TODO() - CollectionType.PHOTOS -> TODO() - CollectionType.LIVETV -> TODO() - CollectionType.PLAYLISTS -> TODO() - CollectionType.FOLDERS -> TODO() } } } @@ -167,17 +197,40 @@ fun TVShowCollectionDetails( pager: List, modifier: Modifier = Modifier, ) { + val title = library.name ?: item.data.name ?: item.data.collectionType?.name ?: "Collection" + val gridFocusRequester = remember { FocusRequester() } - CardGrid( - pager = pager, - itemOnClick = { navigationManager.navigateTo(Destination.MediaItem(it.id, it.type, it)) }, - longClicker = {}, - letterPosition = { 0 }, - requestFocus = true, - gridFocusRequester = gridFocusRequester, - navigationManager = navigationManager, + LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), modifier = modifier, - initialPosition = 0, - positionCallback = { _, _ -> }, - ) + ) { + Text( + text = title, + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + CardGrid( + pager = pager, + itemOnClick = { + navigationManager.navigateTo( + Destination.MediaItem( + it.id, + it.type, + it, + ), + ) + }, + longClicker = {}, + letterPosition = { 0 }, + requestFocus = true, + gridFocusRequester = gridFocusRequester, + navigationManager = navigationManager, + modifier = Modifier.fillMaxSize(), + initialPosition = 0, + positionCallback = { _, _ -> }, + ) + } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt index 0212e665..8b326519 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt @@ -24,15 +24,17 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.Video import com.github.damontecres.dolphin.preferences.UserPreferences +import com.github.damontecres.dolphin.ui.components.ErrorMessage +import com.github.damontecres.dolphin.ui.components.LoadingPage import com.github.damontecres.dolphin.ui.components.details.VideoDetailsHeader import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.NavigationManager +import com.github.damontecres.dolphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.ImageType @@ -46,7 +48,7 @@ class EpisodeViewModel @Inject constructor( api: ApiClient, - ) : ItemViewModel