Add people to movie details

This commit is contained in:
Damontecres 2025-10-03 13:54:16 -04:00
parent 0595c7174c
commit ba92001412
No known key found for this signature in database
8 changed files with 286 additions and 50 deletions

View file

@ -0,0 +1,30 @@
package com.github.damontecres.dolphin.data.model
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.PersonKind
data class Person(
val id: UUID,
val name: String?,
val role: String?,
val type: PersonKind,
val imageUrl: String?,
) {
companion object {
fun fromDto(
dto: BaseItemPerson,
api: ApiClient,
): Person =
Person(
id = dto.id,
name = dto.name,
role = dto.role,
type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
)
}
}

View file

@ -32,11 +32,12 @@ fun ItemRow(
onLongClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
cardContent: @Composable (
index: Int,
item: BaseItem?,
modifier: Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit,
) -> Unit = { item, modifier, onClick, onLongClick ->
) -> Unit = { index, item, modifier, onClick, onLongClick ->
ItemCard(
item = item,
onClick = onClick,
@ -86,6 +87,7 @@ fun ItemRow(
cardOnFocus?.invoke(it.isFocused, index)
}
cardContent.invoke(
index,
item,
cardModifier,
{ if (item != null) onClickItem.invoke(item) },
@ -112,7 +114,7 @@ fun BannerItemRow(
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
modifier = modifier,
cardContent = { item, modifier, onClick, onLongClick ->
cardContent = { index, item, modifier, onClick, onLongClick ->
BannerCard(
imageUrl = item?.imageUrl,
aspectRatio =

View file

@ -0,0 +1,94 @@
package com.github.damontecres.dolphin.ui.cards
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
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.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.Person
import com.github.damontecres.dolphin.ui.enableMarquee
import kotlinx.coroutines.delay
@Composable
fun PersonCard(
item: Person,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
cardWidth: Dp = 150.dp,
cardHeight: Dp = 200.dp,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val hideOverlayDelay = 1_000L
val focused = interactionSource.collectIsFocusedAsState().value
var focusedAfterDelay by remember { mutableStateOf(false) }
if (focused) {
LaunchedEffect(Unit) {
delay(hideOverlayDelay)
if (focused) {
focusedAfterDelay = true
} else {
focusedAfterDelay = false
}
}
} else {
focusedAfterDelay = false
}
Card(
modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.width(cardWidth),
) {
ItemCardImage(
imageUrl = item.imageUrl,
name = item.name,
showOverlay = true,
favorite = false,
watched = false,
unwatchedCount = -1,
watchedPercent = null,
modifier =
Modifier
.fillMaxWidth()
.height(cardHeight),
)
Text(
text = item.name ?: "",
maxLines = 1,
modifier =
Modifier
.enableMarquee(focusedAfterDelay),
)
item.role?.let {
Text(
text = item.role,
maxLines = 1,
modifier =
Modifier
.enableMarquee(focusedAfterDelay),
)
}
}
}
}

View file

@ -137,7 +137,6 @@ fun ExpandablePlayButtons(
) {
if (resumePosition > Duration.ZERO) {
item {
// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
ExpandablePlayButton(
R.string.resume,
resumePosition,
@ -170,6 +169,7 @@ fun ExpandablePlayButtons(
)
}
}
// Watched button
item {
ExpandableFaButton(

View file

@ -0,0 +1,16 @@
package com.github.damontecres.dolphin.ui.data
import androidx.compose.runtime.saveable.Saver
import kotlinx.serialization.Serializable
@Serializable
data class RowColumn(
val row: Int,
val column: Int,
)
val RowColumnSaver =
Saver<RowColumn, List<Int>>(
save = { listOf(it.row, it.column) },
restore = { RowColumn(it[0], it[1]) },
)

View file

@ -2,11 +2,17 @@ package com.github.damontecres.dolphin.ui.detail.movie
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -19,30 +25,39 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
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 coil3.compose.AsyncImage
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.Person
import com.github.damontecres.dolphin.data.model.Video
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.cards.PersonCard
import com.github.damontecres.dolphin.ui.components.ErrorMessage
import com.github.damontecres.dolphin.ui.components.ExpandablePlayButtons
import com.github.damontecres.dolphin.ui.components.LoadingPage
import com.github.damontecres.dolphin.ui.detail.LoadingItemViewModel
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.letNotEmpty
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.ui.tryRequestFocus
import com.github.damontecres.dolphin.util.ExceptionHandler
import com.github.damontecres.dolphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration
@ -51,7 +66,23 @@ class MovieViewModel
@Inject
constructor(
api: ApiClient,
) : LoadingItemViewModel<Video>(api)
) : LoadingItemViewModel<Video>(api) {
val people = MutableLiveData<List<Person>>(listOf())
override fun init(
itemId: UUID,
potential: BaseItem?,
): Job? =
viewModelScope.launch(ExceptionHandler()) {
super.init(itemId, potential)?.join()
item.value?.let { item ->
people.value =
item.data.people?.letNotEmpty { people ->
people.map { Person.fromDto(it, api) }
}
}
}
}
@Composable
fun MovieDetails(
@ -65,6 +96,7 @@ fun MovieDetails(
viewModel.init(destination.itemId, destination.item)
}
val item by viewModel.item.observeAsState()
val people by viewModel.people.observeAsState(listOf())
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
@ -72,10 +104,14 @@ fun MovieDetails(
LoadingState.Success -> {
item?.let {
MovieDetailsContent(
preferences,
navigationManager,
it,
modifier,
preferences = preferences,
navigationManager = navigationManager,
movie = it,
people = people,
overviewOnClick = {},
moreOnClick = {},
watchOnClick = {},
modifier = modifier,
)
}
}
@ -87,6 +123,10 @@ fun MovieDetailsContent(
preferences: UserPreferences,
navigationManager: NavigationManager,
movie: BaseItem,
people: List<Person>,
overviewOnClick: () -> Unit,
watchOnClick: () -> Unit,
moreOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
@ -97,7 +137,7 @@ fun MovieDetailsContent(
val bringIntoViewRequester = remember { BringIntoViewRequester() }
LaunchedEffect(Unit) {
bringIntoViewRequester.bringIntoView()
// bringIntoViewRequester.bringIntoView()
focusRequester.tryRequestFocus()
}
Box(modifier = modifier) {
@ -136,15 +176,17 @@ fun MovieDetailsContent(
modifier = Modifier.fillMaxWidth(),
) {
item {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.bringIntoViewRequester(bringIntoViewRequester),
) {
MovieDetailsHeader(
movie = movie,
bringIntoViewRequester = bringIntoViewRequester,
overviewOnClick = {},
overviewOnClick = overviewOnClick,
Modifier
.fillMaxWidth(.7f),
)
}
item {
ExpandablePlayButtons(
resumePosition = resumePosition,
watched = dto.userData?.played ?: false,
@ -157,20 +199,55 @@ fun MovieDetailsContent(
),
)
},
moreOnClick = {
// TODO
},
watchOnClick = {
// TODO
},
moreOnClick = moreOnClick,
watchOnClick = watchOnClick,
buttonOnFocusChanged = {
if (it.isFocused) {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
modifier = Modifier.focusRequester(focusRequester),
)
}
}
if (people.isNotEmpty()) {
item {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(top = 120.dp),
) {
Text(
text = "People",
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(),
) {
itemsIndexed(people) { index, item ->
PersonCard(
item = item,
onClick = {},
onLongClick = {},
modifier = Modifier,
)
}
}
}
}
}
}
}
}

View file

@ -11,13 +11,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -44,6 +45,8 @@ import com.github.damontecres.dolphin.ui.cards.ItemRow
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
import com.github.damontecres.dolphin.ui.components.ErrorMessage
import com.github.damontecres.dolphin.ui.components.LoadingPage
import com.github.damontecres.dolphin.ui.data.RowColumn
import com.github.damontecres.dolphin.ui.data.RowColumnSaver
import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.nav.NavigationManager
@ -90,6 +93,10 @@ fun MainPageContent(
homeRows: List<HomeRow>,
modifier: Modifier = Modifier,
) {
var position by rememberSaveable(stateSaver = RowColumnSaver) {
mutableStateOf(RowColumn(0, 0))
}
// TODO use position instead?
var focusedItem by remember {
mutableStateOf<BaseItem?>(
homeRows.getOrNull(0)?.items?.getOrNull(
@ -98,7 +105,10 @@ fun MainPageContent(
)
}
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { if (focusedItem != null) focusRequester.tryRequestFocus() }
val positionFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
positionFocusRequester.tryRequestFocus()
}
Box(modifier = modifier) {
if (focusedItem?.backdropImageUrl.isNotNullOrBlank()) {
val gradientColor = MaterialTheme.colorScheme.background
@ -152,7 +162,7 @@ fun MainPageContent(
),
modifier = Modifier,
) {
items(homeRows) { row ->
itemsIndexed(homeRows) { rowIndex, row ->
ItemRow(
title = row.title ?: stringResource(row.section.nameRes),
items = row.items,
@ -160,11 +170,14 @@ fun MainPageContent(
navigationManager.navigateTo(it.destination())
},
cardOnFocus = { isFocused, index ->
if (isFocused) {
focusedItem = row.items.getOrNull(index)
position = RowColumn(rowIndex, index)
}
},
onLongClickItem = {},
modifier = Modifier.fillMaxWidth(),
cardContent = { item, modifier, onClick, onLongClick ->
cardContent = { index, item, modifier, onClick, onLongClick ->
// TODO better aspect ration handling?
BannerCard(
imageUrl = item?.imageUrl,
@ -175,9 +188,13 @@ fun MainPageContent(
onClick = onClick,
onLongClick = onLongClick,
modifier =
modifier.ifElse(
modifier
.ifElse(
focusedItem == item,
Modifier.focusRequester(focusRequester),
).ifElse(
RowColumn(rowIndex, index) == position,
Modifier.focusRequester(positionFocusRequester),
),
interactionSource = null,
cardHeight = 200.dp,

View file

@ -67,7 +67,7 @@ private val darkScheme =
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
border = inversePrimaryDark.copy(alpha = .5f),
border = inversePrimaryDark.copy(alpha = .75f),
)
@Immutable