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.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
@ -39,6 +40,7 @@ fun ItemRow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
focusPair: FocusPair? = null, focusPair: FocusPair? = null,
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null, cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
horizontalPadding: Dp = 16.dp,
) { ) {
val state = rememberLazyListState() val state = rememberLazyListState()
val firstFocus = remember { FocusRequester() } val firstFocus = remember { FocusRequester() }
@ -54,8 +56,8 @@ fun ItemRow(
) )
LazyRow( LazyRow(
state = state, state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(horizontalPadding),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 8.dp),
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.BaseItem 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.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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
@ -66,14 +67,14 @@ abstract class LoadingItemViewModel(
open fun init( open fun init(
itemId: UUID, itemId: UUID,
potential: BaseItem?, potential: BaseItem?,
): Job? { ): Deferred<BaseItem?> =
loading.value = LoadingState.Loading viewModelScope.async(
return viewModelScope.launch(
LoadingExceptionHandler( LoadingExceptionHandler(
loading, loading,
"Error loading item $itemId", "Error loading item $itemId",
) + Dispatchers.IO, ) + Dispatchers.IO,
) { ) {
loading.setValueOnMain(LoadingState.Loading)
try { try {
fetchAndSetItem(itemId) fetchAndSetItem(itemId)
} catch (e: Exception) { } catch (e: Exception) {
@ -82,16 +83,18 @@ abstract class LoadingItemViewModel(
item.value = null item.value = null
loading.value = LoadingState.Error("Error loading item $itemId", e) 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) { withContext(Dispatchers.IO) {
val fetchedItem = api.userLibraryApi.getItem(itemId).content val fetchedItem = api.userLibraryApi.getItem(itemId).content
val item = BaseItem.from(fetchedItem, api)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
item.value = BaseItem.from(fetchedItem, api) this@LoadingItemViewModel.item.value = item
loading.value = LoadingState.Success 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.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
import kotlin.time.Duration import kotlin.time.Duration
@ -87,7 +88,7 @@ fun MovieDetails(
) { ) {
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init(destination.itemId, destination.item) viewModel.init(destination.itemId)
} }
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
val people by viewModel.people.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf())
@ -119,6 +120,14 @@ fun MovieDetails(
onClickItem = { onClickItem = {
viewModel.navigationManager.navigateTo(it.destination()) viewModel.navigationManager.navigateTo(it.destination())
}, },
onClickPerson = {
viewModel.navigationManager.navigateTo(
Destination.MediaItem(
it.id,
BaseItemKind.PERSON,
),
)
},
playOnClick = { playOnClick = {
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.Playback( Destination.Playback(
@ -266,6 +275,7 @@ fun MovieDetailsContent(
favoriteOnClick: () -> Unit, favoriteOnClick: () -> Unit,
moreOnClick: () -> Unit, moreOnClick: () -> Unit,
onClickItem: (BaseItem) -> Unit, onClickItem: (BaseItem) -> Unit,
onClickPerson: (Person) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -361,6 +371,7 @@ fun MovieDetailsContent(
people = people, people = people,
onClick = { onClick = {
position = PEOPLE_ROW position = PEOPLE_ROW
onClickPerson.invoke(it)
}, },
onLongClick = {}, onLongClick = {},
modifier = modifier =

View file

@ -52,10 +52,7 @@ class MovieViewModel
val similar = MutableLiveData<List<BaseItem>>() val similar = MutableLiveData<List<BaseItem>>()
val chosenStreams = MutableLiveData<ChosenStreams?>(null) val chosenStreams = MutableLiveData<ChosenStreams?>(null)
override fun init( fun init(itemId: UUID): Job? {
itemId: UUID,
potential: BaseItem?,
): Job? {
this.itemId = itemId this.itemId = itemId
return viewModelScope.launch( return viewModelScope.launch(
Dispatchers.IO + Dispatchers.IO +
@ -103,9 +100,9 @@ class MovieViewModel
people.value = people.value =
item.data.people item.data.people
?.letNotEmpty { people -> ?.letNotEmpty { people ->
people.map { Person.Companion.fromDto(it, api) } people.map { Person.fromDto(it, api) }
}.orEmpty() }.orEmpty()
chapters.value = Chapter.Companion.fromDto(item.data, api) chapters.value = Chapter.fromDto(item.data, api)
} }
if (!similar.isInitialized) { if (!similar.isInitialized) {
val similar = 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.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration import kotlin.time.Duration
@ -123,6 +124,14 @@ fun SeriesDetails(
favorite = item.data.userData?.isFavorite ?: false, favorite = item.data.userData?.isFavorite ?: false,
modifier = modifier, modifier = modifier,
onClickItem = { viewModel.navigateTo(it.destination()) }, onClickItem = { viewModel.navigateTo(it.destination()) },
onClickPerson = {
viewModel.navigateTo(
Destination.MediaItem(
it.id,
BaseItemKind.PERSON,
),
)
},
onLongClickItem = { season -> onLongClickItem = { season ->
seasonDialog = seasonDialog =
buildDialogForSeason( buildDialogForSeason(
@ -196,6 +205,7 @@ fun SeriesDetailsContent(
played: Boolean, played: Boolean,
favorite: Boolean, favorite: Boolean,
onClickItem: (BaseItem) -> Unit, onClickItem: (BaseItem) -> Unit,
onClickPerson: (Person) -> Unit,
onLongClickItem: (BaseItem) -> Unit, onLongClickItem: (BaseItem) -> Unit,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
playOnClick: () -> Unit, playOnClick: () -> Unit,
@ -361,6 +371,7 @@ fun SeriesDetailsContent(
people = people, people = people,
onClick = { onClick = {
position = PEOPLE_ROW position = PEOPLE_ROW
onClickPerson.invoke(it)
}, },
onLongClick = {}, onLongClick = {},
modifier = 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.CollectionFolderTv
import com.github.damontecres.wholphin.ui.detail.DebugPage import com.github.damontecres.wholphin.ui.detail.DebugPage
import com.github.damontecres.wholphin.ui.detail.FavoritesPage 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.PlaylistDetails
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
@ -173,6 +174,13 @@ fun DestinationContent(
modifier, modifier,
) )
BaseItemKind.PERSON ->
PersonPage(
preferences,
destination,
modifier,
)
else -> { else -> {
Timber.w("Unsupported item type: ${destination.type}") Timber.w("Unsupported item type: ${destination.type}")
Text("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.BaseItemDto
import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStreamType
import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
@ -32,6 +33,18 @@ fun formatDateTime(dateTime: LocalDateTime): String =
dateTime.toString() 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#` * If the item has season & episode info, format as `S# E#`
*/ */

View file

@ -1,14 +1,16 @@
package com.github.damontecres.wholphin.util package com.github.damontecres.wholphin.util
import com.github.damontecres.wholphin.data.model.BaseItem
/** /**
* Generic state for loading something from the API * Generic state for loading something from the API
*/ */
sealed interface LoadingState { 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( data class Error(
val message: String? = null, val message: String? = null,
@ -20,3 +22,23 @@ sealed interface LoadingState {
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") 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(" - ")
}
}