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

View file

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

View file

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

View file

@ -1,21 +1,61 @@
package com.github.damontecres.dolphin.ui.detail 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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text 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.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.cards.ItemRow 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.ErrorMessage
import com.github.damontecres.dolphin.ui.components.LoadingPage 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.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager 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 com.github.damontecres.dolphin.util.LoadingState
import org.jellyfin.sdk.model.extensions.ticks
@Composable @Composable
fun SeriesDetails( fun SeriesDetails(
@ -28,25 +68,119 @@ fun SeriesDetails(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init(destination.itemId, destination.item, null, null) viewModel.init(destination.itemId, destination.item, null, null)
} }
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty()) 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) { 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 -> 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 { item {
Text(text = item.name ?: "Unknown") PersonRow(
} people = people,
item { onClick = {},
ItemRow( onLongClick = {},
title = "Seasons",
items = seasons.items,
onClickItem = { navigationManager.navigateTo(it.destination()) },
onLongClickItem = { },
modifier = Modifier.fillMaxWidth(), 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.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
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.hilt.AuthOkHttpClient 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.ApiRequestPager
import com.github.damontecres.dolphin.util.ExceptionHandler import com.github.damontecres.dolphin.util.ExceptionHandler
import com.github.damontecres.dolphin.util.GetEpisodesRequestHandler import com.github.damontecres.dolphin.util.GetEpisodesRequestHandler
@ -54,6 +56,7 @@ class SeriesViewModel
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val seasons = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty()) val seasons = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
val episodes = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty()) val episodes = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
val people = MutableLiveData<List<Person>>(listOf())
fun init( fun init(
itemId: UUID, itemId: UUID,
@ -80,6 +83,11 @@ class SeriesViewModel
seasons.value = seasonsInfo seasons.value = seasonsInfo
episodes.value = episodeInfo episodes.value = episodeInfo
loading.value = LoadingState.Success loading.value = LoadingState.Success
people.value =
item.data.people
?.letNotEmpty { people ->
people.map { Person.fromDto(it, api) }
}.orEmpty()
} }
maybePlayThemeSong() maybePlayThemeSong()
} else { } else {

View file

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

View file

@ -88,7 +88,7 @@ class MainViewModel
api.userLibraryApi api.userLibraryApi
.getLatestMedia(request) .getLatestMedia(request)
.content .content
.map { BaseItem.Companion.from(it, api, true) } .map { BaseItem.from(it, api, true) }
HomeRow( HomeRow(
section = section, section = section,
items = latest, 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.data.JellyfinUser
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import timber.log.Timber
@Composable @Composable
fun ApplicationContent( fun ApplicationContent(
@ -34,6 +35,7 @@ fun ApplicationContent(
entryProvider = { key -> entryProvider = { key ->
key as Destination key as Destination
val contentKey = "${key}_${server.id}_${user.id}" val contentKey = "${key}_${server.id}_${user.id}"
Timber.d("Navigate: %s", key)
NavEntry(key, contentKey = contentKey) { NavEntry(key, contentKey = contentKey) {
if (key.fullScreen) { if (key.fullScreen) {
DestinationContent( DestinationContent(

View file

@ -36,6 +36,14 @@ sealed class Destination(
@Serializable @Serializable
data object Search : Destination() 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 @Serializable
data class MediaItem( data class MediaItem(
val itemId: UUID, 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.CollectionFolderDetails
import com.github.damontecres.dolphin.ui.detail.EpisodeDetails import com.github.damontecres.dolphin.ui.detail.EpisodeDetails
import com.github.damontecres.dolphin.ui.detail.SeasonDetails 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.VideoDetails
import com.github.damontecres.dolphin.ui.detail.movie.MovieDetails import com.github.damontecres.dolphin.ui.detail.movie.MovieDetails
import com.github.damontecres.dolphin.ui.detail.series.SeriesOverview import com.github.damontecres.dolphin.ui.detail.series.SeriesOverview
@ -56,15 +57,23 @@ fun DestinationContent(
modifier, modifier,
) )
is Destination.SeriesOverview ->
SeriesOverview(
preferences,
navigationManager,
destination,
modifier,
initialSeasonEpisode = destination.seasonEpisode,
)
is Destination.MediaItem -> is Destination.MediaItem ->
when (destination.type) { when (destination.type) {
BaseItemKind.SERIES -> BaseItemKind.SERIES ->
SeriesOverview( SeriesDetails(
preferences, preferences,
navigationManager, navigationManager,
destination, destination,
modifier, modifier,
initialSeasonEpisode = destination.seasonEpisode,
) )
BaseItemKind.SEASON -> BaseItemKind.SEASON ->

View file

@ -6,7 +6,7 @@ import java.time.format.DateTimeFormatter
fun formatDateTime(dateTime: LocalDateTime): String = fun formatDateTime(dateTime: LocalDateTime): String =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 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) formatter.format(dateTime)
} else if (dateTime.toString().length >= 10) { } else if (dateTime.toString().length >= 10) {
dateTime.toString().substring(0, 10) dateTime.toString().substring(0, 10)