Use series details page

This commit is contained in:
Damontecres 2025-10-04 22:42:43 -04:00
parent 887dd7001c
commit 833e1d2ea1
No known key found for this signature in database
11 changed files with 283 additions and 29 deletions

View file

@ -25,14 +25,14 @@ data class BaseItem(
@Transient
val indexNumber = data.indexNumber
fun destination(): Destination.MediaItem {
fun destination(): Destination {
val result =
// Redirect episodes & seasons to their series if possible
when (type) {
BaseItemKind.EPISODE -> {
data.indexNumber?.let { episode ->
data.parentIndexNumber?.let { season ->
Destination.MediaItem(
Destination.SeriesOverview(
data.seriesId!!,
BaseItemKind.SERIES,
this,
@ -43,8 +43,8 @@ data class BaseItem(
}
BaseItemKind.SEASON ->
data.parentIndexNumber?.let { season ->
Destination.MediaItem(
data.indexNumber?.let { season ->
Destination.SeriesOverview(
data.seriesId!!,
BaseItemKind.SERIES,
this,

View file

@ -72,7 +72,7 @@ fun CardGrid(
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) {
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
val columns = 5
val columns = 6
val gridState = rememberLazyGridState()
val scope = rememberCoroutineScope()

View file

@ -30,15 +30,17 @@ abstract class ItemViewModel<T : DolphinModel>(
potential: BaseItem?,
): BaseItem? =
withContext(Dispatchers.IO) {
val fetchedItem =
when {
item.value == null && potential?.id == itemId -> potential
item.value?.id == itemId -> item.value
else -> {
val it = api.userLibraryApi.getItem(itemId).content
BaseItem.from(it, api)
}
}
// val fetchedItem =
// when {
// item.value == null && potential?.id == itemId -> potential
// item.value?.id == itemId -> item.value
// else -> {
// val it = api.userLibraryApi.getItem(itemId).content
// BaseItem.from(it, api)
// }
// }
val it = api.userLibraryApi.getItem(itemId).content
val fetchedItem = BaseItem.from(it, api)
return@withContext fetchedItem?.let {
val modelInstance = convertModel(fetchedItem.data, api)
withContext(Dispatchers.Main) {

View file

@ -1,21 +1,61 @@
package com.github.damontecres.dolphin.ui.detail
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
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.shape.RoundedCornerShape
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
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.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.cards.ItemRow
import com.github.damontecres.dolphin.ui.cards.PersonRow
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.components.StarRating
import com.github.damontecres.dolphin.ui.components.StarRatingPrecision
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.playOnClickSound
import com.github.damontecres.dolphin.ui.playSoundOnFocus
import com.github.damontecres.dolphin.ui.roundMinutes
import com.github.damontecres.dolphin.ui.tryRequestFocus
import com.github.damontecres.dolphin.util.LoadingState
import org.jellyfin.sdk.model.extensions.ticks
@Composable
fun SeriesDetails(
@ -28,25 +68,119 @@ fun SeriesDetails(
LaunchedEffect(Unit) {
viewModel.init(destination.itemId, destination.item, null, null)
}
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState()
val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty())
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val people by viewModel.people.observeAsState(listOf())
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading -> LoadingPage()
LoadingState.Success -> {
item?.let { item ->
LazyColumn(modifier = modifier) {
SeriesDetailsContent(
preferences = preferences,
navigationManager = navigationManager,
series = item,
seasons = seasons,
people = people,
modifier = modifier,
overviewOnClick = {}, // TODO
)
}
}
}
}
@Composable
fun SeriesDetailsContent(
preferences: UserPreferences,
navigationManager: NavigationManager,
series: BaseItem,
seasons: ItemListAndMapping,
people: List<Person>,
overviewOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val dto = series.data
val seasonsFocusRequester = remember { FocusRequester() }
if (seasons.items.isNotEmpty()) {
LaunchedEffect(Unit) {
seasonsFocusRequester.tryRequestFocus()
}
}
Box(
modifier = modifier,
) {
if (series.backdropImageUrl.isNotNullOrBlank()) {
val gradientColor = MaterialTheme.colorScheme.background
AsyncImage(
model = series.backdropImageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
alignment = Alignment.TopEnd,
modifier =
Modifier
.fillMaxHeight(.75f)
.alpha(.5f)
.drawWithContent {
drawContent()
drawRect(
Brush.verticalGradient(
colors = listOf(Color.Transparent, gradientColor),
startY = size.height * .5f,
),
)
drawRect(
Brush.horizontalGradient(
colors = listOf(Color.Transparent, gradientColor),
endX = 0f,
startX = size.width * .75f,
),
)
},
)
}
Column(
modifier =
Modifier
.padding(16.dp)
.fillMaxSize(),
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
item {
SeriesDetailsHeader(
series = series,
overviewOnClick = overviewOnClick,
modifier = Modifier.fillMaxWidth(.7f),
)
}
item {
ItemRow(
title = "Seasons",
items = seasons.items,
onClickItem = { navigationManager.navigateTo(it.destination()) },
onLongClickItem = { },
modifier =
Modifier
.fillMaxWidth()
.focusRequester(seasonsFocusRequester),
)
}
if (people.isNotEmpty()) {
item {
Text(text = item.name ?: "Unknown")
}
item {
ItemRow(
title = "Seasons",
items = seasons.items,
onClickItem = { navigationManager.navigateTo(it.destination()) },
onLongClickItem = { },
PersonRow(
people = people,
onClick = {},
onLongClick = {},
modifier = Modifier.fillMaxWidth(),
)
}
@ -55,3 +189,94 @@ fun SeriesDetails(
}
}
}
@Composable
fun SeriesDetailsHeader(
series: BaseItem,
overviewOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val dto = series.data
val details =
buildList {
dto.productionYear?.let { add(it.toString()) }
dto.runTimeTicks
?.ticks
?.roundMinutes
?.let { add(it.toString()) }
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = series.name ?: "Unknown",
style = MaterialTheme.typography.displayMedium,
modifier = Modifier.fillMaxWidth(),
)
DotSeparatedRow(
texts = details,
textStyle = MaterialTheme.typography.headlineSmall,
)
dto.genres?.letNotEmpty {
Text(
text = it.joinToString(", "),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier,
)
}
dto.communityRating?.let {
if (it >= 0f) {
StarRating(
rating100 = (it * 10).toInt(),
onRatingChange = {},
enabled = false,
precision = StarRatingPrecision.HALF,
playSoundOnFocus = true,
modifier = Modifier.height(32.dp),
)
}
}
dto.overview?.let { overview ->
val interactionSource = remember { MutableInteractionSource() }
val isFocused = interactionSource.collectIsFocusedAsState().value
val bgColor =
if (isFocused) {
MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f)
} else {
Color.Unspecified
}
Box(
modifier =
Modifier
.background(bgColor, shape = RoundedCornerShape(8.dp))
.playSoundOnFocus(true)
.clickable(
enabled = true,
interactionSource = interactionSource,
indication = LocalIndication.current,
) {
playOnClickSound(context)
overviewOnClick.invoke()
},
) {
Text(
text = overview,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.padding(8.dp)
.height(60.dp),
)
}
}
}
}

View file

@ -11,8 +11,10 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
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.hilt.AuthOkHttpClient
import com.github.damontecres.dolphin.ui.letNotEmpty
import com.github.damontecres.dolphin.util.ApiRequestPager
import com.github.damontecres.dolphin.util.ExceptionHandler
import com.github.damontecres.dolphin.util.GetEpisodesRequestHandler
@ -54,6 +56,7 @@ class SeriesViewModel
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val seasons = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
val episodes = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
val people = MutableLiveData<List<Person>>(listOf())
fun init(
itemId: UUID,
@ -80,6 +83,11 @@ class SeriesViewModel
seasons.value = seasonsInfo
episodes.value = episodeInfo
loading.value = LoadingState.Success
people.value =
item.data.people
?.letNotEmpty { people ->
people.map { Person.fromDto(it, api) }
}.orEmpty()
}
maybePlayThemeSong()
} else {

View file

@ -46,7 +46,7 @@ data class SeriesOverviewPosition(
fun SeriesOverview(
preferences: UserPreferences,
navigationManager: NavigationManager,
destination: Destination.MediaItem,
destination: Destination.SeriesOverview,
modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel(),
initialSeasonEpisode: SeasonEpisode? = null,

View file

@ -88,7 +88,7 @@ class MainViewModel
api.userLibraryApi
.getLatestMedia(request)
.content
.map { BaseItem.Companion.from(it, api, true) }
.map { BaseItem.from(it, api, true) }
HomeRow(
section = section,
items = latest,

View file

@ -12,6 +12,7 @@ import com.github.damontecres.dolphin.data.JellyfinServer
import com.github.damontecres.dolphin.data.JellyfinUser
import com.github.damontecres.dolphin.preferences.UserPreferences
import org.jellyfin.sdk.model.api.DeviceProfile
import timber.log.Timber
@Composable
fun ApplicationContent(
@ -34,6 +35,7 @@ fun ApplicationContent(
entryProvider = { key ->
key as Destination
val contentKey = "${key}_${server.id}_${user.id}"
Timber.d("Navigate: %s", key)
NavEntry(key, contentKey = contentKey) {
if (key.fullScreen) {
DestinationContent(

View file

@ -36,6 +36,14 @@ sealed class Destination(
@Serializable
data object Search : Destination()
@Serializable
data class SeriesOverview(
val itemId: UUID,
val type: BaseItemKind,
@Transient val item: BaseItem? = null,
val seasonEpisode: SeasonEpisode? = null,
) : Destination()
@Serializable
data class MediaItem(
val itemId: UUID,

View file

@ -8,6 +8,7 @@ import com.github.damontecres.dolphin.ui.components.LicenseInfo
import com.github.damontecres.dolphin.ui.detail.CollectionFolderDetails
import com.github.damontecres.dolphin.ui.detail.EpisodeDetails
import com.github.damontecres.dolphin.ui.detail.SeasonDetails
import com.github.damontecres.dolphin.ui.detail.SeriesDetails
import com.github.damontecres.dolphin.ui.detail.VideoDetails
import com.github.damontecres.dolphin.ui.detail.movie.MovieDetails
import com.github.damontecres.dolphin.ui.detail.series.SeriesOverview
@ -56,15 +57,23 @@ fun DestinationContent(
modifier,
)
is Destination.SeriesOverview ->
SeriesOverview(
preferences,
navigationManager,
destination,
modifier,
initialSeasonEpisode = destination.seasonEpisode,
)
is Destination.MediaItem ->
when (destination.type) {
BaseItemKind.SERIES ->
SeriesOverview(
SeriesDetails(
preferences,
navigationManager,
destination,
modifier,
initialSeasonEpisode = destination.seasonEpisode,
)
BaseItemKind.SEASON ->

View file

@ -6,7 +6,7 @@ import java.time.format.DateTimeFormatter
fun formatDateTime(dateTime: LocalDateTime): String =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val formatter = DateTimeFormatter.ofPattern("MMMM d, yyyy")
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy")
formatter.format(dateTime)
} else if (dateTime.toString().length >= 10) {
dateTime.toString().substring(0, 10)