From 982506555fb94c727cb98267c2a181f074e67e8e Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Thu, 30 Oct 2025 19:24:36 -0400 Subject: [PATCH] Add page to details about people (#119) Adds a page to show details about people (actors, directors, etc) when clicking on the person card wholphin_person --- .../damontecres/wholphin/ui/cards/ItemRow.kt | 6 +- .../wholphin/ui/components/LoadingRow.kt | 121 +++++ .../wholphin/ui/detail/ItemViewModel.kt | 19 +- .../wholphin/ui/detail/PersonPage.kt | 427 ++++++++++++++++++ .../wholphin/ui/detail/movie/MovieDetails.kt | 13 +- .../ui/detail/movie/MovieViewModel.kt | 9 +- .../ui/detail/series/SeriesDetails.kt | 11 + .../wholphin/ui/nav/DestinationContent.kt | 8 + .../damontecres/wholphin/util/Formatting.kt | 13 + .../damontecres/wholphin/util/LoadingState.kt | 28 +- 10 files changed, 635 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/LoadingRow.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index c600af76..46d38594 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -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() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/LoadingRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/LoadingRow.kt new file mode 100644 index 00000000..da4fa30d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/LoadingRow.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt index d639d6d3..8a15d2f9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt @@ -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 = + 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 } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt new file mode 100644 index 00000000..426af231 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -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.Pending) + val series = MutableLiveData(RowLoadingState.Pending) + val episodes = MutableLiveData(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, + ) { + 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(), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 134b2b1e..16c56b12 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -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 = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index bc2ce98d..d51d9523 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -52,10 +52,7 @@ class MovieViewModel val similar = MutableLiveData>() val chosenStreams = MutableLiveData(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 = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index e601303f..50733339 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -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 = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index bd88f329..72d3fdd0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -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}") diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Formatting.kt index 7bb3fbf5..9e9dfd41 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Formatting.kt @@ -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#` */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt index 77be5ffc..0e41469d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -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, + ) : 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(" - ") + } +}