Show chapters on movie details

This commit is contained in:
Damontecres 2025-10-03 20:49:54 -04:00
parent de36eb7072
commit 9a588f38b4
No known key found for this signature in database
6 changed files with 262 additions and 52 deletions

View file

@ -0,0 +1,36 @@
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.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration
data class Chapter(
val name: String?,
val position: Duration,
val imageUrl: String?,
) {
companion object {
fun fromDto(
dto: BaseItemDto,
api: ApiClient,
): List<Chapter> =
dto.chapters
?.mapIndexed { index, chapter ->
Chapter(
chapter.name,
chapter.startPositionTicks.ticks,
chapter.imageTag?.let {
api.imageApi.getItemImageUrl(
itemId = dto.id,
imageType = ImageType.CHAPTER,
tag = it,
imageIndex = index,
)
},
)
}.orEmpty()
}
}

View file

@ -0,0 +1,65 @@
package com.github.damontecres.dolphin.ui.cards
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.ui.AppColors
import kotlin.time.Duration
@Composable
fun ChapterCard(
name: String?,
position: Duration,
imageUrl: String?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
cardHeight: Dp = 140.dp,
aspectRatio: Float = 16f / 9,
onLongClick: (() -> Unit)? = null,
interactionSource: MutableInteractionSource? = null,
) {
Card(
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
) {
Box(modifier = Modifier.fillMaxSize()) {
AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
)
Column(
modifier =
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.background(AppColors.TransparentBlack50),
) {
name?.let {
Text(
text = it,
)
}
Text(
text = position.toString(),
)
}
}
}
}

View file

@ -0,0 +1,57 @@
package com.github.damontecres.dolphin.ui.cards
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.Chapter
@Composable
fun ChapterRow(
chapters: List<Chapter>,
onClick: (Chapter) -> Unit,
modifier: Modifier = Modifier,
onLongClick: ((Chapter) -> Unit)? = null,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = "Chapters",
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(chapters) { index, item ->
ChapterCard(
name = item.name,
position = item.position,
imageUrl = item.imageUrl,
onClick = { onClick(item) },
modifier = Modifier,
onLongClick = onLongClick?.let { { it(item) } },
)
}
}
}
}

View file

@ -0,0 +1,55 @@
package com.github.damontecres.dolphin.ui.cards
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.Person
@Composable
fun PersonRow(
people: List<Person>,
onClick: (Person) -> Unit,
modifier: Modifier = Modifier,
onLongClick: ((Person) -> Unit)? = null,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
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 = { onClick.invoke(item) },
onLongClick = { onLongClick?.invoke(item) },
modifier = Modifier,
)
}
}
}
}

View file

@ -5,12 +5,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.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.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.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -25,7 +23,6 @@ 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
@ -34,13 +31,14 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope 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.Chapter
import com.github.damontecres.dolphin.data.model.Person 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.cards.ChapterRow
import com.github.damontecres.dolphin.ui.cards.PersonRow
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
@ -68,6 +66,7 @@ class MovieViewModel
api: ApiClient, api: ApiClient,
) : LoadingItemViewModel<Video>(api) { ) : LoadingItemViewModel<Video>(api) {
val people = MutableLiveData<List<Person>>(listOf()) val people = MutableLiveData<List<Person>>(listOf())
val chapters = MutableLiveData<List<Chapter>>(listOf())
override fun init( override fun init(
itemId: UUID, itemId: UUID,
@ -80,6 +79,7 @@ class MovieViewModel
item.data.people?.letNotEmpty { people -> item.data.people?.letNotEmpty { people ->
people.map { Person.fromDto(it, api) } people.map { Person.fromDto(it, api) }
} }
chapters.value = Chapter.fromDto(item.data, api)
} }
} }
} }
@ -97,17 +97,28 @@ fun MovieDetails(
} }
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
val people by viewModel.people.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf())
val chapters by viewModel.chapters.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)
LoadingState.Loading -> LoadingPage() LoadingState.Loading -> LoadingPage()
LoadingState.Success -> { LoadingState.Success -> {
item?.let { item?.let { movie ->
MovieDetailsContent( MovieDetailsContent(
preferences = preferences, preferences = preferences,
navigationManager = navigationManager, navigationManager = navigationManager,
movie = it, movie = movie,
people = people, people = people,
chapters = chapters,
playOnClick = {
navigationManager.navigateTo(
Destination.Playback(
movie.id,
it.inWholeMilliseconds,
movie,
),
)
},
overviewOnClick = {}, overviewOnClick = {},
moreOnClick = {}, moreOnClick = {},
watchOnClick = {}, watchOnClick = {},
@ -124,6 +135,8 @@ fun MovieDetailsContent(
navigationManager: NavigationManager, navigationManager: NavigationManager,
movie: BaseItem, movie: BaseItem,
people: List<Person>, people: List<Person>,
chapters: List<Chapter>,
playOnClick: (Duration) -> Unit,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
watchOnClick: () -> Unit, watchOnClick: () -> Unit,
moreOnClick: () -> Unit, moreOnClick: () -> Unit,
@ -171,14 +184,17 @@ fun MovieDetailsContent(
) )
} }
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(32.dp), contentPadding = PaddingValues(32.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxSize(),
) { ) {
item { item {
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.bringIntoViewRequester(bringIntoViewRequester), modifier =
Modifier
.bringIntoViewRequester(bringIntoViewRequester)
.padding(bottom = 120.dp),
) { ) {
MovieDetailsHeader( MovieDetailsHeader(
movie = movie, movie = movie,
@ -190,15 +206,7 @@ fun MovieDetailsContent(
ExpandablePlayButtons( ExpandablePlayButtons(
resumePosition = resumePosition, resumePosition = resumePosition,
watched = dto.userData?.played ?: false, watched = dto.userData?.played ?: false,
playOnClick = { playOnClick = playOnClick,
navigationManager.navigateTo(
Destination.Playback(
movie.id,
it.inWholeMilliseconds,
movie,
),
)
},
moreOnClick = moreOnClick, moreOnClick = moreOnClick,
watchOnClick = watchOnClick, watchOnClick = watchOnClick,
buttonOnFocusChanged = { buttonOnFocusChanged = {
@ -214,38 +222,22 @@ fun MovieDetailsContent(
} }
if (people.isNotEmpty()) { if (people.isNotEmpty()) {
item { item {
Column( PersonRow(
verticalArrangement = Arrangement.spacedBy(8.dp), people = people,
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 = {}, onClick = {},
onLongClick = {}, onLongClick = {},
modifier = Modifier, modifier = Modifier.fillMaxWidth(),
) )
} }
} }
} if (chapters.isNotEmpty()) {
item {
ChapterRow(
chapters = chapters,
onClick = { playOnClick.invoke(it.position) },
onLongClick = {},
modifier = Modifier.fillMaxWidth(),
)
} }
} }
} }

View file

@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import com.github.damontecres.dolphin.data.model.Chapter
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.util.ExceptionHandler import com.github.damontecres.dolphin.util.ExceptionHandler
@ -80,6 +81,7 @@ class PlaybackViewModel
val subtitleStreams = MutableLiveData<List<SubtitleStream>>(listOf()) val subtitleStreams = MutableLiveData<List<SubtitleStream>>(listOf())
val currentPlayback = MutableLiveData<CurrentPlayback?>(null) val currentPlayback = MutableLiveData<CurrentPlayback?>(null)
val trickplay = MutableLiveData<TrickplayInfo?>(null) val trickplay = MutableLiveData<TrickplayInfo?>(null)
val chapters = MutableLiveData<List<Chapter>>(listOf())
private lateinit var deviceProfile: DeviceProfile private lateinit var deviceProfile: DeviceProfile
private lateinit var dto: BaseItemDto private lateinit var dto: BaseItemDto
@ -174,6 +176,9 @@ class PlaybackViewModel
if (destination.positionMs > 0) destination.positionMs else C.TIME_UNSET, if (destination.positionMs > 0) destination.positionMs else C.TIME_UNSET,
) )
player.prepare() player.prepare()
this@PlaybackViewModel.chapters.value = Chapter.fromDto(dto, api)
Timber.v("chapters=${this@PlaybackViewModel.chapters.value}")
} }
} }
} }