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, onLongClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
cardContent: @Composable ( cardContent: @Composable (
index: Int,
item: BaseItem?, item: BaseItem?,
modifier: Modifier, modifier: Modifier,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
) -> Unit = { item, modifier, onClick, onLongClick -> ) -> Unit = { index, item, modifier, onClick, onLongClick ->
ItemCard( ItemCard(
item = item, item = item,
onClick = onClick, onClick = onClick,
@ -86,6 +87,7 @@ fun ItemRow(
cardOnFocus?.invoke(it.isFocused, index) cardOnFocus?.invoke(it.isFocused, index)
} }
cardContent.invoke( cardContent.invoke(
index,
item, item,
cardModifier, cardModifier,
{ if (item != null) onClickItem.invoke(item) }, { if (item != null) onClickItem.invoke(item) },
@ -112,7 +114,7 @@ fun BannerItemRow(
onClickItem = onClickItem, onClickItem = onClickItem,
onLongClickItem = onLongClickItem, onLongClickItem = onLongClickItem,
modifier = modifier, modifier = modifier,
cardContent = { item, modifier, onClick, onLongClick -> cardContent = { index, item, modifier, onClick, onLongClick ->
BannerCard( BannerCard(
imageUrl = item?.imageUrl, imageUrl = item?.imageUrl,
aspectRatio = 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) { if (resumePosition > Duration.ZERO) {
item { item {
// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
ExpandablePlayButton( ExpandablePlayButton(
R.string.resume, R.string.resume,
resumePosition, resumePosition,
@ -170,6 +169,7 @@ fun ExpandablePlayButtons(
) )
} }
} }
// Watched button // Watched button
item { item {
ExpandableFaButton( 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn 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.foundation.relocation.bringIntoViewRequester
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
@ -19,30 +25,39 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.data.model.BaseItem 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.data.model.Video
import com.github.damontecres.dolphin.preferences.UserPreferences 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.ErrorMessage
import com.github.damontecres.dolphin.ui.components.ExpandablePlayButtons import com.github.damontecres.dolphin.ui.components.ExpandablePlayButtons
import com.github.damontecres.dolphin.ui.components.LoadingPage import com.github.damontecres.dolphin.ui.components.LoadingPage
import com.github.damontecres.dolphin.ui.detail.LoadingItemViewModel import com.github.damontecres.dolphin.ui.detail.LoadingItemViewModel
import com.github.damontecres.dolphin.ui.isNotNullOrBlank 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.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.ui.tryRequestFocus
import com.github.damontecres.dolphin.util.ExceptionHandler import com.github.damontecres.dolphin.util.ExceptionHandler
import com.github.damontecres.dolphin.util.LoadingState import com.github.damontecres.dolphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration import kotlin.time.Duration
@ -51,7 +66,23 @@ class MovieViewModel
@Inject @Inject
constructor( constructor(
api: ApiClient, 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 @Composable
fun MovieDetails( fun MovieDetails(
@ -65,6 +96,7 @@ fun MovieDetails(
viewModel.init(destination.itemId, destination.item) viewModel.init(destination.itemId, destination.item)
} }
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
val people by viewModel.people.observeAsState(listOf())
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)
@ -72,10 +104,14 @@ fun MovieDetails(
LoadingState.Success -> { LoadingState.Success -> {
item?.let { item?.let {
MovieDetailsContent( MovieDetailsContent(
preferences, preferences = preferences,
navigationManager, navigationManager = navigationManager,
it, movie = it,
modifier, people = people,
overviewOnClick = {},
moreOnClick = {},
watchOnClick = {},
modifier = modifier,
) )
} }
} }
@ -87,6 +123,10 @@ fun MovieDetailsContent(
preferences: UserPreferences, preferences: UserPreferences,
navigationManager: NavigationManager, navigationManager: NavigationManager,
movie: BaseItem, movie: BaseItem,
people: List<Person>,
overviewOnClick: () -> Unit,
watchOnClick: () -> Unit,
moreOnClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -97,7 +137,7 @@ fun MovieDetailsContent(
val bringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
bringIntoViewRequester.bringIntoView() // bringIntoViewRequester.bringIntoView()
focusRequester.tryRequestFocus() focusRequester.tryRequestFocus()
} }
Box(modifier = modifier) { Box(modifier = modifier) {
@ -136,40 +176,77 @@ fun MovieDetailsContent(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
item { item {
MovieDetailsHeader( Column(
movie = movie, verticalArrangement = Arrangement.spacedBy(8.dp),
bringIntoViewRequester = bringIntoViewRequester, modifier = Modifier.bringIntoViewRequester(bringIntoViewRequester),
overviewOnClick = {}, ) {
Modifier MovieDetailsHeader(
.fillMaxWidth(.7f), movie = movie,
) bringIntoViewRequester = bringIntoViewRequester,
overviewOnClick = overviewOnClick,
Modifier
.fillMaxWidth(.7f),
)
ExpandablePlayButtons(
resumePosition = resumePosition,
watched = dto.userData?.played ?: false,
playOnClick = {
navigationManager.navigateTo(
Destination.Playback(
movie.id,
it.inWholeMilliseconds,
movie,
),
)
},
moreOnClick = moreOnClick,
watchOnClick = watchOnClick,
buttonOnFocusChanged = {
if (it.isFocused) {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
modifier = Modifier.focusRequester(focusRequester),
)
}
} }
item { if (people.isNotEmpty()) {
ExpandablePlayButtons( item {
resumePosition = resumePosition, Column(
watched = dto.userData?.played ?: false, verticalArrangement = Arrangement.spacedBy(8.dp),
playOnClick = { modifier =
navigationManager.navigateTo( Modifier
Destination.Playback( .fillMaxWidth()
movie.id, .padding(top = 120.dp),
it.inWholeMilliseconds, ) {
movie, Text(
), text = "People",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
) )
}, LazyRow(
moreOnClick = { state = rememberLazyListState(),
// TODO horizontalArrangement = Arrangement.spacedBy(16.dp),
}, contentPadding = PaddingValues(8.dp),
watchOnClick = { modifier =
// TODO Modifier
}, .padding(start = 16.dp)
buttonOnFocusChanged = { .fillMaxWidth()
scope.launch(ExceptionHandler()) { .focusRestorer(),
bringIntoViewRequester.bringIntoView() ) {
itemsIndexed(people) { index, item ->
PersonCard(
item = item,
onClick = {},
onLongClick = {},
modifier = Modifier,
)
}
} }
}, }
modifier = Modifier.focusRequester(focusRequester), }
)
} }
} }
} }

View file

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

View file

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