mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Better loading & error handling
This commit is contained in:
parent
1411859b7f
commit
6e42cd3c65
15 changed files with 677 additions and 375 deletions
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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 = { _, _ -> },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue