mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Use circular cards for people (#691)
## Description Clips person images to a circle. The rectangle is still used in grid pages like favorite people. This PR also updates the fallback image to use a person icon if there is no image for that person Also adds role/job to discover pages' people rows ### Related issues Closes #682 ### Screenshots 
This commit is contained in:
parent
5cb3662995
commit
7c018f9a37
7 changed files with 189 additions and 50 deletions
|
|
@ -184,7 +184,7 @@ data class DiscoverItem(
|
|||
id = credit.id!!,
|
||||
type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON),
|
||||
title = credit.name ?: credit.title,
|
||||
subtitle = null,
|
||||
subtitle = credit.character,
|
||||
overview = credit.overview,
|
||||
availability =
|
||||
SeerrAvailability.from(credit.mediaInfo?.status)
|
||||
|
|
@ -199,7 +199,7 @@ data class DiscoverItem(
|
|||
id = credit.id!!,
|
||||
type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON),
|
||||
title = credit.name ?: credit.title,
|
||||
subtitle = null,
|
||||
subtitle = credit.job,
|
||||
overview = credit.overview,
|
||||
availability =
|
||||
SeerrAvailability.from(credit.mediaInfo?.status)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
|
@ -7,6 +8,7 @@ import org.jellyfin.sdk.model.api.BaseItemPerson
|
|||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.PersonKind
|
||||
|
||||
@Stable
|
||||
data class Person(
|
||||
val id: UUID,
|
||||
val name: String?,
|
||||
|
|
|
|||
|
|
@ -113,6 +113,13 @@ fun ItemCardImage(
|
|||
modifier: Modifier = Modifier,
|
||||
useFallbackText: Boolean = true,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
fallback: @Composable BoxScope.() -> Unit = {
|
||||
ItemCardImageFallback(
|
||||
name = name,
|
||||
useFallbackText = useFallbackText,
|
||||
modifier = Modifier,
|
||||
)
|
||||
},
|
||||
) {
|
||||
var imageError by remember(imageUrl) { mutableStateOf(false) }
|
||||
Box(
|
||||
|
|
@ -134,11 +141,7 @@ fun ItemCardImage(
|
|||
.align(Alignment.TopCenter),
|
||||
)
|
||||
} else {
|
||||
ItemCardImageFallback(
|
||||
name = name,
|
||||
useFallbackText = useFallbackText,
|
||||
modifier = Modifier,
|
||||
)
|
||||
fallback.invoke(this)
|
||||
}
|
||||
if (showOverlay) {
|
||||
ItemCardImageOverlay(
|
||||
|
|
|
|||
|
|
@ -1,37 +1,77 @@
|
|||
package com.github.damontecres.wholphin.ui.cards
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.CardDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.Person
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.PersonKind
|
||||
|
||||
/**
|
||||
* A Card for a [Person] such as an actor or director
|
||||
*/
|
||||
@Composable
|
||||
fun PersonCard(
|
||||
item: Person,
|
||||
person: Person,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) = PersonCard(
|
||||
name = person.name,
|
||||
role = person.role,
|
||||
imageUrl = person.imageUrl,
|
||||
favorite = person.favorite,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier,
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PersonCard(
|
||||
name: String?,
|
||||
role: String?,
|
||||
imageUrl: String?,
|
||||
favorite: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -65,25 +105,62 @@ fun PersonCard(
|
|||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = interactionSource,
|
||||
shape = CardDefaults.shape(CircleShape),
|
||||
border =
|
||||
CardDefaults.border(
|
||||
focusedBorder =
|
||||
Border(
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 3.dp,
|
||||
color = MaterialTheme.colorScheme.border,
|
||||
),
|
||||
shape = CircleShape,
|
||||
),
|
||||
),
|
||||
colors =
|
||||
CardDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
) {
|
||||
ItemCardImage(
|
||||
imageUrl = item.imageUrl,
|
||||
name = item.name,
|
||||
showOverlay = true,
|
||||
favorite = item.favorite,
|
||||
imageUrl = imageUrl,
|
||||
name = name,
|
||||
showOverlay = false,
|
||||
favorite = favorite,
|
||||
watched = false,
|
||||
unwatchedCount = -1,
|
||||
numberOfVersions = -1,
|
||||
watchedPercent = null,
|
||||
useFallbackText = false,
|
||||
useFallbackText = true,
|
||||
contentScale = ContentScale.Crop,
|
||||
fallback = {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.fillMaxSize()
|
||||
.align(Alignment.Center),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_user),
|
||||
fontFamily = FontAwesome,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 64.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(8.dp)
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(AspectRatios.TALL), // TODO,
|
||||
.aspectRatio(AspectRatios.SQUARE)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
|
|
@ -94,7 +171,7 @@ fun PersonCard(
|
|||
.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = item.name ?: "",
|
||||
text = name ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
|
|
@ -103,9 +180,9 @@ fun PersonCard(
|
|||
.padding(horizontal = 4.dp)
|
||||
.enableMarquee(focusedAfterDelay),
|
||||
)
|
||||
item.role?.let {
|
||||
role?.let {
|
||||
Text(
|
||||
text = item.role,
|
||||
text = role,
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
|
|
@ -118,3 +195,24 @@ fun PersonCard(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewTvSpec
|
||||
@Composable
|
||||
private fun PersonCardPreview() {
|
||||
WholphinTheme {
|
||||
PersonCard(
|
||||
person =
|
||||
Person(
|
||||
id = UUID.randomUUID(),
|
||||
name = "John Smith",
|
||||
role = "Actor",
|
||||
type = PersonKind.ACTOR,
|
||||
imageUrl = null,
|
||||
favorite = false,
|
||||
),
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
modifier = Modifier.width(personRowCardWidth),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.Person
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
|
||||
|
|
@ -52,14 +53,14 @@ fun PersonRow(
|
|||
.fillMaxWidth()
|
||||
.focusRestorer(firstFocus),
|
||||
) {
|
||||
itemsIndexed(people) { index, item ->
|
||||
itemsIndexed(people) { index, person ->
|
||||
PersonCard(
|
||||
item = item,
|
||||
onClick = { onClick.invoke(item) },
|
||||
onLongClick = { onLongClick?.invoke(index, item) },
|
||||
person = person,
|
||||
onClick = { onClick.invoke(person) },
|
||||
onLongClick = { onLongClick?.invoke(index, person) },
|
||||
modifier =
|
||||
Modifier
|
||||
.width(120.dp)
|
||||
.width(personRowCardWidth)
|
||||
.ifElse(index == 0, Modifier.focusRequester(firstFocus))
|
||||
.animateItem(),
|
||||
)
|
||||
|
|
@ -67,3 +68,52 @@ fun PersonRow(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DiscoverPersonRow(
|
||||
people: List<DiscoverItem>,
|
||||
onClick: (DiscoverItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@StringRes title: Int = R.string.people,
|
||||
onLongClick: ((Int, DiscoverItem) -> Unit)? = null,
|
||||
) {
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
LazyRow(
|
||||
state = rememberLazyListState(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
.fillMaxWidth()
|
||||
.focusRestorer(firstFocus),
|
||||
) {
|
||||
itemsIndexed(people) { index, person ->
|
||||
PersonCard(
|
||||
name = person.title,
|
||||
role = person.subtitle,
|
||||
imageUrl = person.posterUrl,
|
||||
favorite = false,
|
||||
onClick = { onClick.invoke(person) },
|
||||
onLongClick = { onLongClick?.invoke(index, person) },
|
||||
modifier =
|
||||
Modifier
|
||||
.width(personRowCardWidth)
|
||||
.ifElse(index == 0, Modifier.focusRequester(firstFocus))
|
||||
.animateItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val personRowCardWidth = 108.dp
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import com.github.damontecres.wholphin.services.SeerrUserConfig
|
|||
import com.github.damontecres.wholphin.services.TrailerService
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
||||
import com.github.damontecres.wholphin.ui.cards.DiscoverPersonRow
|
||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
|
|
@ -60,12 +61,9 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
|
|||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -325,22 +323,17 @@ fun DiscoverMovieDetailsContent(
|
|||
}
|
||||
if (people.isNotEmpty()) {
|
||||
item {
|
||||
DiscoverRow(
|
||||
row =
|
||||
DiscoverRowData(
|
||||
stringResource(R.string.people),
|
||||
DataLoadingState.Success(people),
|
||||
),
|
||||
onClickItem = { index: Int, item: DiscoverItem ->
|
||||
DiscoverPersonRow(
|
||||
people = people,
|
||||
onClick = {
|
||||
position = PEOPLE_ROW
|
||||
onClickPerson.invoke(item)
|
||||
onClickPerson.invoke(it)
|
||||
},
|
||||
onLongClickItem = { index, person ->
|
||||
onLongClick = { index, person ->
|
||||
position = PEOPLE_ROW
|
||||
onLongClickPerson.invoke(index, person)
|
||||
},
|
||||
onCardFocus = {},
|
||||
focusRequester = focusRequesters[PEOPLE_ROW],
|
||||
modifier = Modifier.focusRequester(focusRequesters[PEOPLE_ROW]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
|
|||
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
||||
import com.github.damontecres.wholphin.services.TrailerService
|
||||
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
||||
import com.github.damontecres.wholphin.ui.cards.DiscoverPersonRow
|
||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
|
|
@ -59,14 +60,11 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
|
|||
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.discover.DiscoverRow
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -358,22 +356,17 @@ fun DiscoverSeriesDetailsContent(
|
|||
// }
|
||||
if (people.isNotEmpty()) {
|
||||
item {
|
||||
DiscoverRow(
|
||||
row =
|
||||
DiscoverRowData(
|
||||
stringResource(R.string.people),
|
||||
DataLoadingState.Success(people),
|
||||
),
|
||||
onClickItem = { index: Int, item: DiscoverItem ->
|
||||
DiscoverPersonRow(
|
||||
people = people,
|
||||
onClick = {
|
||||
position = PEOPLE_ROW
|
||||
onClickPerson.invoke(item)
|
||||
onClickPerson.invoke(it)
|
||||
},
|
||||
onLongClickItem = { index, person ->
|
||||
onLongClick = { index, person ->
|
||||
position = PEOPLE_ROW
|
||||
onLongClickPerson.invoke(index, person)
|
||||
},
|
||||
onCardFocus = {},
|
||||
focusRequester = focusRequesters[PEOPLE_ROW],
|
||||
modifier = Modifier.focusRequester(focusRequesters[PEOPLE_ROW]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue