From 833e1d2ea19d2b6aa7ed741e424c63029eddc37e Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 4 Oct 2025 22:42:43 -0400 Subject: [PATCH] Use series details page --- .../dolphin/data/model/BaseItem.kt | 8 +- .../damontecres/dolphin/ui/detail/CardGrid.kt | 2 +- .../dolphin/ui/detail/ItemViewModel.kt | 20 +- .../dolphin/ui/detail/SeriesDetails.kt | 245 +++++++++++++++++- .../dolphin/ui/detail/SeriesViewModel.kt | 8 + .../ui/detail/series/SeriesOverview.kt | 2 +- .../dolphin/ui/main/MainViewModel.kt | 2 +- .../dolphin/ui/nav/ApplicationContent.kt | 2 + .../damontecres/dolphin/ui/nav/Destination.kt | 8 + .../dolphin/ui/nav/DestinationContent.kt | 13 +- .../damontecres/dolphin/util/Formatting.kt | 2 +- 11 files changed, 283 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt index 9f9ded53..bc5e2c9a 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt index 8547bbba..02443699 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt @@ -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() diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt index 9d8ede6d..94efdf41 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt @@ -30,15 +30,17 @@ abstract class ItemViewModel( 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) { diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt index 1492d375..2fbf8cc1 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt @@ -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, + 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), + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt index 08bbf0f0..6b7bdba3 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt @@ -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.Loading) val seasons = MutableLiveData(ItemListAndMapping.empty()) val episodes = MutableLiveData(ItemListAndMapping.empty()) + val people = MutableLiveData>(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 { diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt index 453e54e0..0003c308 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/main/MainViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/main/MainViewModel.kt index 65af67ac..8d6b851d 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/main/MainViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/main/MainViewModel.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt index a56acee0..16d707e4 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt @@ -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( diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt index 48453a0e..ddf3c27e 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt index 7ce7efd1..95651ea1 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt @@ -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 -> diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/Formatting.kt b/app/src/main/java/com/github/damontecres/dolphin/util/Formatting.kt index c47d9063..205aefd0 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/Formatting.kt @@ -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)