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.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),
)
}
}

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
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<Library>(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val pager = MutableLiveData<ApiRequestPager<GetItemsRequest>?>()
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<BaseItem?>,
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 = { _, _ -> },
)
}
}

View file

@ -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<Video>(api)
) : LoadingItemViewModel<Video>(api)
@Composable
fun EpisodeDetails(
@ -60,20 +62,23 @@ fun EpisodeDetails(
viewModel.init(destination.itemId, destination.item)
}
val item by viewModel.item.observeAsState()
if (item == null) {
Text(text = "Loading...")
} else {
item?.let { item ->
EpisodeDetailsContent(
item = item,
backdropImageUrl =
remember {
item.data.parentBackdropItemId?.let {
viewModel.imageUrl(it, ImageType.BACKDROP)
}
},
modifier = modifier,
)
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading -> LoadingPage()
LoadingState.Success -> {
item?.let { item ->
EpisodeDetailsContent(
item = item,
backdropImageUrl =
remember {
item.data.parentBackdropItemId?.let {
viewModel.imageUrl(it, ImageType.BACKDROP)
}
},
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.DolphinModel
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.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@ -22,32 +25,73 @@ abstract class ItemViewModel<T : DolphinModel>(
val item = MutableLiveData<BaseItem?>(null)
val model = MutableLiveData<T?>(null)
open fun init(
suspend fun fetchItem(
itemId: UUID,
potential: BaseItem?,
): Job? {
if (item.value == null && potential?.id == itemId) {
item.value = potential
return null
}
if (item.value?.id == itemId) {
return null
}
return viewModelScope.launch(ExceptionHandler()) {
try {
val fetchedItem = api.userLibraryApi.getItem(itemId).content
val modelInstance = convertModel(fetchedItem, api)
item.value = BaseItem.from(fetchedItem, api)
model.value = modelInstance as T
} catch (e: Exception) {
Timber.e(e, "Failed to load item $itemId")
item.value = null
): BaseItem? =
withContext(Dispatchers.IO) {
val fetchedItem =
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)
}
}
return@withContext fetchedItem?.let {
val modelInstance = convertModel(fetchedItem.data, api)
withContext(Dispatchers.Main) {
item.value = fetchedItem
model.value = modelInstance as T
}
fetchedItem
}
}
}
fun imageUrl(
itemId: UUID,
type: ImageType,
): 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 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.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.extensions.ticks
@ -27,7 +30,7 @@ class MovieViewModel
@Inject
constructor(
api: ApiClient,
) : ItemViewModel<Video>(api)
) : LoadingItemViewModel<Video>(api)
@Composable
fun MovieDetails(
@ -41,50 +44,59 @@ fun MovieDetails(
viewModel.init(destination.itemId, destination.item)
}
val item by viewModel.item.observeAsState()
if (item == null) {
Text(text = "Loading...")
} else {
item?.let { item ->
val dto = item.data
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(32.dp),
modifier = modifier,
) {
item {
Text(text = item.name ?: "Unknown")
}
dto.overview?.let {
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading -> LoadingPage()
LoadingState.Success -> {
item?.let { item ->
val dto = item.data
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(32.dp),
modifier = modifier,
) {
item {
Text(text = it)
Text(text = item.name ?: "Unknown")
}
}
dto.userData?.playbackPositionTicks?.ticks?.let {
if (it > 60.seconds) {
dto.overview?.let {
item {
Button(
onClick = {
navigationManager.navigateTo(
Destination.Playback(
item.id,
it.inWholeMilliseconds,
item,
),
)
},
) {
Text(text = "Resume")
Text(text = it)
}
}
dto.userData?.playbackPositionTicks?.ticks?.let {
if (it > 60.seconds) {
item {
Button(
onClick = {
navigationManager.navigateTo(
Destination.Playback(
item.id,
it.inWholeMilliseconds,
item,
),
)
},
) {
Text(text = "Resume")
}
}
}
}
}
item {
Button(
onClick = {
navigationManager.navigateTo(Destination.Playback(item.id, 0L, item))
},
) {
Text(text = "Play")
item {
Button(
onClick = {
navigationManager.navigateTo(
Destination.Playback(
item.id,
0L,
item,
),
)
},
) {
Text(text = "Play")
}
}
}
}

View file

@ -44,13 +44,12 @@ class SeasonViewModel
) : ItemViewModel<Video>(api) {
val episodes = MutableLiveData<List<BaseItem?>>(listOf())
override fun init(
fun init(
itemId: UUID,
potential: BaseItem?,
): Job? =
viewModelScope.launch(ExceptionHandler()) {
super.init(itemId, potential)?.join()
item.value?.let { item ->
fetchItem(itemId, potential)?.let { item ->
val request =
GetItemsRequest(
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.preferences.UserPreferences
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.NavigationManager
import com.github.damontecres.dolphin.util.ApiRequestPager
import com.github.damontecres.dolphin.util.ExceptionHandler
import com.github.damontecres.dolphin.util.GetEpisodesRequestHandler
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.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@ -45,88 +49,117 @@ class SeriesViewModel
constructor(
api: ApiClient,
) : ItemViewModel<Video>(api) {
private lateinit var seriesId: UUID
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val seasons = 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(
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)
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")
}
}
}
}
fun loadEpisodes(season: Int): Deferred<ApiRequestPager<*>> =
viewModelScope.async(ExceptionHandler()) {
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")
episodes.value = pager
pager
private suspend fun getSeasons(item: BaseItem): ApiRequestPager<GetItemsRequest>? {
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()
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(
@ -164,26 +197,30 @@ fun SeriesDetails(
viewModel: SeriesViewModel = hiltViewModel(),
) {
LaunchedEffect(Unit) {
viewModel.init(destination.itemId, destination.item)
viewModel.init(destination.itemId, destination.item, null, null)
}
val item by viewModel.item.observeAsState()
val seasons by viewModel.seasons.observeAsState(listOf())
if (item == null) {
Text(text = "Loading...")
} else {
item?.let { item ->
LazyColumn(modifier = modifier) {
item {
Text(text = item.name ?: "Unknown")
}
item {
ItemRow(
title = "Seasons",
items = seasons,
onClickItem = { navigationManager.navigateTo(it.destination()) },
onLongClickItem = { },
modifier = Modifier.fillMaxWidth(),
)
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading -> LoadingPage()
LoadingState.Success -> {
item?.let { item ->
LazyColumn(modifier = modifier) {
item {
Text(text = item.name ?: "Unknown")
}
item {
ItemRow(
title = "Seasons",
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 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.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.extensions.ticks
@ -27,7 +30,7 @@ class VideoViewModel
@Inject
constructor(
api: ApiClient,
) : ItemViewModel<Video>(api)
) : LoadingItemViewModel<Video>(api)
@Composable
fun VideoDetails(
@ -41,50 +44,59 @@ fun VideoDetails(
viewModel.init(destination.itemId, destination.item)
}
val item by viewModel.item.observeAsState()
if (item == null) {
Text(text = "Loading...")
} else {
item?.let { item ->
val dto = item.data
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(32.dp),
modifier = modifier,
) {
item {
Text(text = item.name ?: "Unknown")
}
dto.overview?.let {
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading -> LoadingPage()
LoadingState.Success -> {
item?.let { item ->
val dto = item.data
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(32.dp),
modifier = modifier,
) {
item {
Text(text = it)
Text(text = item.name ?: "Unknown")
}
}
dto.userData?.playbackPositionTicks?.ticks?.let {
if (it > 60.seconds) {
dto.overview?.let {
item {
Button(
onClick = {
navigationManager.navigateTo(
Destination.Playback(
item.id,
it.inWholeMilliseconds,
item,
),
)
},
) {
Text(text = "Resume")
Text(text = it)
}
}
dto.userData?.playbackPositionTicks?.ticks?.let {
if (it > 60.seconds) {
item {
Button(
onClick = {
navigationManager.navigateTo(
Destination.Playback(
item.id,
it.inWholeMilliseconds,
item,
),
)
},
) {
Text(text = "Resume")
}
}
}
}
}
item {
Button(
onClick = {
navigationManager.navigateTo(Destination.Playback(item.id, 0L, item))
},
) {
Text(text = "Play")
item {
Button(
onClick = {
navigationManager.navigateTo(
Destination.Playback(
item.id,
0L,
item,
),
)
},
) {
Text(text = "Play")
}
}
}
}

View file

@ -12,14 +12,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.Text
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.data.ItemDetailsDialog
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.dolphin.ui.detail.SeriesViewModel
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.LoadingState
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks
@ -48,6 +51,7 @@ fun SeriesOverview(
initialSeasonEpisode: SeasonEpisode = SeasonEpisode(0, 0),
) {
val firstItemFocusRequester = remember { FocusRequester() }
val episodeRowFocusRequester = remember { FocusRequester() }
OneTimeLaunchedEffect {
Timber.v("SeriesDetailParent: itemId=${destination.itemId}, initialSeasonEpisode=$initialSeasonEpisode")
@ -58,6 +62,8 @@ fun SeriesOverview(
initialSeasonEpisode.episode,
)
}
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val series by viewModel.item.observeAsState(null)
val seasons by viewModel.seasons.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) {
// TODO
Text(text = "Loading...")
} else {
series?.let { series ->
SeriesOverviewContent(
series = series,
seasons = seasons,
episodes = episodes,
position = position,
backdropImageUrl = remember { viewModel.imageUrl(series.id, ImageType.BACKDROP) },
firstItemFocusRequester = firstItemFocusRequester,
onFocus = {
if (it.seasonTabIndex != position.seasonTabIndex) {
viewModel.loadEpisodes(seasons[it.seasonTabIndex]!!.indexNumber!!)
}
position = it
},
onClick = {
val resumePosition =
it.data.userData
?.playbackPositionTicks
?.ticks ?: Duration.ZERO
navigationManager.navigateTo(Destination.Playback(it.id, resumePosition.inWholeMilliseconds, it))
},
onLongClick = {
// TODO
},
playOnClick = { resume ->
episodes.getOrNull(position.episodeRowIndex)?.let {
LoadingState.Loading -> LoadingPage()
LoadingState.Success -> {
series?.let { series ->
LaunchedEffect(Unit) { episodeRowFocusRequester.tryRequestFocus() }
SeriesOverviewContent(
series = series,
seasons = seasons,
episodes = episodes,
position = position,
backdropImageUrl =
remember {
viewModel.imageUrl(
series.id,
ImageType.BACKDROP,
)
},
firstItemFocusRequester = firstItemFocusRequester,
episodeRowFocusRequester = episodeRowFocusRequester,
onFocus = {
if (it.seasonTabIndex != position.seasonTabIndex) {
viewModel.loadEpisodes(seasons[it.seasonTabIndex]!!.indexNumber!!)
}
position = it
},
onClick = {
val resumePosition =
it.data.userData
?.playbackPositionTicks
?.ticks ?: Duration.ZERO
navigationManager.navigateTo(
Destination.Playback(
it.id,
resume.inWholeMilliseconds,
resumePosition.inWholeMilliseconds,
it,
),
)
}
},
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(),
},
onLongClick = {
// TODO
},
playOnClick = { resume ->
episodes.getOrNull(position.episodeRowIndex)?.let {
navigationManager.navigateTo(
Destination.Playback(
it.id,
resume.inWholeMilliseconds,
it,
),
)
}
},
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,
backdropImageUrl: String?,
firstItemFocusRequester: FocusRequester,
episodeRowFocusRequester: FocusRequester,
onFocus: (SeriesOverviewPosition) -> Unit,
onClick: (BaseItem) -> Unit,
onLongClick: (BaseItem) -> Unit,
@ -177,7 +178,10 @@ fun SeriesOverviewContent(
state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(start = 16.dp),
modifier = modifier.focusRestorer(firstItemFocusRequester),
modifier =
Modifier
.focusRestorer(firstItemFocusRequester)
.focusRequester(episodeRowFocusRequester),
) {
itemsIndexed(episodes) { episodeIndex, episode ->
val interactionSource = remember { MutableInteractionSource() }
@ -205,6 +209,7 @@ fun SeriesOverviewContent(
Modifier.focusRequester(firstItemFocusRequester),
),
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.cards.BannerCard
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.ErrorMessage
import com.github.damontecres.dolphin.ui.components.LoadingPage
import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.nav.NavigationManager
@ -73,14 +73,9 @@ fun MainPage(
}
val loading by viewModel.loadingState.observeAsState(LoadingState.Loading)
when (val state = loading) {
is LoadingState.Error -> Text(text = "Error: ${state.message}", modifier = modifier)
LoadingState.Loading ->
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
CircularProgress(modifier = Modifier.size(250.dp))
}
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading -> LoadingPage()
LoadingState.Success -> {
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.ui.DefaultItemFields
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
@ -37,7 +37,13 @@ class MainViewModel
fun init(preferences: UserPreferences) {
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 displayPrefs =
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(
val message: String? = null,
val exception: Exception? = null,
val exception: Throwable? = null,
) : LoadingState
}