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

![Image](https://github.com/user-attachments/assets/9ca5c25b-5ba0-49d9-a680-6520804b0ace)
This commit is contained in:
Ray 2026-01-13 14:16:49 -05:00 committed by GitHub
parent 5cb3662995
commit 7c018f9a37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 189 additions and 50 deletions

View file

@ -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)

View file

@ -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?,

View file

@ -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(

View file

@ -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),
)
}
}

View file

@ -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

View file

@ -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]),
)
}
}

View file

@ -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]),
)
}
}