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,18 +53,25 @@ 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() =
withContext(Dispatchers.IO) {
if (!pager.isInitialized) { if (!pager.isInitialized) {
item.value?.let { item -> item.value?.let { item ->
val includeItemTypes = val includeItemTypes =
@ -85,7 +106,11 @@ class CollectionFolderViewModel
val newPager = val newPager =
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
newPager.init() newPager.init()
newPager.getBlocking(0)
withContext(Dispatchers.Main) {
pager.value = newPager pager.value = newPager
loading.value = LoadingState.Success
}
} }
} }
} }
@ -102,12 +127,15 @@ 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...") when (val state = loading) {
} else { is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading -> LoadingPage()
LoadingState.Success -> {
pager?.let { pager -> pager?.let { pager ->
when (library!!.collectionType) { when (library!!.collectionType) {
CollectionType.UNKNOWN -> TODO() CollectionType.UNKNOWN -> TODO()
@ -122,6 +150,7 @@ fun CollectionFolderDetails(
pager, pager,
modifier, modifier,
) )
CollectionType.TVSHOWS -> { CollectionType.TVSHOWS -> {
TVShowCollectionDetails( TVShowCollectionDetails(
preferences, preferences,
@ -133,10 +162,6 @@ fun CollectionFolderDetails(
) )
} }
CollectionType.MUSIC -> TODO()
CollectionType.MUSICVIDEOS -> TODO()
CollectionType.TRAILERS -> TODO()
// TODO? // TODO?
CollectionType.HOMEVIDEOS -> CollectionType.HOMEVIDEOS ->
TVShowCollectionDetails( TVShowCollectionDetails(
@ -147,6 +172,10 @@ fun CollectionFolderDetails(
pager, pager,
modifier, modifier,
) )
CollectionType.MUSIC -> TODO()
CollectionType.MUSICVIDEOS -> TODO()
CollectionType.TRAILERS -> TODO()
CollectionType.BOXSETS -> TODO() CollectionType.BOXSETS -> TODO()
CollectionType.BOOKS -> TODO() CollectionType.BOOKS -> TODO()
CollectionType.PHOTOS -> TODO() CollectionType.PHOTOS -> TODO()
@ -156,6 +185,7 @@ fun CollectionFolderDetails(
} }
} }
} }
}
} }
@Composable @Composable
@ -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() }
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = modifier,
) {
Text(
text = title,
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
CardGrid( CardGrid(
pager = pager, pager = pager,
itemOnClick = { navigationManager.navigateTo(Destination.MediaItem(it.id, it.type, it)) }, itemOnClick = {
navigationManager.navigateTo(
Destination.MediaItem(
it.id,
it.type,
it,
),
)
},
longClicker = {}, longClicker = {},
letterPosition = { 0 }, letterPosition = { 0 },
requestFocus = true, requestFocus = true,
gridFocusRequester = gridFocusRequester, gridFocusRequester = gridFocusRequester,
navigationManager = navigationManager, navigationManager = navigationManager,
modifier = modifier, modifier = Modifier.fillMaxSize(),
initialPosition = 0, initialPosition = 0,
positionCallback = { _, _ -> }, 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,9 +62,11 @@ 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)
LoadingState.Loading -> LoadingPage()
LoadingState.Success -> {
item?.let { item -> item?.let { item ->
EpisodeDetailsContent( EpisodeDetailsContent(
item = item, item = item,
@ -76,6 +80,7 @@ fun EpisodeDetails(
) )
} }
} }
}
} }
@Composable @Composable

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,27 +25,27 @@ 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
item.value?.id == itemId -> item.value
else -> {
val it = api.userLibraryApi.getItem(itemId).content
BaseItem.from(it, api)
} }
if (item.value?.id == itemId) {
return null
} }
return viewModelScope.launch(ExceptionHandler()) { return@withContext fetchedItem?.let {
try { val modelInstance = convertModel(fetchedItem.data, api)
val fetchedItem = api.userLibraryApi.getItem(itemId).content withContext(Dispatchers.Main) {
val modelInstance = convertModel(fetchedItem, api) item.value = fetchedItem
item.value = BaseItem.from(fetchedItem, api)
model.value = modelInstance as T model.value = modelInstance as T
} catch (e: Exception) {
Timber.e(e, "Failed to load item $itemId")
item.value = null
} }
fetchedItem
} }
} }
@ -51,3 +54,44 @@ abstract class ItemViewModel<T : DolphinModel>(
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,9 +44,11 @@ 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)
LoadingState.Loading -> LoadingPage()
LoadingState.Success -> {
item?.let { item -> item?.let { item ->
val dto = item.data val dto = item.data
LazyColumn( LazyColumn(
@ -81,7 +86,13 @@ fun MovieDetails(
item { item {
Button( Button(
onClick = { onClick = {
navigationManager.navigateTo(Destination.Playback(item.id, 0L, item)) navigationManager.navigateTo(
Destination.Playback(
item.id,
0L,
item,
),
)
}, },
) { ) {
Text(text = "Play") Text(text = "Play")
@ -90,4 +101,5 @@ fun MovieDetails(
} }
} }
} }
}
} }

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,16 +49,48 @@ 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( fun init(
itemId: UUID, itemId: UUID,
potential: BaseItem?, potential: BaseItem?,
): Job = season: Int?,
viewModelScope.launch(ExceptionHandler()) { episode: Int?,
super.init(itemId, potential)?.join() ) {
item.value?.let { item -> this.seriesId = itemId
viewModelScope.launch(
LoadingExceptionHandler(
loading,
"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")
}
}
}
}
private suspend fun getSeasons(item: BaseItem): ApiRequestPager<GetItemsRequest>? {
val request = val request =
GetItemsRequest( GetItemsRequest(
parentId = item.id, parentId = item.id,
@ -78,7 +114,6 @@ class SeriesViewModel
itemCount = item.data.childCount, itemCount = item.data.childCount,
) )
pager.init() pager.init()
seasons.value = pager
Timber.v("Loaded ${pager.size} seasons for series ${item.id}") Timber.v("Loaded ${pager.size} seasons for series ${item.id}")
val pairs = val pairs =
pager.mapIndexed { index, _ -> pager.mapIndexed { index, _ ->
@ -87,26 +122,10 @@ class SeriesViewModel
} }
mapOf(*pairs.toTypedArray()) mapOf(*pairs.toTypedArray())
mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray()) mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray())
} return pager
} }
fun init( private suspend fun loadEpisodesInternal(season: Int): ApiRequestPager<GetEpisodesRequest> {
itemId: UUID,
potential: BaseItem?,
season: Int?,
episode: Int?,
) {
viewModelScope.launch(ExceptionHandler()) {
init(itemId, potential).join()
season?.let { seasonNum ->
// TODO map season number to index in list
loadEpisodes(seasonNum)
}
}
}
fun loadEpisodes(season: Int): Deferred<ApiRequestPager<*>> =
viewModelScope.async(ExceptionHandler()) {
val request = val request =
GetEpisodesRequest( GetEpisodesRequest(
seriesId = item.value!!.id, seriesId = item.value!!.id,
@ -125,8 +144,22 @@ class SeriesViewModel
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope) val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
pager.init() pager.init()
Timber.v("Loaded ${pager.size} episodes for season $season") Timber.v("Loaded ${pager.size} episodes for season $season")
episodes.value = pager return pager
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,13 +197,16 @@ 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) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading -> LoadingPage()
LoadingState.Success -> {
item?.let { item -> item?.let { item ->
LazyColumn(modifier = modifier) { LazyColumn(modifier = modifier) {
item { item {
@ -188,4 +224,5 @@ fun SeriesDetails(
} }
} }
} }
}
} }

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,9 +44,11 @@ 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)
LoadingState.Loading -> LoadingPage()
LoadingState.Success -> {
item?.let { item -> item?.let { item ->
val dto = item.data val dto = item.data
LazyColumn( LazyColumn(
@ -81,7 +86,13 @@ fun VideoDetails(
item { item {
Button( Button(
onClick = { onClick = {
navigationManager.navigateTo(Destination.Playback(item.id, 0L, item)) navigationManager.navigateTo(
Destination.Playback(
item.id,
0L,
item,
),
)
}, },
) { ) {
Text(text = "Play") Text(text = "Play")
@ -90,4 +101,5 @@ fun VideoDetails(
} }
} }
} }
}
} }

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,21 +97,28 @@ 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 =
remember {
viewModel.imageUrl(
series.id,
ImageType.BACKDROP,
)
},
firstItemFocusRequester = firstItemFocusRequester, firstItemFocusRequester = firstItemFocusRequester,
episodeRowFocusRequester = episodeRowFocusRequester,
onFocus = { onFocus = {
if (it.seasonTabIndex != position.seasonTabIndex) { if (it.seasonTabIndex != position.seasonTabIndex) {
viewModel.loadEpisodes(seasons[it.seasonTabIndex]!!.indexNumber!!) viewModel.loadEpisodes(seasons[it.seasonTabIndex]!!.indexNumber!!)
@ -117,7 +130,13 @@ fun SeriesOverview(
it.data.userData it.data.userData
?.playbackPositionTicks ?.playbackPositionTicks
?.ticks ?: Duration.ZERO ?.ticks ?: Duration.ZERO
navigationManager.navigateTo(Destination.Playback(it.id, resumePosition.inWholeMilliseconds, it)) navigationManager.navigateTo(
Destination.Playback(
it.id,
resumePosition.inWholeMilliseconds,
it,
),
)
}, },
onLongClick = { onLongClick = {
// TODO // TODO
@ -159,6 +178,7 @@ fun SeriesOverview(
) )
} }
} }
}
overviewDialog?.let { info -> overviewDialog?.let { info ->
ItemDetailsDialog( ItemDetailsDialog(

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
} }