mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
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:
parent
90f4ca7306
commit
982506555f
10 changed files with 635 additions and 20 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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#`
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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(" - ")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue