Better loading & error handling

This commit is contained in:
Damontecres 2025-10-02 22:43:05 -04:00
parent 1411859b7f
commit 6e42cd3c65
No known key found for this signature in database
15 changed files with 677 additions and 375 deletions

View file

@ -2,10 +2,12 @@ package com.github.damontecres.dolphin.ui.components
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import com.github.damontecres.dolphin.ui.ifElse 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),
)
}
}

View file

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

View file

@ -1,27 +1,41 @@
package com.github.damontecres.dolphin.ui.detail 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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester 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.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.Library import com.github.damontecres.dolphin.data.model.Library
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect 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.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager 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.ApiRequestPager
import com.github.damontecres.dolphin.util.ExceptionHandler
import com.github.damontecres.dolphin.util.GetItemsRequestHandler 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
@ -39,56 +53,67 @@ class CollectionFolderViewModel
constructor( constructor(
api: ApiClient, api: ApiClient,
) : ItemViewModel<Library>(api) { ) : ItemViewModel<Library>(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val pager = MutableLiveData<ApiRequestPager<GetItemsRequest>?>() val pager = MutableLiveData<ApiRequestPager<GetItemsRequest>?>()
override fun init( fun init(
itemId: UUID, itemId: UUID,
potential: BaseItem?, potential: BaseItem?,
): Job = ): Job =
viewModelScope.launch(ExceptionHandler()) { viewModelScope.launch(
super.init(itemId, potential)?.join() LoadingExceptionHandler(
loading,
"Error loading collection $itemId",
) + Dispatchers.IO,
) {
fetchItem(itemId, potential)
setup() setup()
} }
private suspend fun setup() { private suspend fun setup() =
if (!pager.isInitialized) { withContext(Dispatchers.IO) {
item.value?.let { item -> if (!pager.isInitialized) {
val includeItemTypes = item.value?.let { item ->
when (item.data.collectionType) { val includeItemTypes =
CollectionType.UNKNOWN -> TODO() when (item.data.collectionType) {
CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE) CollectionType.UNKNOWN -> TODO()
CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES) CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE)
CollectionType.MUSIC -> TODO() CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES)
CollectionType.MUSICVIDEOS -> TODO() CollectionType.MUSIC -> TODO()
CollectionType.TRAILERS -> TODO() CollectionType.MUSICVIDEOS -> TODO()
CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO) CollectionType.TRAILERS -> TODO()
CollectionType.BOXSETS -> TODO() CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO)
CollectionType.BOOKS -> TODO() CollectionType.BOXSETS -> TODO()
CollectionType.PHOTOS -> TODO() CollectionType.BOOKS -> TODO()
CollectionType.LIVETV -> TODO() CollectionType.PHOTOS -> TODO()
CollectionType.PLAYLISTS -> TODO() CollectionType.LIVETV -> TODO()
CollectionType.FOLDERS -> TODO() CollectionType.PLAYLISTS -> TODO()
null -> TODO() CollectionType.FOLDERS -> TODO()
} null -> TODO()
val request = }
GetItemsRequest( val request =
parentId = item.id, GetItemsRequest(
isSeries = true, parentId = item.id,
mediaTypes = null, isSeries = true,
mediaTypes = null,
// recursive = true, // recursive = true,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
includeItemTypes = includeItemTypes, includeItemTypes = includeItemTypes,
sortBy = listOf(ItemSortBy.SORT_NAME), sortBy = listOf(ItemSortBy.SORT_NAME),
sortOrder = listOf(SortOrder.ASCENDING), sortOrder = listOf(SortOrder.ASCENDING),
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO), fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO),
) )
val newPager = val newPager =
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
newPager.init() newPager.init()
pager.value = newPager newPager.getBlocking(0)
withContext(Dispatchers.Main) {
pager.value = newPager
loading.value = LoadingState.Success
}
}
} }
} }
}
} }
@Composable @Composable
@ -102,57 +127,62 @@ fun CollectionFolderDetails(
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
viewModel.init(destination.itemId, destination.item) viewModel.init(destination.itemId, destination.item)
} }
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
val library by viewModel.model.observeAsState() val library by viewModel.model.observeAsState()
val pager by viewModel.pager.observeAsState() val pager by viewModel.pager.observeAsState()
if (library == null) {
Text("Loading library...")
} else {
pager?.let { pager ->
when (library!!.collectionType) {
CollectionType.UNKNOWN -> TODO()
// TODO? when (val state = loading) {
CollectionType.MOVIES -> is LoadingState.Error -> ErrorMessage(state)
TVShowCollectionDetails( LoadingState.Loading -> LoadingPage()
preferences, LoadingState.Success -> {
navigationManager, pager?.let { pager ->
library!!, when (library!!.collectionType) {
item!!, CollectionType.UNKNOWN -> TODO()
pager,
modifier, // TODO?
) CollectionType.MOVIES ->
CollectionType.TVSHOWS -> { TVShowCollectionDetails(
TVShowCollectionDetails( preferences,
preferences, navigationManager,
navigationManager, library!!,
library!!, item!!,
item!!, pager,
pager, modifier,
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<BaseItem?>, pager: List<BaseItem?>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val title = library.name ?: item.data.name ?: item.data.collectionType?.name ?: "Collection"
val gridFocusRequester = remember { FocusRequester() } val gridFocusRequester = remember { FocusRequester() }
CardGrid( LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
pager = pager, Column(
itemOnClick = { navigationManager.navigateTo(Destination.MediaItem(it.id, it.type, it)) }, verticalArrangement = Arrangement.spacedBy(0.dp),
longClicker = {},
letterPosition = { 0 },
requestFocus = true,
gridFocusRequester = gridFocusRequester,
navigationManager = navigationManager,
modifier = modifier, 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 = { _, _ -> },
)
}
} }

View file

@ -24,15 +24,17 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.Video import com.github.damontecres.dolphin.data.model.Video
import com.github.damontecres.dolphin.preferences.UserPreferences 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.components.details.VideoDetailsHeader
import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
@ -46,7 +48,7 @@ class EpisodeViewModel
@Inject @Inject
constructor( constructor(
api: ApiClient, api: ApiClient,
) : ItemViewModel<Video>(api) ) : LoadingItemViewModel<Video>(api)
@Composable @Composable
fun EpisodeDetails( fun EpisodeDetails(
@ -60,20 +62,23 @@ fun EpisodeDetails(
viewModel.init(destination.itemId, destination.item) viewModel.init(destination.itemId, destination.item)
} }
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
if (item == null) { val loading by viewModel.loading.observeAsState(LoadingState.Loading)
Text(text = "Loading...") when (val state = loading) {
} else { is LoadingState.Error -> ErrorMessage(state)
item?.let { item -> LoadingState.Loading -> LoadingPage()
EpisodeDetailsContent( LoadingState.Success -> {
item = item, item?.let { item ->
backdropImageUrl = EpisodeDetailsContent(
remember { item = item,
item.data.parentBackdropItemId?.let { backdropImageUrl =
viewModel.imageUrl(it, ImageType.BACKDROP) remember {
} item.data.parentBackdropItemId?.let {
}, viewModel.imageUrl(it, ImageType.BACKDROP)
modifier = modifier, }
) },
modifier = modifier,
)
}
} }
} }
} }

View file

@ -6,9 +6,12 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.DolphinModel import com.github.damontecres.dolphin.data.model.DolphinModel
import com.github.damontecres.dolphin.data.model.convertModel import com.github.damontecres.dolphin.data.model.convertModel
import com.github.damontecres.dolphin.util.ExceptionHandler import com.github.damontecres.dolphin.util.LoadingExceptionHandler
import com.github.damontecres.dolphin.util.LoadingState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@ -22,32 +25,73 @@ abstract class ItemViewModel<T : DolphinModel>(
val item = MutableLiveData<BaseItem?>(null) val item = MutableLiveData<BaseItem?>(null)
val model = MutableLiveData<T?>(null) val model = MutableLiveData<T?>(null)
open fun init( suspend fun fetchItem(
itemId: UUID, itemId: UUID,
potential: BaseItem?, potential: BaseItem?,
): Job? { ): BaseItem? =
if (item.value == null && potential?.id == itemId) { withContext(Dispatchers.IO) {
item.value = potential val fetchedItem =
return null when {
} item.value == null && potential?.id == itemId -> potential
if (item.value?.id == itemId) { item.value?.id == itemId -> item.value
return null else -> {
} val it = api.userLibraryApi.getItem(itemId).content
return viewModelScope.launch(ExceptionHandler()) { BaseItem.from(it, api)
try { }
val fetchedItem = api.userLibraryApi.getItem(itemId).content }
val modelInstance = convertModel(fetchedItem, api) return@withContext fetchedItem?.let {
item.value = BaseItem.from(fetchedItem, api) val modelInstance = convertModel(fetchedItem.data, api)
model.value = modelInstance as T withContext(Dispatchers.Main) {
} catch (e: Exception) { item.value = fetchedItem
Timber.e(e, "Failed to load item $itemId") model.value = modelInstance as T
item.value = null }
fetchedItem
} }
} }
}
fun imageUrl( fun imageUrl(
itemId: UUID, itemId: UUID,
type: ImageType, type: ImageType,
): String? = api.imageApi.getItemImageUrl(itemId, type) ): String? = api.imageApi.getItemImageUrl(itemId, type)
} }
abstract class LoadingItemViewModel<T : DolphinModel>(
api: ApiClient,
) : ItemViewModel<T>(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
open fun init(
itemId: UUID,
potential: BaseItem?,
): Job? {
if (item.value == null && potential?.id == itemId) {
item.value = potential
loading.value = LoadingState.Success
return null
}
if (item.value?.id == itemId) {
loading.value = LoadingState.Success
return null
}
return viewModelScope.launch(
LoadingExceptionHandler(
loading,
"Error loading item $itemId",
) + Dispatchers.IO,
) {
try {
val fetchedItem = api.userLibraryApi.getItem(itemId).content
val modelInstance = convertModel(fetchedItem, api)
withContext(Dispatchers.Main) {
item.value = BaseItem.from(fetchedItem, api)
model.value = modelInstance as T
loading.value = LoadingState.Success
}
} catch (e: Exception) {
Timber.e(e, "Failed to load item $itemId")
item.value = null
loading.value = LoadingState.Error("Error loading item $itemId", e)
}
}
}
}

View file

@ -14,8 +14,11 @@ import androidx.tv.material3.Button
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.Video import com.github.damontecres.dolphin.data.model.Video
import com.github.damontecres.dolphin.preferences.UserPreferences 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.nav.Destination import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
@ -27,7 +30,7 @@ class MovieViewModel
@Inject @Inject
constructor( constructor(
api: ApiClient, api: ApiClient,
) : ItemViewModel<Video>(api) ) : LoadingItemViewModel<Video>(api)
@Composable @Composable
fun MovieDetails( fun MovieDetails(
@ -41,50 +44,59 @@ fun MovieDetails(
viewModel.init(destination.itemId, destination.item) viewModel.init(destination.itemId, destination.item)
} }
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
if (item == null) { val loading by viewModel.loading.observeAsState(LoadingState.Loading)
Text(text = "Loading...") when (val state = loading) {
} else { is LoadingState.Error -> ErrorMessage(state)
item?.let { item -> LoadingState.Loading -> LoadingPage()
val dto = item.data LoadingState.Success -> {
LazyColumn( item?.let { item ->
verticalArrangement = Arrangement.spacedBy(8.dp), val dto = item.data
contentPadding = PaddingValues(32.dp), LazyColumn(
modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp),
) { contentPadding = PaddingValues(32.dp),
item { modifier = modifier,
Text(text = item.name ?: "Unknown") ) {
}
dto.overview?.let {
item { item {
Text(text = it) Text(text = item.name ?: "Unknown")
} }
} dto.overview?.let {
dto.userData?.playbackPositionTicks?.ticks?.let {
if (it > 60.seconds) {
item { item {
Button( Text(text = it)
onClick = { }
navigationManager.navigateTo( }
Destination.Playback( dto.userData?.playbackPositionTicks?.ticks?.let {
item.id, if (it > 60.seconds) {
it.inWholeMilliseconds, item {
item, Button(
), onClick = {
) navigationManager.navigateTo(
}, Destination.Playback(
) { item.id,
Text(text = "Resume") it.inWholeMilliseconds,
item,
),
)
},
) {
Text(text = "Resume")
}
} }
} }
} }
} item {
item { Button(
Button( onClick = {
onClick = { navigationManager.navigateTo(
navigationManager.navigateTo(Destination.Playback(item.id, 0L, item)) Destination.Playback(
}, item.id,
) { 0L,
Text(text = "Play") item,
),
)
},
) {
Text(text = "Play")
}
} }
} }
} }

View file

@ -44,13 +44,12 @@ class SeasonViewModel
) : ItemViewModel<Video>(api) { ) : ItemViewModel<Video>(api) {
val episodes = MutableLiveData<List<BaseItem?>>(listOf()) val episodes = MutableLiveData<List<BaseItem?>>(listOf())
override fun init( fun init(
itemId: UUID, itemId: UUID,
potential: BaseItem?, potential: BaseItem?,
): Job? = ): Job? =
viewModelScope.launch(ExceptionHandler()) { viewModelScope.launch(ExceptionHandler()) {
super.init(itemId, potential)?.join() fetchItem(itemId, potential)?.let { item ->
item.value?.let { item ->
val request = val request =
GetItemsRequest( GetItemsRequest(
parentId = item.id, parentId = item.id,

View file

@ -15,17 +15,21 @@ import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.Video import com.github.damontecres.dolphin.data.model.Video
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.cards.ItemRow import com.github.damontecres.dolphin.ui.cards.ItemRow
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.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.util.ApiRequestPager import com.github.damontecres.dolphin.util.ApiRequestPager
import com.github.damontecres.dolphin.util.ExceptionHandler import com.github.damontecres.dolphin.util.ExceptionHandler
import com.github.damontecres.dolphin.util.GetEpisodesRequestHandler import com.github.damontecres.dolphin.util.GetEpisodesRequestHandler
import com.github.damontecres.dolphin.util.GetItemsRequestHandler 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@ -45,88 +49,117 @@ class SeriesViewModel
constructor( constructor(
api: ApiClient, api: ApiClient,
) : ItemViewModel<Video>(api) { ) : ItemViewModel<Video>(api) {
private lateinit var seriesId: UUID
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val seasons = MutableLiveData<List<BaseItem?>>(listOf()) val seasons = MutableLiveData<List<BaseItem?>>(listOf())
val episodes = MutableLiveData<List<BaseItem?>>(listOf()) val episodes = MutableLiveData<List<BaseItem?>>(listOf())
override fun init(
itemId: UUID,
potential: BaseItem?,
): Job =
viewModelScope.launch(ExceptionHandler()) {
super.init(itemId, potential)?.join()
item.value?.let { item ->
val request =
GetItemsRequest(
parentId = item.id,
recursive = false,
includeItemTypes = listOf(BaseItemKind.SEASON),
sortBy = listOf(ItemSortBy.INDEX_NUMBER),
sortOrder = listOf(SortOrder.ASCENDING),
fields =
listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
ItemFields.SEASON_USER_DATA,
),
)
val pager =
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
viewModelScope,
itemCount = item.data.childCount,
)
pager.init()
seasons.value = pager
Timber.v("Loaded ${pager.size} seasons for series ${item.id}")
val pairs =
pager.mapIndexed { index, _ ->
val season = pager.getBlocking(index)
Pair(season?.indexNumber!!, index)
}
mapOf(*pairs.toTypedArray())
mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray())
}
}
fun init( fun init(
itemId: UUID, itemId: UUID,
potential: BaseItem?, potential: BaseItem?,
season: Int?, season: Int?,
episode: Int?, episode: Int?,
) { ) {
viewModelScope.launch(ExceptionHandler()) { this.seriesId = itemId
init(itemId, potential).join() viewModelScope.launch(
season?.let { seasonNum -> LoadingExceptionHandler(
// TODO map season number to index in list loading,
loadEpisodes(seasonNum) "Error loading series $seriesId",
) + Dispatchers.IO,
) {
val item = fetchItem(seriesId, potential)
if (item != null) {
val seasonPager = getSeasons(item)
val episodePager =
season?.let { seasonNum ->
// TODO map season number to index in list
loadEpisodesInternal(seasonNum)
}
withContext(Dispatchers.Main) {
seasons.value = seasonPager.orEmpty()
episodes.value = episodePager.orEmpty()
loading.value = LoadingState.Success
}
} else {
withContext(Dispatchers.Main) {
seasons.value = listOf()
episodes.value = listOf()
loading.value = LoadingState.Error("Series $seriesId not found")
}
} }
} }
} }
fun loadEpisodes(season: Int): Deferred<ApiRequestPager<*>> = private suspend fun getSeasons(item: BaseItem): ApiRequestPager<GetItemsRequest>? {
viewModelScope.async(ExceptionHandler()) { val request =
val request = GetItemsRequest(
GetEpisodesRequest( parentId = item.id,
seriesId = item.value!!.id, recursive = false,
season = season, includeItemTypes = listOf(BaseItemKind.SEASON),
sortBy = ItemSortBy.INDEX_NUMBER, sortBy = listOf(ItemSortBy.INDEX_NUMBER),
fields = sortOrder = listOf(SortOrder.ASCENDING),
listOf( fields =
ItemFields.MEDIA_SOURCES, listOf(
ItemFields.MEDIA_STREAMS, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW, ItemFields.CHILD_COUNT,
ItemFields.CUSTOM_RATING, ItemFields.SEASON_USER_DATA,
ItemFields.TRICKPLAY, ),
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, )
), val pager =
) ApiRequestPager(
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope) api,
pager.init() request,
Timber.v("Loaded ${pager.size} episodes for season $season") GetItemsRequestHandler,
episodes.value = pager viewModelScope,
pager itemCount = item.data.childCount,
)
pager.init()
Timber.v("Loaded ${pager.size} seasons for series ${item.id}")
val pairs =
pager.mapIndexed { index, _ ->
val season = pager.getBlocking(index)
Pair(season?.indexNumber!!, index)
}
mapOf(*pairs.toTypedArray())
mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray())
return pager
}
private suspend fun loadEpisodesInternal(season: Int): ApiRequestPager<GetEpisodesRequest> {
val request =
GetEpisodesRequest(
seriesId = item.value!!.id,
season = season,
sortBy = ItemSortBy.INDEX_NUMBER,
fields =
listOf(
ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_STREAMS,
ItemFields.OVERVIEW,
ItemFields.CUSTOM_RATING,
ItemFields.TRICKPLAY,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
),
)
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
pager.init()
Timber.v("Loaded ${pager.size} episodes for season $season")
return pager
}
fun loadEpisodes(season: Int) =
viewModelScope.async(ExceptionHandler(true)) {
val episodePager =
try {
loadEpisodesInternal(season)
} catch (e: Exception) {
Timber.e(e, "Error loading episodes for $seriesId for season $season")
// TODO show error in UI?
listOf()
}
withContext(Dispatchers.Main) {
episodes.value = episodePager
}
} }
fun setWatched( fun setWatched(
@ -164,26 +197,30 @@ fun SeriesDetails(
viewModel: SeriesViewModel = hiltViewModel(), viewModel: SeriesViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init(destination.itemId, destination.item) viewModel.init(destination.itemId, destination.item, null, null)
} }
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
val seasons by viewModel.seasons.observeAsState(listOf()) val seasons by viewModel.seasons.observeAsState(listOf())
if (item == null) { val loading by viewModel.loading.observeAsState(LoadingState.Loading)
Text(text = "Loading...")
} else { when (val state = loading) {
item?.let { item -> is LoadingState.Error -> ErrorMessage(state)
LazyColumn(modifier = modifier) { LoadingState.Loading -> LoadingPage()
item { LoadingState.Success -> {
Text(text = item.name ?: "Unknown") item?.let { item ->
} LazyColumn(modifier = modifier) {
item { item {
ItemRow( Text(text = item.name ?: "Unknown")
title = "Seasons", }
items = seasons, item {
onClickItem = { navigationManager.navigateTo(it.destination()) }, ItemRow(
onLongClickItem = { }, title = "Seasons",
modifier = Modifier.fillMaxWidth(), items = seasons,
) onClickItem = { navigationManager.navigateTo(it.destination()) },
onLongClickItem = { },
modifier = Modifier.fillMaxWidth(),
)
}
} }
} }
} }

View file

@ -14,8 +14,11 @@ import androidx.tv.material3.Button
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.Video import com.github.damontecres.dolphin.data.model.Video
import com.github.damontecres.dolphin.preferences.UserPreferences 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.nav.Destination import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
@ -27,7 +30,7 @@ class VideoViewModel
@Inject @Inject
constructor( constructor(
api: ApiClient, api: ApiClient,
) : ItemViewModel<Video>(api) ) : LoadingItemViewModel<Video>(api)
@Composable @Composable
fun VideoDetails( fun VideoDetails(
@ -41,50 +44,59 @@ fun VideoDetails(
viewModel.init(destination.itemId, destination.item) viewModel.init(destination.itemId, destination.item)
} }
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
if (item == null) { val loading by viewModel.loading.observeAsState(LoadingState.Loading)
Text(text = "Loading...") when (val state = loading) {
} else { is LoadingState.Error -> ErrorMessage(state)
item?.let { item -> LoadingState.Loading -> LoadingPage()
val dto = item.data LoadingState.Success -> {
LazyColumn( item?.let { item ->
verticalArrangement = Arrangement.spacedBy(8.dp), val dto = item.data
contentPadding = PaddingValues(32.dp), LazyColumn(
modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp),
) { contentPadding = PaddingValues(32.dp),
item { modifier = modifier,
Text(text = item.name ?: "Unknown") ) {
}
dto.overview?.let {
item { item {
Text(text = it) Text(text = item.name ?: "Unknown")
} }
} dto.overview?.let {
dto.userData?.playbackPositionTicks?.ticks?.let {
if (it > 60.seconds) {
item { item {
Button( Text(text = it)
onClick = { }
navigationManager.navigateTo( }
Destination.Playback( dto.userData?.playbackPositionTicks?.ticks?.let {
item.id, if (it > 60.seconds) {
it.inWholeMilliseconds, item {
item, Button(
), onClick = {
) navigationManager.navigateTo(
}, Destination.Playback(
) { item.id,
Text(text = "Resume") it.inWholeMilliseconds,
item,
),
)
},
) {
Text(text = "Resume")
}
} }
} }
} }
} item {
item { Button(
Button( onClick = {
onClick = { navigationManager.navigateTo(
navigationManager.navigateTo(Destination.Playback(item.id, 0L, item)) Destination.Playback(
}, item.id,
) { 0L,
Text(text = "Play") item,
),
)
},
) {
Text(text = "Play")
}
} }
} }
} }

View file

@ -12,14 +12,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect 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.data.ItemDetailsDialog import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.dolphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.dolphin.ui.detail.SeriesViewModel import com.github.damontecres.dolphin.ui.detail.SeriesViewModel
import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.ui.tryRequestFocus
import com.github.damontecres.dolphin.util.LoadingState
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
@ -48,6 +51,7 @@ fun SeriesOverview(
initialSeasonEpisode: SeasonEpisode = SeasonEpisode(0, 0), initialSeasonEpisode: SeasonEpisode = SeasonEpisode(0, 0),
) { ) {
val firstItemFocusRequester = remember { FocusRequester() } val firstItemFocusRequester = remember { FocusRequester() }
val episodeRowFocusRequester = remember { FocusRequester() }
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
Timber.v("SeriesDetailParent: itemId=${destination.itemId}, initialSeasonEpisode=$initialSeasonEpisode") Timber.v("SeriesDetailParent: itemId=${destination.itemId}, initialSeasonEpisode=$initialSeasonEpisode")
@ -58,6 +62,8 @@ fun SeriesOverview(
initialSeasonEpisode.episode, initialSeasonEpisode.episode,
) )
} }
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val series by viewModel.item.observeAsState(null) val series by viewModel.item.observeAsState(null)
val seasons by viewModel.seasons.observeAsState(listOf()) val seasons by viewModel.seasons.observeAsState(listOf())
val episodes by viewModel.episodes.observeAsState(listOf()) val episodes by viewModel.episodes.observeAsState(listOf())
@ -91,72 +97,86 @@ fun SeriesOverview(
} }
} }
LaunchedEffect(Unit) { when (val state = loading) {
} is LoadingState.Error -> ErrorMessage(state)
if (series == null) { LoadingState.Loading -> LoadingPage()
// TODO
Text(text = "Loading...") LoadingState.Success -> {
} else { series?.let { series ->
series?.let { series -> LaunchedEffect(Unit) { episodeRowFocusRequester.tryRequestFocus() }
SeriesOverviewContent( SeriesOverviewContent(
series = series, series = series,
seasons = seasons, seasons = seasons,
episodes = episodes, episodes = episodes,
position = position, position = position,
backdropImageUrl = remember { viewModel.imageUrl(series.id, ImageType.BACKDROP) }, backdropImageUrl =
firstItemFocusRequester = firstItemFocusRequester, remember {
onFocus = { viewModel.imageUrl(
if (it.seasonTabIndex != position.seasonTabIndex) { series.id,
viewModel.loadEpisodes(seasons[it.seasonTabIndex]!!.indexNumber!!) ImageType.BACKDROP,
} )
position = it },
}, firstItemFocusRequester = firstItemFocusRequester,
onClick = { episodeRowFocusRequester = episodeRowFocusRequester,
val resumePosition = onFocus = {
it.data.userData if (it.seasonTabIndex != position.seasonTabIndex) {
?.playbackPositionTicks viewModel.loadEpisodes(seasons[it.seasonTabIndex]!!.indexNumber!!)
?.ticks ?: Duration.ZERO }
navigationManager.navigateTo(Destination.Playback(it.id, resumePosition.inWholeMilliseconds, it)) position = it
}, },
onLongClick = { onClick = {
// TODO val resumePosition =
}, it.data.userData
playOnClick = { resume -> ?.playbackPositionTicks
episodes.getOrNull(position.episodeRowIndex)?.let { ?.ticks ?: Duration.ZERO
navigationManager.navigateTo( navigationManager.navigateTo(
Destination.Playback( Destination.Playback(
it.id, it.id,
resume.inWholeMilliseconds, resumePosition.inWholeMilliseconds,
it, it,
), ),
) )
} },
}, onLongClick = {
watchOnClick = { // TODO
episodes.getOrNull(position.episodeRowIndex)?.let { },
val played = it.data.userData?.played ?: false playOnClick = { resume ->
viewModel.setWatched(it.id, !played, position.episodeRowIndex) episodes.getOrNull(position.episodeRowIndex)?.let {
} navigationManager.navigateTo(
}, Destination.Playback(
moreOnClick = { it.id,
// TODO show more actions resume.inWholeMilliseconds,
}, it,
overviewOnClick = { ),
episodes.getOrNull(position.episodeRowIndex)?.let {
overviewDialog =
ItemDetailsDialogInfo(
title = it.name ?: "Unknown",
overview = it.data.overview,
files =
it.data.mediaSources
?.mapNotNull { it.path }
.orEmpty(),
) )
} }
}, },
modifier = modifier, watchOnClick = {
) episodes.getOrNull(position.episodeRowIndex)?.let {
val played = it.data.userData?.played ?: false
viewModel.setWatched(it.id, !played, position.episodeRowIndex)
}
},
moreOnClick = {
// TODO show more actions
},
overviewOnClick = {
episodes.getOrNull(position.episodeRowIndex)?.let {
overviewDialog =
ItemDetailsDialogInfo(
title = it.name ?: "Unknown",
overview = it.data.overview,
files =
it.data.mediaSources
?.mapNotNull { it.path }
.orEmpty(),
)
}
},
modifier = modifier,
)
}
} }
} }

View file

@ -53,6 +53,7 @@ fun SeriesOverviewContent(
position: SeriesOverviewPosition, position: SeriesOverviewPosition,
backdropImageUrl: String?, backdropImageUrl: String?,
firstItemFocusRequester: FocusRequester, firstItemFocusRequester: FocusRequester,
episodeRowFocusRequester: FocusRequester,
onFocus: (SeriesOverviewPosition) -> Unit, onFocus: (SeriesOverviewPosition) -> Unit,
onClick: (BaseItem) -> Unit, onClick: (BaseItem) -> Unit,
onLongClick: (BaseItem) -> Unit, onLongClick: (BaseItem) -> Unit,
@ -177,7 +178,10 @@ fun SeriesOverviewContent(
state = state, state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(start = 16.dp), contentPadding = PaddingValues(start = 16.dp),
modifier = modifier.focusRestorer(firstItemFocusRequester), modifier =
Modifier
.focusRestorer(firstItemFocusRequester)
.focusRequester(episodeRowFocusRequester),
) { ) {
itemsIndexed(episodes) { episodeIndex, episode -> itemsIndexed(episodes) { episodeIndex, episode ->
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
@ -205,6 +209,7 @@ fun SeriesOverviewContent(
Modifier.focusRequester(firstItemFocusRequester), Modifier.focusRequester(firstItemFocusRequester),
), ),
interactionSource = interactionSource, interactionSource = interactionSource,
cardHeight = 120.dp,
) )
} }
} }

View file

@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -42,8 +41,9 @@ import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.dolphin.ui.cards.BannerCard import com.github.damontecres.dolphin.ui.cards.BannerCard
import com.github.damontecres.dolphin.ui.cards.ItemRow import com.github.damontecres.dolphin.ui.cards.ItemRow
import com.github.damontecres.dolphin.ui.components.CircularProgress
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
import com.github.damontecres.dolphin.ui.components.ErrorMessage
import com.github.damontecres.dolphin.ui.components.LoadingPage
import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.nav.NavigationManager import com.github.damontecres.dolphin.ui.nav.NavigationManager
@ -73,14 +73,9 @@ fun MainPage(
} }
val loading by viewModel.loadingState.observeAsState(LoadingState.Loading) val loading by viewModel.loadingState.observeAsState(LoadingState.Loading)
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> Text(text = "Error: ${state.message}", modifier = modifier) is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading ->
Box( LoadingState.Loading -> LoadingPage()
modifier = modifier,
contentAlignment = Alignment.Center,
) {
CircularProgress(modifier = Modifier.size(250.dp))
}
LoadingState.Success -> { LoadingState.Success -> {
val homeRows by viewModel.homeRows.observeAsState(listOf()) val homeRows by viewModel.homeRows.observeAsState(listOf())

View file

@ -7,7 +7,7 @@ import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.DefaultItemFields import com.github.damontecres.dolphin.ui.DefaultItemFields
import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.util.ExceptionHandler import com.github.damontecres.dolphin.util.LoadingExceptionHandler
import com.github.damontecres.dolphin.util.LoadingState import com.github.damontecres.dolphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -37,7 +37,13 @@ class MainViewModel
fun init(preferences: UserPreferences) { fun init(preferences: UserPreferences) {
val limit = preferences.appPreferences.homePagePreferences.maxItemsPerRow val limit = preferences.appPreferences.homePagePreferences.maxItemsPerRow
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loadingState,
"Error loading home page",
),
) {
val user = api.userApi.getCurrentUser().content val user = api.userApi.getCurrentUser().content
val displayPrefs = val displayPrefs =
api.displayPreferencesApi api.displayPreferencesApi

View file

@ -0,0 +1,43 @@
package com.github.damontecres.dolphin.util
import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import com.github.damontecres.dolphin.DolphinApplication
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import timber.log.Timber
import kotlin.coroutines.CoroutineContext
class LoadingExceptionHandler(
private val loadingState: MutableLiveData<LoadingState>,
private val errorMessage: String?,
private val autoToast: Boolean = false,
) : CoroutineExceptionHandler {
override val key: CoroutineContext.Key<*>
get() = CoroutineExceptionHandler
override fun handleException(
context: CoroutineContext,
exception: Throwable,
) {
if (exception is CancellationException) {
// Don't log/toast cancellations
return
}
Timber.e(exception, "Exception in coroutine")
loadingState.value =
LoadingState.Error(
message = errorMessage,
exception = exception,
)
if (autoToast) {
Toast
.makeText(
DolphinApplication.instance,
"Error: ${exception.message}",
Toast.LENGTH_LONG,
).show()
}
}
}

View file

@ -7,6 +7,6 @@ sealed interface LoadingState {
data class Error( data class Error(
val message: String? = null, val message: String? = null,
val exception: Exception? = null, val exception: Throwable? = null,
) : LoadingState ) : LoadingState
} }