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!!, id = credit.id!!,
type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON),
title = credit.name ?: credit.title, title = credit.name ?: credit.title,
subtitle = null, subtitle = credit.character,
overview = credit.overview, overview = credit.overview,
availability = availability =
SeerrAvailability.from(credit.mediaInfo?.status) SeerrAvailability.from(credit.mediaInfo?.status)
@ -199,7 +199,7 @@ data class DiscoverItem(
id = credit.id!!, id = credit.id!!,
type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON),
title = credit.name ?: credit.title, title = credit.name ?: credit.title,
subtitle = null, subtitle = credit.job,
overview = credit.overview, overview = credit.overview,
availability = availability =
SeerrAvailability.from(credit.mediaInfo?.status) SeerrAvailability.from(credit.mediaInfo?.status)

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.data.model 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.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.UUID 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.ImageType
import org.jellyfin.sdk.model.api.PersonKind import org.jellyfin.sdk.model.api.PersonKind
@Stable
data class Person( data class Person(
val id: UUID, val id: UUID,
val name: String?, val name: String?,

View file

@ -113,6 +113,13 @@ fun ItemCardImage(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
useFallbackText: Boolean = true, useFallbackText: Boolean = true,
contentScale: ContentScale = ContentScale.Fit, contentScale: ContentScale = ContentScale.Fit,
fallback: @Composable BoxScope.() -> Unit = {
ItemCardImageFallback(
name = name,
useFallbackText = useFallbackText,
modifier = Modifier,
)
},
) { ) {
var imageError by remember(imageUrl) { mutableStateOf(false) } var imageError by remember(imageUrl) { mutableStateOf(false) }
Box( Box(
@ -134,11 +141,7 @@ fun ItemCardImage(
.align(Alignment.TopCenter), .align(Alignment.TopCenter),
) )
} else { } else {
ItemCardImageFallback( fallback.invoke(this)
name = name,
useFallbackText = useFallbackText,
modifier = Modifier,
)
} }
if (showOverlay) { if (showOverlay) {
ItemCardImageOverlay( ItemCardImageOverlay(

View file

@ -1,37 +1,77 @@
package com.github.damontecres.wholphin.ui.cards package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState 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.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.text.style.TextAlign
import androidx.compose.ui.unit.dp 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.Card
import androidx.tv.material3.CardDefaults import androidx.tv.material3.CardDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.ui.AspectRatios 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.enableMarquee
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import kotlinx.coroutines.delay 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 * A Card for a [Person] such as an actor or director
*/ */
@Composable @Composable
fun PersonCard( 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, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -65,25 +105,62 @@ fun PersonCard(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
interactionSource = interactionSource, interactionSource = interactionSource,
shape = CardDefaults.shape(CircleShape),
border =
CardDefaults.border(
focusedBorder =
Border(
border =
BorderStroke(
width = 3.dp,
color = MaterialTheme.colorScheme.border,
),
shape = CircleShape,
),
),
colors = colors =
CardDefaults.colors( CardDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) { ) {
ItemCardImage( ItemCardImage(
imageUrl = item.imageUrl, imageUrl = imageUrl,
name = item.name, name = name,
showOverlay = true, showOverlay = false,
favorite = item.favorite, favorite = favorite,
watched = false, watched = false,
unwatchedCount = -1, unwatchedCount = -1,
numberOfVersions = -1, numberOfVersions = -1,
watchedPercent = null, 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 =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(AspectRatios.TALL), // TODO, .aspectRatio(AspectRatios.SQUARE)
.clip(CircleShape),
) )
} }
Column( Column(
@ -94,7 +171,7 @@ fun PersonCard(
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
Text( Text(
text = item.name ?: "", text = name ?: "",
maxLines = 1, maxLines = 1,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = modifier =
@ -103,9 +180,9 @@ fun PersonCard(
.padding(horizontal = 4.dp) .padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay), .enableMarquee(focusedAfterDelay),
) )
item.role?.let { role?.let {
Text( Text(
text = item.role, text = role,
maxLines = 1, maxLines = 1,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = 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.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R 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.data.model.Person
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
@ -52,14 +53,14 @@ fun PersonRow(
.fillMaxWidth() .fillMaxWidth()
.focusRestorer(firstFocus), .focusRestorer(firstFocus),
) { ) {
itemsIndexed(people) { index, item -> itemsIndexed(people) { index, person ->
PersonCard( PersonCard(
item = item, person = person,
onClick = { onClick.invoke(item) }, onClick = { onClick.invoke(person) },
onLongClick = { onLongClick?.invoke(index, item) }, onLongClick = { onLongClick?.invoke(index, person) },
modifier = modifier =
Modifier Modifier
.width(120.dp) .width(personRowCardWidth)
.ifElse(index == 0, Modifier.focusRequester(firstFocus)) .ifElse(index == 0, Modifier.focusRequester(firstFocus))
.animateItem(), .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.services.TrailerService
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard 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.ItemRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.DialogItem 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.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions 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.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus 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.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -325,22 +323,17 @@ fun DiscoverMovieDetailsContent(
} }
if (people.isNotEmpty()) { if (people.isNotEmpty()) {
item { item {
DiscoverRow( DiscoverPersonRow(
row = people = people,
DiscoverRowData( onClick = {
stringResource(R.string.people),
DataLoadingState.Success(people),
),
onClickItem = { index: Int, item: DiscoverItem ->
position = PEOPLE_ROW position = PEOPLE_ROW
onClickPerson.invoke(item) onClickPerson.invoke(it)
}, },
onLongClickItem = { index, person -> onLongClick = { index, person ->
position = PEOPLE_ROW position = PEOPLE_ROW
onLongClickPerson.invoke(index, person) onLongClickPerson.invoke(index, person)
}, },
onCardFocus = {}, modifier = Modifier.focusRequester(focusRequesters[PEOPLE_ROW]),
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.SeerrUserConfig
import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard 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.ItemRow
import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams 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.components.OverviewText
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo 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.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.tryRequestFocus 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.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -358,22 +356,17 @@ fun DiscoverSeriesDetailsContent(
// } // }
if (people.isNotEmpty()) { if (people.isNotEmpty()) {
item { item {
DiscoverRow( DiscoverPersonRow(
row = people = people,
DiscoverRowData( onClick = {
stringResource(R.string.people),
DataLoadingState.Success(people),
),
onClickItem = { index: Int, item: DiscoverItem ->
position = PEOPLE_ROW position = PEOPLE_ROW
onClickPerson.invoke(item) onClickPerson.invoke(it)
}, },
onLongClickItem = { index, person -> onLongClick = { index, person ->
position = PEOPLE_ROW position = PEOPLE_ROW
onLongClickPerson.invoke(index, person) onLongClickPerson.invoke(index, person)
}, },
onCardFocus = {}, modifier = Modifier.focusRequester(focusRequesters[PEOPLE_ROW]),
focusRequester = focusRequesters[PEOPLE_ROW],
) )
} }
} }