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
---
.../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(" - ")
+ }
+}