Add page to details about people (#119)

Adds a page to show details about people (actors, directors, etc) when
clicking on the person card

<img width="1280" height="771" alt="wholphin_person"
src="https://github.com/user-attachments/assets/ceba6c77-2943-4faf-a2c2-81471fb3acff"
/>
This commit is contained in:
damontecres 2025-10-30 19:24:36 -04:00 committed by GitHub
parent 90f4ca7306
commit 982506555f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 635 additions and 20 deletions

View file

@ -17,6 +17,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
@ -39,6 +40,7 @@ fun ItemRow(
modifier: Modifier = Modifier,
focusPair: FocusPair? = null,
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
horizontalPadding: Dp = 16.dp,
) {
val state = rememberLazyListState()
val firstFocus = remember { FocusRequester() }
@ -54,8 +56,8 @@ fun ItemRow(
)
LazyRow(
state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(horizontalPadding),
contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 8.dp),
modifier =
Modifier
.fillMaxWidth()

View file

@ -0,0 +1,121 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.util.RowLoadingState
@Composable
fun LoadingRow(
title: String,
state: RowLoadingState,
rowIndex: Int,
position: RowColumn,
focusRequester: FocusRequester,
onClickItem: (BaseItem) -> Unit,
onClickPosition: (RowColumn) -> Unit,
modifier: Modifier = Modifier,
showIfEmpty: Boolean = true,
horizontalPadding: Dp = 16.dp,
cardContent: @Composable (
index: Int,
item: BaseItem?,
modifier: Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit,
) -> Unit = @Composable { index, item, mod, onClick, onLongClick ->
SeasonCard(
item = item,
onClick = {
onClickPosition.invoke(RowColumn(rowIndex, index))
onClick.invoke()
},
onLongClick = onLongClick,
imageHeight = Cards.height2x3,
modifier =
mod
.ifElse(
position.row == rowIndex && position.column == index,
Modifier.focusRequester(focusRequester),
),
)
},
) {
when (val r = state) {
is RowLoadingState.Error ->
LoadingRowPlaceholder(
title = title,
message = r.localizedMessage,
messageColor = MaterialTheme.colorScheme.error,
modifier = Modifier,
)
RowLoadingState.Pending,
RowLoadingState.Loading,
->
LoadingRowPlaceholder(
title = title,
message = "Loading...",
modifier = modifier,
)
is RowLoadingState.Success -> {
if (r.items.isNotEmpty()) {
ItemRow(
title = title,
items = r.items,
onClickItem = onClickItem,
onLongClickItem = {},
modifier = modifier,
cardContent = cardContent,
horizontalPadding = horizontalPadding,
)
} else if (showIfEmpty) {
LoadingRowPlaceholder(
title = title,
message = "No results",
modifier = modifier,
)
}
}
}
}
@Composable
fun LoadingRowPlaceholder(
title: String,
message: String,
modifier: Modifier = Modifier,
messageColor: Color = MaterialTheme.colorScheme.onBackground,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.padding(bottom = 32.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = message,
style = MaterialTheme.typography.titleMedium,
color = messageColor,
)
}
}

View file

@ -4,11 +4,12 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
@ -66,14 +67,14 @@ abstract class LoadingItemViewModel(
open fun init(
itemId: UUID,
potential: BaseItem?,
): Job? {
loading.value = LoadingState.Loading
return viewModelScope.launch(
): Deferred<BaseItem?> =
viewModelScope.async(
LoadingExceptionHandler(
loading,
"Error loading item $itemId",
) + Dispatchers.IO,
) {
loading.setValueOnMain(LoadingState.Loading)
try {
fetchAndSetItem(itemId)
} catch (e: Exception) {
@ -82,16 +83,18 @@ abstract class LoadingItemViewModel(
item.value = null
loading.value = LoadingState.Error("Error loading item $itemId", e)
}
}
null
}
}
open suspend fun fetchAndSetItem(itemId: UUID) =
open suspend fun fetchAndSetItem(itemId: UUID): BaseItem =
withContext(Dispatchers.IO) {
val fetchedItem = api.userLibraryApi.getItem(itemId).content
val item = BaseItem.from(fetchedItem, api)
withContext(Dispatchers.Main) {
item.value = BaseItem.from(fetchedItem, api)
this@LoadingItemViewModel.item.value = item
loading.value = LoadingState.Success
}
return@withContext item
}
}

View file

@ -0,0 +1,427 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.text.style.TextOverflow
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 androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.LoadingRow
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.RowLoadingState
import com.github.damontecres.wholphin.util.formatDate
import dagger.hilt.android.lifecycle.HiltViewModel
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import java.time.LocalDate
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class PersonViewModel
@Inject
constructor(
api: ApiClient,
val navigationManager: NavigationManager,
) : LoadingItemViewModel(api) {
val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
val episodes = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
fun init(itemId: UUID) {
viewModelScope.launchIO(
LoadingExceptionHandler(
loading,
"Error loading item $itemId",
),
) {
super.init(itemId, null).await()?.let { person ->
val dto = person.data
if ((dto.movieCount ?: 0) > 0) {
fetchRow(person.id, BaseItemKind.MOVIE, movies)
} else {
movies.setValueOnMain(RowLoadingState.Success(listOf()))
}
if ((dto.seriesCount ?: 0) > 0) {
fetchRow(person.id, BaseItemKind.SERIES, series)
} else {
series.setValueOnMain(RowLoadingState.Success(listOf()))
}
if ((dto.episodeCount ?: 0) > 0) {
fetchRow(person.id, BaseItemKind.EPISODE, episodes)
} else {
episodes.setValueOnMain(RowLoadingState.Success(listOf()))
}
}
}
}
private fun fetchRow(
itemId: UUID,
type: BaseItemKind,
target: MutableLiveData<RowLoadingState>,
) {
viewModelScope.launchIO {
target.setValueOnMain(RowLoadingState.Loading)
try {
val request =
GetItemsRequest(
personIds = listOf(itemId),
includeItemTypes = listOf(type),
fields = SlimItemFields,
recursive = true,
sortBy = listOf(ItemSortBy.PREMIERE_DATE, ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME),
sortOrder = listOf(SortOrder.DESCENDING, SortOrder.DESCENDING, SortOrder.ASCENDING),
)
val pager =
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
viewModelScope,
pageSize = 15,
useSeriesForPrimary = false,
).init()
target.setValueOnMain(RowLoadingState.Success(pager))
} catch (ex: Exception) {
Timber.e(ex, "Error fetching $type for $itemId")
target.setValueOnMain(RowLoadingState.Error(ex))
}
}
}
}
@Composable
fun PersonPage(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
viewModel: PersonViewModel = hiltViewModel(),
) {
OneTimeLaunchedEffect {
viewModel.init(destination.itemId)
}
val person by viewModel.item.observeAsState()
val movies by viewModel.movies.observeAsState(RowLoadingState.Pending)
val series by viewModel.series.observeAsState(RowLoadingState.Pending)
val episodes by viewModel.episodes.observeAsState(RowLoadingState.Pending)
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state, modifier)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage(modifier)
LoadingState.Success -> {
person?.let { person ->
var showOverviewDialog by remember { mutableStateOf(false) }
val name = person.name ?: person.id.toString()
PersonPageContent(
preferences = preferences,
name = name,
overview = person.data.overview,
imageUrl = person.imageUrl,
birthdate = person.data.premiereDate?.toLocalDate(),
deathdate = person.data.endDate?.toLocalDate(),
birthPlace = person.data.productionLocations?.firstOrNull(),
movies = movies,
series = series,
episodes = episodes,
onClickItem = {
viewModel.navigationManager.navigateTo(it.destination())
},
overviewOnClick = { showOverviewDialog = true },
modifier = modifier,
)
AnimatedVisibility(showOverviewDialog) {
ItemDetailsDialog(
info =
ItemDetailsDialogInfo(
title = name,
overview = person.data.overview,
files = listOf(),
),
onDismissRequest = { showOverviewDialog = false },
)
}
}
}
}
}
private const val HEADER_ROW = 0
private const val MOVIE_ROW = 1
private const val SERIES_ROW = 2
private const val EPISODE_ROW = 3
@Composable
fun PersonPageContent(
preferences: UserPreferences,
name: String,
overview: String?,
imageUrl: String?,
birthdate: LocalDate?,
deathdate: LocalDate?,
birthPlace: String?,
movies: RowLoadingState,
series: RowLoadingState,
episodes: RowLoadingState,
onClickItem: (BaseItem) -> Unit,
overviewOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
val headerFocusRequester = remember { FocusRequester() }
var position by rememberPosition()
LaunchedEffect(Unit) {
if (position.row > HEADER_ROW) {
focusRequester.tryRequestFocus()
} else {
headerFocusRequester.tryRequestFocus()
}
}
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier =
modifier
.padding(start = 16.dp),
) {
item {
PersonHeader(
name = name,
overview = overview,
imageUrl = imageUrl,
birthdate = birthdate,
birthPlace = birthPlace,
deathdate = deathdate,
overviewOnClick = overviewOnClick,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 480.dp)
.padding(top = 16.dp, bottom = 40.dp)
.focusRequester(headerFocusRequester),
)
}
item {
LoadingRow(
title = rowTitle("Movies", movies),
state = movies,
rowIndex = MOVIE_ROW,
position = position,
focusRequester = focusRequester,
onClickItem = onClickItem,
onClickPosition = { position = it },
showIfEmpty = false,
modifier = Modifier.fillMaxWidth(),
)
}
item {
LoadingRow(
title = rowTitle("Series", series),
state = series,
rowIndex = SERIES_ROW,
position = position,
focusRequester = focusRequester,
onClickItem = onClickItem,
onClickPosition = { position = it },
showIfEmpty = false,
modifier = Modifier.fillMaxWidth(),
)
}
item {
LoadingRow(
title = rowTitle("Episodes", episodes),
state = episodes,
rowIndex = EPISODE_ROW,
position = position,
focusRequester = focusRequester,
onClickItem = onClickItem,
onClickPosition = { position = it },
showIfEmpty = false,
horizontalPadding = 32.dp,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
fun rowTitle(
prefix: String,
state: RowLoadingState,
): String =
if (state is RowLoadingState.Success) {
"$prefix (${state.items.size})"
} else {
prefix
}
@Composable
fun PersonHeader(
name: String,
overview: String?,
imageUrl: String?,
birthdate: LocalDate?,
deathdate: LocalDate?,
birthPlace: String?,
overviewOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(32.dp),
modifier = modifier,
) {
AsyncImage(
model = imageUrl,
contentDescription = name,
modifier =
Modifier
// .fillMaxWidth(.25f)
.weight(1f)
.clip(RoundedCornerShape(10)),
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
// .fillMaxWidth(.7f)
.weight(3f)
.padding(top = 16.dp, end = 32.dp)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), RoundedCornerShape(10))
.padding(16.dp),
) {
Text(
text = name,
color = MaterialTheme.colorScheme.onSurface,
style =
MaterialTheme.typography.displaySmall.copy(
shadow =
Shadow(
color = Color.DarkGray,
offset = Offset(5f, 2f),
blurRadius = 2f,
),
),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 8.dp),
)
birthdate?.let {
val age = if (deathdate == null) birthdate.until(LocalDate.now())?.years else null
val text =
if (age != null) {
"Born: ${formatDate(it)} ($age years old)"
} else {
"Born: ${formatDate(it)}"
}
Text(
text = text,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
birthPlace?.let {
Text(
text = "Birthplace: $it",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
deathdate?.let {
val age = birthdate?.until(it)?.years
val text =
if (age != null) {
"Died: ${formatDate(it)} ($age years old)"
} else {
"Died: ${formatDate(it)}"
}
Text(
text = text,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
OverviewText(
overview = overview ?: "",
maxLines = 3,
onClick = overviewOnClick,
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
@PreviewTvSpec
@Composable
private fun PersonPreview() {
WholphinTheme {
PersonHeader(
name = "John Smith",
overview = "John Smith is an actor",
imageUrl = null,
birthdate = LocalDate.of(1975, 3, 22),
birthPlace = "Phoenix, Arizona, USA",
deathdate = LocalDate.of(2025, 2, 1),
overviewOnClick = {},
modifier = Modifier.fillMaxWidth(),
)
}
}

View file

@ -74,6 +74,7 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID
import kotlin.time.Duration
@ -87,7 +88,7 @@ fun MovieDetails(
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.init(destination.itemId, destination.item)
viewModel.init(destination.itemId)
}
val item by viewModel.item.observeAsState()
val people by viewModel.people.observeAsState(listOf())
@ -119,6 +120,14 @@ fun MovieDetails(
onClickItem = {
viewModel.navigationManager.navigateTo(it.destination())
},
onClickPerson = {
viewModel.navigationManager.navigateTo(
Destination.MediaItem(
it.id,
BaseItemKind.PERSON,
),
)
},
playOnClick = {
viewModel.navigationManager.navigateTo(
Destination.Playback(
@ -266,6 +275,7 @@ fun MovieDetailsContent(
favoriteOnClick: () -> Unit,
moreOnClick: () -> Unit,
onClickItem: (BaseItem) -> Unit,
onClickPerson: (Person) -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
@ -361,6 +371,7 @@ fun MovieDetailsContent(
people = people,
onClick = {
position = PEOPLE_ROW
onClickPerson.invoke(it)
},
onLongClick = {},
modifier =

View file

@ -52,10 +52,7 @@ class MovieViewModel
val similar = MutableLiveData<List<BaseItem>>()
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
override fun init(
itemId: UUID,
potential: BaseItem?,
): Job? {
fun init(itemId: UUID): Job? {
this.itemId = itemId
return viewModelScope.launch(
Dispatchers.IO +
@ -103,9 +100,9 @@ class MovieViewModel
people.value =
item.data.people
?.letNotEmpty { people ->
people.map { Person.Companion.fromDto(it, api) }
people.map { Person.fromDto(it, api) }
}.orEmpty()
chapters.value = Chapter.Companion.fromDto(item.data, api)
chapters.value = Chapter.fromDto(item.data, api)
}
if (!similar.isInitialized) {
val similar =

View file

@ -72,6 +72,7 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration
@ -123,6 +124,14 @@ fun SeriesDetails(
favorite = item.data.userData?.isFavorite ?: false,
modifier = modifier,
onClickItem = { viewModel.navigateTo(it.destination()) },
onClickPerson = {
viewModel.navigateTo(
Destination.MediaItem(
it.id,
BaseItemKind.PERSON,
),
)
},
onLongClickItem = { season ->
seasonDialog =
buildDialogForSeason(
@ -196,6 +205,7 @@ fun SeriesDetailsContent(
played: Boolean,
favorite: Boolean,
onClickItem: (BaseItem) -> Unit,
onClickPerson: (Person) -> Unit,
onLongClickItem: (BaseItem) -> Unit,
overviewOnClick: () -> Unit,
playOnClick: () -> Unit,
@ -361,6 +371,7 @@ fun SeriesDetailsContent(
people = people,
onClick = {
position = PEOPLE_ROW
onClickPerson.invoke(it)
},
onLongClick = {},
modifier =

View file

@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie
import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv
import com.github.damontecres.wholphin.ui.detail.DebugPage
import com.github.damontecres.wholphin.ui.detail.FavoritesPage
import com.github.damontecres.wholphin.ui.detail.PersonPage
import com.github.damontecres.wholphin.ui.detail.PlaylistDetails
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
@ -173,6 +174,13 @@ fun DestinationContent(
modifier,
)
BaseItemKind.PERSON ->
PersonPage(
preferences,
destination,
modifier,
)
else -> {
Timber.w("Unsupported item type: ${destination.type}")
Text("Unsupported item type: ${destination.type}")

View file

@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
@ -32,6 +33,18 @@ fun formatDateTime(dateTime: LocalDateTime): String =
dateTime.toString()
}
fun formatDate(dateTime: LocalDate): String =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// TODO server returns in UTC, but sdk converts to local time
// eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy")
formatter.format(dateTime)
} else if (dateTime.toString().length >= 10) {
dateTime.toString().substring(0, 10)
} else {
dateTime.toString()
}
/**
* If the item has season & episode info, format as `S# E#`
*/

View file

@ -1,14 +1,16 @@
package com.github.damontecres.wholphin.util
import com.github.damontecres.wholphin.data.model.BaseItem
/**
* Generic state for loading something from the API
*/
sealed interface LoadingState {
object Pending : LoadingState
data object Pending : LoadingState
object Loading : LoadingState
data object Loading : LoadingState
object Success : LoadingState
data object Success : LoadingState
data class Error(
val message: String? = null,
@ -20,3 +22,23 @@ sealed interface LoadingState {
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
}
}
sealed interface RowLoadingState {
data object Pending : RowLoadingState
data object Loading : RowLoadingState
data class Success(
val items: List<BaseItem?>,
) : RowLoadingState
data class Error(
val message: String? = null,
val exception: Throwable? = null,
) : RowLoadingState {
constructor(exception: Throwable) : this(null, exception)
val localizedMessage: String =
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
}
}