From 31b4881d7ca0ab5f476e6bd95b5804dde2675a79 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 30 Sep 2025 11:08:30 -0400 Subject: [PATCH] Move files --- .../dolphin/data/model/BaseItem.kt | 2 +- .../dolphin/ui/cards/BannerCard.kt | 80 +++ .../ui/components/details/SeriesDetailPage.kt | 543 ------------------ .../ui/detail/series/FocusedEpisodeFooter.kt | 159 +++++ .../ui/detail/series/FocusedEpisodeHeader.kt | 127 ++++ .../ui/detail/series/SeriesOverview.kt | 98 ++++ .../ui/detail/series/SeriesOverviewContent.kt | 204 +++++++ .../damontecres/dolphin/ui/nav/Destination.kt | 2 +- .../dolphin/ui/nav/DestinationContent.kt | 6 +- gradle/libs.versions.toml | 2 +- 10 files changed, 674 insertions(+), 549 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/cards/BannerCard.kt delete mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/components/details/SeriesDetailPage.kt create mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeFooter.kt create mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeHeader.kt create mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt create mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt 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 f0dd1544..f05daeb1 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 @@ -1,6 +1,6 @@ package com.github.damontecres.dolphin.data.model -import com.github.damontecres.dolphin.ui.components.details.SeasonEpisode +import com.github.damontecres.dolphin.ui.detail.series.SeasonEpisode import com.github.damontecres.dolphin.ui.nav.Destination import kotlinx.serialization.Serializable import kotlinx.serialization.Transient diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/BannerCard.kt new file mode 100644 index 00000000..a8679154 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/BannerCard.kt @@ -0,0 +1,80 @@ +package com.github.damontecres.dolphin.ui.cards + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +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.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import com.github.damontecres.dolphin.ui.AppColors +import com.github.damontecres.dolphin.ui.isNotNullOrBlank + +@Composable +fun BannerCard( + imageUrl: String, + cornerText: String?, + played: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + cardWidth: Dp = 160.dp, + cardHeight: Dp = cardWidth * 9 / 16, + interactionSource: MutableInteractionSource? = null, +) { + Card( + modifier = modifier.size(cardWidth, cardHeight), + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + ) { + Box { + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier, + ) + if (played || cornerText.isNotNullOrBlank()) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.TopEnd), + ) { + if (played) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.border.copy(alpha = 1f), + modifier = + Modifier + .size(48.dp) + .padding(8.dp), + ) + } + if (cornerText.isNotNullOrBlank()) { + Text( + text = cornerText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.background(AppColors.TransparentBlack50), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/details/SeriesDetailPage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/details/SeriesDetailPage.kt deleted file mode 100644 index 129bfb3b..00000000 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/details/SeriesDetailPage.kt +++ /dev/null @@ -1,543 +0,0 @@ -package com.github.damontecres.dolphin.ui.components.details - -import androidx.compose.foundation.LocalIndication -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusGroup -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.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -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.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer -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.Tab -import androidx.tv.material3.TabRow -import androidx.tv.material3.Text -import coil3.compose.AsyncImage -import com.github.damontecres.dolphin.R -import com.github.damontecres.dolphin.data.model.BaseItem -import com.github.damontecres.dolphin.preferences.UserPreferences -import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect -import com.github.damontecres.dolphin.ui.cards.ItemCard -import com.github.damontecres.dolphin.ui.components.DotSeparatedRow -import com.github.damontecres.dolphin.ui.components.ExpandableFaButton -import com.github.damontecres.dolphin.ui.components.ExpandablePlayButton -import com.github.damontecres.dolphin.ui.components.StarRating -import com.github.damontecres.dolphin.ui.components.StarRatingPrecision -import com.github.damontecres.dolphin.ui.components.TitleValueText -import com.github.damontecres.dolphin.ui.detail.SeriesViewModel -import com.github.damontecres.dolphin.ui.ifElse -import com.github.damontecres.dolphin.ui.isNotNullOrBlank -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.tryRequestFocus -import com.github.damontecres.dolphin.util.formatDateTime -import kotlinx.serialization.Serializable -import org.jellyfin.sdk.model.api.ImageType -import org.jellyfin.sdk.model.api.MediaStreamType -import org.jellyfin.sdk.model.extensions.ticks -import timber.log.Timber -import kotlin.time.Duration - -@Serializable -data class SeasonEpisode( - val season: Int, - val episode: Int, -) - -@Composable -fun SeriesDetailParent( - preferences: UserPreferences, - navigationManager: NavigationManager, - destination: Destination.MediaItem, - modifier: Modifier = Modifier, - viewModel: SeriesViewModel = hiltViewModel(), - initialSeasonEpisode: SeasonEpisode = SeasonEpisode(0, 0), -) { - val firstItemFocusRequester = remember { FocusRequester() } - - OneTimeLaunchedEffect { - Timber.v("SeriesDetailParent: itemId=${destination.itemId}, initialSeasonEpisode=$initialSeasonEpisode") - viewModel.init(destination.itemId, destination.item, initialSeasonEpisode.season) - } - val series by viewModel.item.observeAsState(null) - val seasons by viewModel.seasons.observeAsState(listOf()) - val episodes by viewModel.episodes.observeAsState(listOf()) - var seasonEpisode by rememberSaveable( - destination, - stateSaver = - Saver( - save = { listOf(it.season, it.episode) }, - restore = { SeasonEpisode(it[0], it[1]) }, - ), - ) { mutableStateOf(initialSeasonEpisode) } - - LaunchedEffect(episodes) { - if (episodes.isNotEmpty()) { - // TODO focus on first episode when changing seasons -// firstItemFocusRequester.requestFocus() - } - } - - if (series == null) { - // TODO - Text(text = "Loading...") - } else { - series?.let { series -> - SeriesDetailPage( - series = series, - seasons = seasons, - episodes = episodes, - seasonEpisode = seasonEpisode, - backdropImageUrl = remember { viewModel.imageUrl(series.id, ImageType.BACKDROP) }, - firstItemFocusRequester = firstItemFocusRequester, - onFocus = { - if (it.season != seasonEpisode.season) { - viewModel.loadEpisodes(seasons[it.season]!!.id) - } - seasonEpisode = it - }, - onClick = { - val resumePosition = - it.data.userData - ?.playbackPositionTicks - ?.ticks ?: Duration.ZERO - navigationManager.navigateTo(Destination.Playback(it.id, resumePosition.inWholeMilliseconds, it)) - }, - onLongClick = { - }, - modifier = modifier, - ) - } - } -} - -@Composable -fun SeriesDetailPage( - series: BaseItem, - seasons: List, - episodes: List, - seasonEpisode: SeasonEpisode, - backdropImageUrl: String?, - firstItemFocusRequester: FocusRequester, - onFocus: (SeasonEpisode) -> Unit, - onClick: (BaseItem) -> Unit, - onLongClick: (BaseItem) -> Unit, - modifier: Modifier = Modifier, -) { - val bringIntoViewRequester = remember { BringIntoViewRequester() } - // TODO need to map between season index and tab index in case of missing seasons - var selectedTabIndex by rememberSaveable(seasonEpisode) { mutableIntStateOf(seasonEpisode.season) } - val focusRequesters = remember(seasons.size) { List(seasons.size) { FocusRequester() } } - var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) } - val tabRowFocusRequester = remember { FocusRequester() } - - val focusedEpisode = episodes.getOrNull(seasonEpisode.episode) - - Box( - modifier = - modifier - .fillMaxWidth() -// .fillMaxHeight(.33f) - .height(460.dp) - .bringIntoViewRequester(bringIntoViewRequester), - ) { - if (backdropImageUrl.isNotNullOrBlank()) { - val gradientColor = MaterialTheme.colorScheme.background - AsyncImage( - model = backdropImageUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - alignment = Alignment.TopEnd, - modifier = - Modifier - .fillMaxHeight(.5f) - .drawWithContent { - drawContent() - drawRect( - Brush.verticalGradient( - colors = listOf(Color.Transparent, gradientColor), - startY = 500f, - ), - ) - drawRect( - Brush.horizontalGradient( - colors = listOf(gradientColor, Color.Transparent), - endX = 400f, - startX = 100f, - ), - ) - }, - ) - } - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(16.dp), - modifier = Modifier, - ) { - item { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .ifElse(focusRequesters.size > selectedTabIndex, { Modifier.focusRestorer(focusRequesters[selectedTabIndex]) }) - .focusRequester(tabRowFocusRequester) - .padding(horizontal = 16.dp) - .fillMaxWidth(), - ) { - seasons.forEachIndexed { index, season -> - season?.let { season -> - Tab( - selected = index == selectedTabIndex, - onFocus = {}, - onClick = { - selectedTabIndex = index - onFocus.invoke(SeasonEpisode(index, 0)) - }, - modifier = - Modifier - .focusRequester(focusRequesters[index]), - ) { - Text( - text = season.name ?: "Season ${season.data.indexNumber}", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(8.dp), - ) - } - } - } - } - } - item { - series.name?.let { - Text( - text = it, - style = MaterialTheme.typography.headlineLarge, - modifier = Modifier, - ) - } - } - item { - // Episode header - focusedEpisode?.let { ep -> - FocusedEpisodeHeader( - ep = ep, - overviewOnClick = { - // TODO show full overview dialog - }, - modifier = Modifier, - ) - } - } - item { - key(seasonEpisode.season) { - val state = rememberLazyListState() - LazyRow( - state = state, - horizontalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(start = 32.dp), - modifier = modifier.focusRestorer(firstItemFocusRequester), - ) { - itemsIndexed(episodes) { index, episode -> - val interactionSource = remember { MutableInteractionSource() } - if (interactionSource.collectIsFocusedAsState().value) { - onFocus.invoke(SeasonEpisode(selectedTabIndex, index)) - } - ItemCard( - item = episode, - onClick = { if (episode != null) onClick.invoke(episode) }, - onLongClick = { if (episode != null) onLongClick.invoke(episode) }, - modifier = - Modifier.ifElse( - index == 0, - Modifier.focusRequester(firstItemFocusRequester), - ), - interactionSource = interactionSource, - ) - } - } - } - } - item { - focusedEpisode?.let { ep -> - FocusedEpisodeFooter( - ep = ep, - playOnClick = {}, - moreOnClick = {}, - watchOnClick = {}, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } - } -} - -@Composable -fun FocusedEpisodeHeader( - ep: BaseItem, - overviewOnClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val dto = ep.data - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier, - ) { - Text( - text = dto.episodeTitle ?: dto.name ?: "", - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier, - ) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - val details = - buildList { - if (dto.parentIndexNumber != null && dto.indexNumber != null) { - add("S${dto.parentIndexNumber} E${dto.indexNumber}") - } - dto.premiereDate?.let { add(formatDateTime(it)) } - dto.mediaSources?.firstOrNull()?.runTimeTicks?.ticks?.inWholeMinutes?.toString()?.let { - add(it) - } - dto.seriesStudio?.let { add(it) } - } - DotSeparatedRow( - texts = details, - textStyle = MaterialTheme.typography.titleLarge, - ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Critic: ${dto.criticRating}", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier, - ) - StarRating( - rating100 = - dto - .userData - ?.rating - ?.times(100) - ?.toInt() ?: 0, - 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 = .75f) - } 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.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(8.dp), - ) - } - } - } -} - -@Composable -fun FocusedEpisodeFooter( - ep: BaseItem, - playOnClick: (Duration) -> Unit, - moreOnClick: () -> Unit, - watchOnClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val dto = ep.data - val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO - val firstFocus = remember { FocusRequester() } - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = modifier, - ) { - LazyRow( - horizontalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(8.dp), - modifier = - Modifier - .focusGroup() - .focusProperties { - onEnter = { - firstFocus.tryRequestFocus() - } - }, - ) { - if (resumePosition > Duration.ZERO) { - item { -// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } - ExpandablePlayButton( - R.string.resume, - resumePosition, - Icons.Default.PlayArrow, - playOnClick, - Modifier.focusRequester(firstFocus), - // .onFocusChanged(buttonOnFocusChanged), - ) - } - item { - ExpandablePlayButton( - R.string.restart, - Duration.ZERO, - Icons.Default.Refresh, - playOnClick, - Modifier, - // .onFocusChanged(buttonOnFocusChanged), - ) - } - } else { - item { - ExpandablePlayButton( - R.string.play, - Duration.ZERO, - Icons.Default.PlayArrow, - playOnClick, - Modifier.focusRequester(firstFocus), - // .onFocusChanged(buttonOnFocusChanged) - ) - } - } - - val played = dto.userData?.played ?: false - // Played button - item { - ExpandableFaButton( - title = if (played) R.string.mark_unwatched else R.string.mark_watched, - iconStringRes = if (played) R.string.fa_eye else R.string.fa_eye_slash, - onClick = watchOnClick, - modifier = Modifier, - ) - } - - // More button - item { - ExpandablePlayButton( - R.string.more, - Duration.ZERO, - Icons.Default.MoreVert, - { moreOnClick.invoke() }, - Modifier, - // .onFocusChanged(buttonOnFocusChanged) - ) - } - } - - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - dto.mediaStreams - ?.firstOrNull { it.type == MediaStreamType.VIDEO } - ?.let { stream -> - stream.displayTitle?.let { - TitleValueText( - "Video", - it, - ) - } - } - - dto.mediaStreams - ?.firstOrNull { it.type == MediaStreamType.AUDIO } - ?.let { stream -> - stream.displayTitle?.let { - TitleValueText( - "Audio", - it, - ) - } - } - - dto.mediaStreams - ?.filter { it.type == MediaStreamType.SUBTITLE && it.language.isNotNullOrBlank() } - ?.mapNotNull { it.language } - ?.joinToString(", ") - ?.let { - if (it.isNotNullOrBlank()) { - TitleValueText( - "Subtitles", - it, - modifier = Modifier.widthIn(max = 64.dp), - ) - } - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeFooter.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeFooter.kt new file mode 100644 index 00000000..dd85fff1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeFooter.kt @@ -0,0 +1,159 @@ +package com.github.damontecres.dolphin.ui.detail.series + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.unit.dp +import com.github.damontecres.dolphin.R +import com.github.damontecres.dolphin.data.model.BaseItem +import com.github.damontecres.dolphin.ui.components.ExpandableFaButton +import com.github.damontecres.dolphin.ui.components.ExpandablePlayButton +import com.github.damontecres.dolphin.ui.components.TitleValueText +import com.github.damontecres.dolphin.ui.isNotNullOrBlank +import com.github.damontecres.dolphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.extensions.ticks +import kotlin.time.Duration + +@Composable +fun FocusedEpisodeFooter( + ep: BaseItem, + playOnClick: (Duration) -> Unit, + moreOnClick: () -> Unit, + watchOnClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val dto = ep.data + val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO + val firstFocus = remember { FocusRequester() } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(8.dp), + modifier = + Modifier + .focusGroup() + .focusProperties { + onEnter = { + firstFocus.tryRequestFocus() + } + }, + ) { + if (resumePosition > Duration.ZERO) { + item { +// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + ExpandablePlayButton( + R.string.resume, + resumePosition, + Icons.Default.PlayArrow, + playOnClick, + Modifier.focusRequester(firstFocus), + // .onFocusChanged(buttonOnFocusChanged), + ) + } + item { + ExpandablePlayButton( + R.string.restart, + Duration.ZERO, + Icons.Default.Refresh, + playOnClick, + Modifier, + // .onFocusChanged(buttonOnFocusChanged), + ) + } + } else { + item { + ExpandablePlayButton( + R.string.play, + Duration.ZERO, + Icons.Default.PlayArrow, + playOnClick, + Modifier.focusRequester(firstFocus), + // .onFocusChanged(buttonOnFocusChanged) + ) + } + } + + val played = dto.userData?.played ?: false + // Played button + item { + ExpandableFaButton( + title = if (played) R.string.mark_unwatched else R.string.mark_watched, + iconStringRes = if (played) R.string.fa_eye else R.string.fa_eye_slash, + onClick = watchOnClick, + modifier = Modifier, + ) + } + + // More button + item { + ExpandablePlayButton( + R.string.more, + Duration.ZERO, + Icons.Default.MoreVert, + { moreOnClick.invoke() }, + Modifier, + // .onFocusChanged(buttonOnFocusChanged) + ) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + dto.mediaStreams + ?.firstOrNull { it.type == MediaStreamType.VIDEO } + ?.let { stream -> + stream.displayTitle?.let { + TitleValueText( + "Video", + it, + ) + } + } + + dto.mediaStreams + ?.firstOrNull { it.type == MediaStreamType.AUDIO } + ?.let { stream -> + stream.displayTitle?.let { + TitleValueText( + "Audio", + it, + ) + } + } + + dto.mediaStreams + ?.filter { it.type == MediaStreamType.SUBTITLE && it.language.isNotNullOrBlank() } + ?.mapNotNull { it.language } + ?.joinToString(", ") + ?.let { + if (it.isNotNullOrBlank()) { + TitleValueText( + "Subtitles", + it, + modifier = Modifier.widthIn(max = 64.dp), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeHeader.kt new file mode 100644 index 00000000..46ea40eb --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -0,0 +1,127 @@ +package com.github.damontecres.dolphin.ui.detail.series + +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.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.dolphin.data.model.BaseItem +import com.github.damontecres.dolphin.ui.components.DotSeparatedRow +import com.github.damontecres.dolphin.ui.components.StarRating +import com.github.damontecres.dolphin.ui.components.StarRatingPrecision +import com.github.damontecres.dolphin.ui.playOnClickSound +import com.github.damontecres.dolphin.ui.playSoundOnFocus +import com.github.damontecres.dolphin.util.formatDateTime +import org.jellyfin.sdk.model.extensions.ticks + +@Composable +fun FocusedEpisodeHeader( + ep: BaseItem, + overviewOnClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val dto = ep.data + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + Text( + text = dto.episodeTitle ?: dto.name ?: "", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val details = + buildList { + if (dto.parentIndexNumber != null && dto.indexNumber != null) { + add("S${dto.parentIndexNumber} E${dto.indexNumber}") + } + dto.premiereDate?.let { add(formatDateTime(it)) } + dto.mediaSources?.firstOrNull()?.runTimeTicks?.ticks?.inWholeMinutes?.toString()?.let { + add(it) + } + dto.seriesStudio?.let { add(it) } + } + DotSeparatedRow( + texts = details, + textStyle = MaterialTheme.typography.titleLarge, + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Critic: ${dto.criticRating}", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier, + ) + StarRating( + rating100 = + dto + .userData + ?.rating + ?.times(100) + ?.toInt() ?: 0, + 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 = .75f) + } 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.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(8.dp), + ) + } + } + } +} 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 new file mode 100644 index 00000000..2c94048e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt @@ -0,0 +1,98 @@ +package com.github.damontecres.dolphin.ui.detail.series + +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.Text +import com.github.damontecres.dolphin.preferences.UserPreferences +import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect +import com.github.damontecres.dolphin.ui.detail.SeriesViewModel +import com.github.damontecres.dolphin.ui.nav.Destination +import com.github.damontecres.dolphin.ui.nav.NavigationManager +import kotlinx.serialization.Serializable +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.extensions.ticks +import timber.log.Timber +import kotlin.time.Duration + +@Serializable +data class SeasonEpisode( + val season: Int, + val episode: Int, +) + +@Composable +fun SeriesOverview( + preferences: UserPreferences, + navigationManager: NavigationManager, + destination: Destination.MediaItem, + modifier: Modifier = Modifier, + viewModel: SeriesViewModel = hiltViewModel(), + initialSeasonEpisode: SeasonEpisode = SeasonEpisode(0, 0), +) { + val firstItemFocusRequester = remember { FocusRequester() } + + OneTimeLaunchedEffect { + Timber.v("SeriesDetailParent: itemId=${destination.itemId}, initialSeasonEpisode=$initialSeasonEpisode") + viewModel.init(destination.itemId, destination.item, initialSeasonEpisode.season) + } + val series by viewModel.item.observeAsState(null) + val seasons by viewModel.seasons.observeAsState(listOf()) + val episodes by viewModel.episodes.observeAsState(listOf()) + var seasonEpisode by rememberSaveable( + destination, + stateSaver = + Saver( + save = { listOf(it.season, it.episode) }, + restore = { SeasonEpisode(it[0], it[1]) }, + ), + ) { mutableStateOf(initialSeasonEpisode) } + + LaunchedEffect(episodes) { + if (episodes.isNotEmpty()) { + // TODO focus on first episode when changing seasons +// firstItemFocusRequester.requestFocus() + } + } + + if (series == null) { + // TODO + Text(text = "Loading...") + } else { + series?.let { series -> + SeriesOverviewContent( + series = series, + seasons = seasons, + episodes = episodes, + seasonEpisode = seasonEpisode, + backdropImageUrl = remember { viewModel.imageUrl(series.id, ImageType.BACKDROP) }, + firstItemFocusRequester = firstItemFocusRequester, + onFocus = { + if (it.season != seasonEpisode.season) { + viewModel.loadEpisodes(seasons[it.season]!!.id) + } + seasonEpisode = it + }, + onClick = { + val resumePosition = + it.data.userData + ?.playbackPositionTicks + ?.ticks ?: Duration.ZERO + navigationManager.navigateTo(Destination.Playback(it.id, resumePosition.inWholeMilliseconds, it)) + }, + onLongClick = { + }, + modifier = modifier, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt new file mode 100644 index 00000000..2f2ece7c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt @@ -0,0 +1,204 @@ +package com.github.damontecres.dolphin.ui.detail.series + +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.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +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.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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +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.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Tab +import androidx.tv.material3.TabRow +import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import com.github.damontecres.dolphin.data.model.BaseItem +import com.github.damontecres.dolphin.ui.cards.ItemCard +import com.github.damontecres.dolphin.ui.ifElse +import com.github.damontecres.dolphin.ui.isNotNullOrBlank + +@Composable +fun SeriesOverviewContent( + series: BaseItem, + seasons: List, + episodes: List, + seasonEpisode: SeasonEpisode, + backdropImageUrl: String?, + firstItemFocusRequester: FocusRequester, + onFocus: (SeasonEpisode) -> Unit, + onClick: (BaseItem) -> Unit, + onLongClick: (BaseItem) -> Unit, + modifier: Modifier = Modifier, +) { + val bringIntoViewRequester = remember { BringIntoViewRequester() } + // TODO need to map between season index and tab index in case of missing seasons + var selectedTabIndex by rememberSaveable(seasonEpisode) { mutableIntStateOf(seasonEpisode.season) } + val focusRequesters = remember(seasons.size) { List(seasons.size) { FocusRequester() } } + var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) } + val tabRowFocusRequester = remember { FocusRequester() } + + val focusedEpisode = episodes.getOrNull(seasonEpisode.episode) + + Box( + modifier = + modifier + .fillMaxWidth() +// .fillMaxHeight(.33f) + .height(460.dp) + .bringIntoViewRequester(bringIntoViewRequester), + ) { + if (backdropImageUrl.isNotNullOrBlank()) { + val gradientColor = MaterialTheme.colorScheme.background + AsyncImage( + model = backdropImageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = Alignment.TopEnd, + modifier = + Modifier + .fillMaxHeight(.5f) + .drawWithContent { + drawContent() + drawRect( + Brush.verticalGradient( + colors = listOf(Color.Transparent, gradientColor), + startY = 500f, + ), + ) + drawRect( + Brush.horizontalGradient( + colors = listOf(gradientColor, Color.Transparent), + endX = 400f, + startX = 100f, + ), + ) + }, + ) + } + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp), + modifier = Modifier, + ) { + item { + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = + Modifier + .ifElse(focusRequesters.size > selectedTabIndex, { Modifier.focusRestorer(focusRequesters[selectedTabIndex]) }) + .focusRequester(tabRowFocusRequester) + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) { + seasons.forEachIndexed { index, season -> + season?.let { season -> + Tab( + selected = index == selectedTabIndex, + onFocus = {}, + onClick = { + selectedTabIndex = index + onFocus.invoke(SeasonEpisode(index, 0)) + }, + modifier = + Modifier + .focusRequester(focusRequesters[index]), + ) { + Text( + text = season.name ?: "Season ${season.data.indexNumber}", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(8.dp), + ) + } + } + } + } + } + item { + series.name?.let { + Text( + text = it, + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier, + ) + } + } + item { + // Episode header + focusedEpisode?.let { ep -> + FocusedEpisodeHeader( + ep = ep, + overviewOnClick = { + // TODO show full overview dialog + }, + modifier = Modifier, + ) + } + } + item { + key(seasonEpisode.season) { + val state = rememberLazyListState() + LazyRow( + state = state, + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(start = 32.dp), + modifier = modifier.focusRestorer(firstItemFocusRequester), + ) { + itemsIndexed(episodes) { index, episode -> + val interactionSource = remember { MutableInteractionSource() } + if (interactionSource.collectIsFocusedAsState().value) { + onFocus.invoke(SeasonEpisode(selectedTabIndex, index)) + } + ItemCard( + item = episode, + onClick = { if (episode != null) onClick.invoke(episode) }, + onLongClick = { if (episode != null) onLongClick.invoke(episode) }, + modifier = + Modifier.ifElse( + index == 0, + Modifier.focusRequester(firstItemFocusRequester), + ), + interactionSource = interactionSource, + ) + } + } + } + } + item { + focusedEpisode?.let { ep -> + FocusedEpisodeFooter( + ep = ep, + playOnClick = {}, + moreOnClick = {}, + watchOnClick = {}, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} 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 212ce72e..925bc4b2 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 @@ -4,7 +4,7 @@ package com.github.damontecres.dolphin.ui.nav import androidx.navigation3.runtime.NavKey import com.github.damontecres.dolphin.data.model.BaseItem -import com.github.damontecres.dolphin.ui.components.details.SeasonEpisode +import com.github.damontecres.dolphin.ui.detail.series.SeasonEpisode import com.github.damontecres.dolphin.util.UuidSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.Transient 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 24def2a0..8a75236b 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 @@ -4,13 +4,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.tv.material3.Text import com.github.damontecres.dolphin.preferences.UserPreferences -import com.github.damontecres.dolphin.ui.components.details.SeasonEpisode -import com.github.damontecres.dolphin.ui.components.details.SeriesDetailParent import com.github.damontecres.dolphin.ui.detail.CollectionFolderDetails import com.github.damontecres.dolphin.ui.detail.EpisodeDetails import com.github.damontecres.dolphin.ui.detail.MovieDetails import com.github.damontecres.dolphin.ui.detail.SeasonDetails import com.github.damontecres.dolphin.ui.detail.VideoDetails +import com.github.damontecres.dolphin.ui.detail.series.SeasonEpisode +import com.github.damontecres.dolphin.ui.detail.series.SeriesOverview import com.github.damontecres.dolphin.ui.main.MainPage import com.github.damontecres.dolphin.ui.playback.PlaybackContent import com.github.damontecres.dolphin.ui.setup.SwitchServerContent @@ -49,7 +49,7 @@ fun DestinationContent( is Destination.MediaItem -> when (destination.type) { BaseItemKind.SERIES -> - SeriesDetailParent( + SeriesOverview( preferences, navigationManager, destination, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f34dc1ee..2df052fc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ lifecycleRuntimeKtx = "2.9.4" activityCompose = "1.11.0" androidx-media3 = "1.8.0" coil = "3.3.0" -jellyfin-sdk = "1.7.0-beta.5" +jellyfin-sdk = "1.7.0-beta.6" nav3Core = "1.0.0-alpha09" lifecycleViewmodelNav3 = "1.0.0-alpha04" material3AdaptiveNav3 = "1.0.0-SNAPSHOT"