diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaCardActions.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaCardActions.kt index 9e8acd54c0..fa76ed767f 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaCardActions.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaCardActions.kt @@ -23,13 +23,15 @@ data class MediaCardActions( val onSetWatched: ((watched: Boolean) -> Unit)? = null, val onToggleFavorite: ((favorite: Boolean) -> Unit)? = null, val onToggleWatchlist: ((inWatchlist: Boolean) -> Unit)? = null, - val onRemoveFromContinueWatching: (() -> Unit)? = null, + /** Removes the card from its Home row; [removeFromHomeLabel] names the list it leaves. */ + val onRemoveFromHome: (() -> Unit)? = null, + val removeFromHomeLabel: String = "Remove from Continue Watching", ) { val isEmpty: Boolean get() = onSetWatched == null && onToggleFavorite == null && onToggleWatchlist == null && - onRemoveFromContinueWatching == null + onRemoveFromHome == null } /** @@ -82,9 +84,9 @@ fun MediaCardContextMenu( }, ) } - actions.onRemoveFromContinueWatching?.let { remove -> + actions.onRemoveFromHome?.let { remove -> MenuRow( - text = "Remove from Continue Watching", + text = actions.removeFromHomeLabel, icon = Icons.Default.Close, onClick = { remove() diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/EpisodeList.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/EpisodeList.kt index fe25f025d9..33ee53fa3d 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/EpisodeList.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/EpisodeList.kt @@ -43,8 +43,11 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -57,6 +60,7 @@ import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs import org.prairieserver.prairie.common.ui.components.SpoilerImage import org.prairieserver.prairie.common.ui.components.DefaultArtworkKind import org.prairieserver.prairie.common.ui.components.ThumbhashImage +import org.prairieserver.prairie.model.catalog.EpisodeCardCaption import org.prairieserver.prairie.model.catalog.EpisodeListItem import org.prairieserver.prairie.model.catalog.hasMachineTranslatedOverview import org.prairieserver.prairie.model.settings.EpisodeSpoilers @@ -75,8 +79,6 @@ fun EpisodeList( onEpisodeWatchedChange: ((String, Boolean) -> Unit)? = null, highlightContentId: String? = null, selectsCenteredEpisode: Boolean = false, - /** Expanded tablet/fold cards include their full editorial caption. */ - showsEpisodeDetails: Boolean = false, /** Expanded rails select on tap and never force the card into centre. */ tapToFocusEpisode: Boolean = false, modifier: Modifier = Modifier, @@ -89,6 +91,7 @@ fun EpisodeList( val currentHighlightId by rememberUpdatedState(highlightContentId) val currentOnSelect by rememberUpdatedState(onEpisodeDetailClick) var wasScrolling by remember { mutableStateOf(false) } + val railHasCurrentEpisode = episodes.any { it.contentId == highlightContentId } LaunchedEffect(episodes.map(EpisodeListItem::contentId), highlightContentId) { val selectedIndex = episodes.indexOfFirst { it.contentId == highlightContentId } @@ -147,8 +150,8 @@ fun EpisodeList( { watched -> change(episode.contentId, watched) } }, isCurrent = episode.contentId == highlightContentId, + reservesNowViewing = railHasCurrentEpisode, cardWidth = cardWidth, - showsEpisodeDetails = showsEpisodeDetails, ) } } @@ -171,8 +174,9 @@ private fun EpisodeRailCard( onSelect: () -> Unit, onWatchedChange: ((Boolean) -> Unit)?, isCurrent: Boolean, + /** The rail marks a current episode, so every card keeps room for the tag. */ + reservesNowViewing: Boolean, cardWidth: Dp, - showsEpisodeDetails: Boolean, ) { var menuExpanded by remember { mutableStateOf(false) } val isWatched = episode.userData?.played == true @@ -272,74 +276,74 @@ private fun EpisodeRailCard( } } - Column( - modifier = if (showsEpisodeDetails) Modifier.height(132.dp) else Modifier, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { + // The iOS card's caption: NOW VIEWING, "S01E02 · Title", air date and + // runtime, then up to three lines of description. A LazyRow is as tall + // as its tallest visible card, so every card keeps the same lines, empty + // where there's nothing to show, and the rail doesn't change height as it + // scrolls. Accessibility font sizes lift the line limits, as on iOS. + val accessibilityText = LocalDensity.current.fontScale > 1.3f + val reservesLines = !accessibilityText + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (isCurrent || reservesNowViewing) { Text( - text = "EPISODE ${episode.episodeNumber}", - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 1.sp, - color = Color.White.copy(alpha = 0.55f), + text = "NOW VIEWING", + fontSize = 9.sp, + fontWeight = FontWeight.Black, + letterSpacing = 0.8.sp, + color = Color.Black, + modifier = Modifier + .then(if (isCurrent) Modifier else Modifier.alpha(0f).clearAndSetSemantics {}) + .clip(RoundedCornerShape(50)) + .background(Color.White) + .padding(horizontal = 5.dp, vertical = 2.dp), ) - if (showsEpisodeDetails && visibleOverview != null && - hasMachineTranslatedOverview(episode.machineTranslatedFields) - ) { - DetailMachineTranslatedLabel(compact = true, iconSize = 11.dp) - } - if (isCurrent) { - Text( - text = "NOW VIEWING", - fontSize = 9.sp, - fontWeight = FontWeight.Black, - letterSpacing = 0.8.sp, - color = Color.Black, - modifier = Modifier - .clip(RoundedCornerShape(50)) - .background(Color.White) - .padding( - horizontal = 5.dp, - vertical = if (showsEpisodeDetails) 1.dp else 2.dp, - ), - ) - } } Text( - text = episode.title?.trim()?.takeIf { it.isNotEmpty() } - ?: "Episode ${episode.episodeNumber}", + text = EpisodeCardCaption.title(episode), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = DetailPrimaryText, - maxLines = 1, + maxLines = if (accessibilityText) Int.MAX_VALUE else 1, overflow = TextOverflow.Ellipsis, ) - if (showsEpisodeDetails) { - episodeMetadataLine(episode)?.let { metadata -> - Text( - text = metadata, - style = MaterialTheme.typography.labelSmall, - color = DetailSecondaryText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - // Spoiler protection omits the text; the fixed-height column - // keeps the card the same size. - if (visibleOverview != null) { - Text( - text = visibleOverview, - style = MaterialTheme.typography.bodySmall, - color = DetailSecondaryText, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) + val metadata = EpisodeCardCaption.metadata( + airDate = formatCardDate(episode.airDate) ?: episode.airDate, + runtimeMinutes = episode.runtime, + ) + val showsTranslationLabel = visibleOverview != null && + hasMachineTranslatedOverview(episode.machineTranslatedFields) + if (metadata != null || showsTranslationLabel || reservesLines) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (metadata != null || reservesLines) { + Text( + text = metadata.orEmpty(), + style = MaterialTheme.typography.labelSmall, + color = DetailSecondaryText, + maxLines = if (accessibilityText) Int.MAX_VALUE else 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + if (showsTranslationLabel) { + DetailMachineTranslatedLabel(compact = true, iconSize = 11.dp) + } } } + // Spoiler protection leaves the description out; the title and + // metadata still identify the episode. + if (visibleOverview != null || reservesLines) { + Text( + text = visibleOverview.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = DetailSecondaryText, + minLines = if (accessibilityText) 1 else 3, + maxLines = if (accessibilityText) Int.MAX_VALUE else 3, + overflow = TextOverflow.Ellipsis, + ) + } } PrairieDropdownMenu( expanded = menuExpanded, @@ -363,17 +367,9 @@ internal fun episodeProgressFraction(positionSeconds: Double?, durationSeconds: return (position / duration).toFloat().coerceIn(0f, 1f) } -private fun episodeMetadataLine(episode: EpisodeListItem): String? = buildList { - val airDate = formatCardDate(episode.airDate) - ?: episode.airDate?.takeIf { it.isNotBlank() } - if (airDate != null) add(airDate) - if (episode.runtime > 0) add("${episode.runtime}m") -}.takeIf { it.isNotEmpty() }?.joinToString(" · ") - -/** Static loading footprint matching the artwork and compact identity row. */ +/** Static loading footprint matching the artwork and the card caption. */ @Composable internal fun EpisodeListSkeleton( - showsEpisodeDetails: Boolean = false, modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxWidth()) { @@ -396,13 +392,11 @@ internal fun EpisodeListSkeleton( .clip(RoundedCornerShape(8.dp)) .background(Color.White.copy(alpha = 0.09f + index * 0.01f)), ) - EpisodeSkeletonLine(width = 82.dp, height = 9.dp) - if (showsEpisodeDetails) { - EpisodeSkeletonLine(width = 150.dp, height = 14.dp) - EpisodeSkeletonLine(width = 112.dp, height = 10.dp) - EpisodeSkeletonLine(width = 224.dp, height = 10.dp) - EpisodeSkeletonLine(width = 196.dp, height = 10.dp) - } + EpisodeSkeletonLine(width = 150.dp, height = 14.dp) + EpisodeSkeletonLine(width = 112.dp, height = 10.dp) + EpisodeSkeletonLine(width = 224.dp, height = 10.dp) + EpisodeSkeletonLine(width = 224.dp, height = 10.dp) + EpisodeSkeletonLine(width = 196.dp, height = 10.dp) } } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeasonEpisodePager.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeasonEpisodePager.kt index 25275b74ec..d5a7a4439a 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeasonEpisodePager.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeasonEpisodePager.kt @@ -53,8 +53,6 @@ internal fun SeasonEpisodePager( showsSeasonSelector: Boolean = true, selectsCenteredEpisode: Boolean = false, allowsSeasonPaging: Boolean = true, - /** Tablet/fold rail cards show title, date/runtime, and overview. */ - showsEpisodeDetails: Boolean = false, /** Tablet/fold rail selection follows taps rather than centre position. */ tapToFocusEpisode: Boolean = false, ) { @@ -67,7 +65,6 @@ internal fun SeasonEpisodePager( onEpisodeWatchedChange = onEpisodeWatchedChange, highlightContentId = highlightContentId, selectsCenteredEpisode = selectsCenteredEpisode, - showsEpisodeDetails = showsEpisodeDetails, tapToFocusEpisode = tapToFocusEpisode, ) return @@ -167,7 +164,6 @@ internal fun SeasonEpisodePager( onEpisodeWatchedChange = onEpisodeWatchedChange, highlightContentId = highlightContentId, selectsCenteredEpisode = selectsCenteredEpisode, - showsEpisodeDetails = showsEpisodeDetails, tapToFocusEpisode = tapToFocusEpisode, modifier = Modifier .onSizeChanged { pageHeightsPx[page] = it.height } @@ -191,16 +187,12 @@ private fun SeasonEpisodePage( onEpisodeWatchedChange: ((String, Boolean) -> Unit)?, highlightContentId: String?, selectsCenteredEpisode: Boolean, - showsEpisodeDetails: Boolean, tapToFocusEpisode: Boolean, modifier: Modifier = Modifier, ) { when { isLoading -> { - EpisodeListSkeleton( - showsEpisodeDetails = showsEpisodeDetails, - modifier = modifier, - ) + EpisodeListSkeleton(modifier = modifier) } episodes.isEmpty() -> { Text( @@ -218,7 +210,6 @@ private fun SeasonEpisodePage( onEpisodeWatchedChange = onEpisodeWatchedChange, highlightContentId = highlightContentId, selectsCenteredEpisode = selectsCenteredEpisode, - showsEpisodeDetails = showsEpisodeDetails, tapToFocusEpisode = tapToFocusEpisode, modifier = modifier, ) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeriesDetailContent.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeriesDetailContent.kt index 71c57958a6..3fb6ec48dc 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeriesDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeriesDetailContent.kt @@ -247,7 +247,6 @@ fun SeriesDetailContent( showsSeasonSelector = false, selectsCenteredEpisode = !showsEpisodeDetails, allowsSeasonPaging = false, - showsEpisodeDetails = showsEpisodeDetails, tapToFocusEpisode = showsEpisodeDetails, ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt index eba2056fb6..84001a4cd8 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt @@ -457,11 +457,7 @@ fun HomeScreen( onSetWatched = viewModel::setWatched, onToggleFavorite = viewModel::toggleFavorite, onToggleWatchlist = viewModel::toggleWatchlist, - onDismissContinueWatching = { item -> - item.progressUpdatedAt?.let { ts -> - viewModel.dismissContinueWatching(item.contentId, ts) - } - }, + onRemoveFromHome = viewModel::dismissFromHome, ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeSectionRow.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeSectionRow.kt index 7f52a5485b..d0a8c71d94 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeSectionRow.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeSectionRow.kt @@ -9,6 +9,7 @@ import org.prairieserver.prairie.android.ui.components.MediaCardActions import org.prairieserver.prairie.android.ui.components.MediaRow import org.prairieserver.prairie.model.section.ResolvedSection import org.prairieserver.prairie.model.section.SectionItem +import org.prairieserver.prairie.viewmodel.HomeDismissal /** * Renders a single home screen section as a horizontal row of media cards. @@ -32,7 +33,8 @@ fun HomeSectionRow( onSetWatched: ((String, Boolean) -> Unit)? = null, onToggleFavorite: ((String, Boolean) -> Unit)? = null, onToggleWatchlist: ((String, Boolean) -> Unit)? = null, - onDismissContinueWatching: ((SectionItem) -> Unit)? = null, + /** Removes a Continue Watching or Next Up card; see [HomeDismissal]. */ + onRemoveFromHome: ((SectionItem) -> Unit)? = null, onCenteredContinueWatchingItemChanged: ((SectionItem?) -> Unit)? = null, modifier: Modifier = Modifier, ) { @@ -41,6 +43,7 @@ fun HomeSectionRow( val isContinueWatching = section.sectionType == "continue_watching" || section.sectionType == "in_progress" val showProgress = isContinueWatching + val offersRemoval = onRemoveFromHome != null && section.sectionType in HomeDismissal.sectionTypes val useBackdrop = showProgress || section.sectionType == "next_up" val cardStyle = if (useBackdrop) CardStyle.Backdrop else CardStyle.Poster @@ -57,13 +60,13 @@ fun HomeSectionRow( icon = if (isContinueWatching) Icons.Filled.PlayCircle else null, modifier = modifier, cardActions = { item -> + val dismissal = if (offersRemoval) HomeDismissal.of(item) else null MediaCardActions( onSetWatched = onSetWatched?.let { cb -> { watched -> cb(item.contentId, watched) } }, onToggleFavorite = onToggleFavorite?.let { cb -> { fav -> cb(item.contentId, fav) } }, onToggleWatchlist = onToggleWatchlist?.let { cb -> { wl -> cb(item.contentId, wl) } }, - onRemoveFromContinueWatching = if (isContinueWatching && onDismissContinueWatching != null && item.progressUpdatedAt != null) { - { onDismissContinueWatching(item) } - } else null, + onRemoveFromHome = dismissal?.let { { onRemoveFromHome?.invoke(item) } }, + removeFromHomeLabel = dismissal?.menuLabel.orEmpty(), ) }, onCenteredItemChanged = if (isContinueWatching) onCenteredContinueWatchingItemChanged else null, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaCardActions.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaCardActions.kt index 0d2157f803..75b8705a5a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaCardActions.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaCardActions.kt @@ -75,14 +75,16 @@ data class TvMediaCardActions( val onSetWatched: ((watched: Boolean) -> Unit)? = null, val onToggleFavorite: ((favorite: Boolean) -> Unit)? = null, val onToggleWatchlist: ((inWatchlist: Boolean) -> Unit)? = null, - val onRemoveFromContinueWatching: (() -> Unit)? = null, + /** Removes the card from its Home row; [removeFromHomeLabel] names the list it leaves. */ + val onRemoveFromHome: (() -> Unit)? = null, + val removeFromHomeLabel: String = "Remove from Continue Watching", ) { val isEmpty: Boolean get() = onPlay == null && onSetWatched == null && onToggleFavorite == null && onToggleWatchlist == null && - onRemoveFromContinueWatching == null + onRemoveFromHome == null } /** @@ -118,7 +120,7 @@ fun TvMediaCardContextMenu( actions.onSetWatched != null -> TvMenuAction.Watched actions.onToggleFavorite != null -> TvMenuAction.Favorite actions.onToggleWatchlist != null -> TvMenuAction.Watchlist - else -> TvMenuAction.RemoveFromContinueWatching + else -> TvMenuAction.RemoveFromHome } Box( @@ -239,15 +241,15 @@ private fun TvMediaCardMenuRows( }, ) } - actions.onRemoveFromContinueWatching?.let { remove -> + actions.onRemoveFromHome?.let { remove -> TvMenuRow( - text = "Remove from Continue Watching", + text = actions.removeFromHomeLabel, icon = Icons.Default.Close, onClick = { remove() onDismiss() }, - modifier = if (firstAction == TvMenuAction.RemoveFromContinueWatching) { + modifier = if (firstAction == TvMenuAction.RemoveFromHome) { Modifier.focusRequester(firstRowFocus) } else { Modifier @@ -285,7 +287,7 @@ private enum class TvMenuAction { Watched, Favorite, Watchlist, - RemoveFromContinueWatching, + RemoveFromHome, } @OptIn(ExperimentalTvMaterial3Api::class) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailEpisodeRail.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailEpisodeRail.kt index 9c20b7b055..29ed5ee28e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailEpisodeRail.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailEpisodeRail.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.platform.LocalLayoutDirection @@ -64,12 +65,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Icon import androidx.tv.material3.Text -import org.prairieserver.prairie.common.cards.LocalCardPresentation import org.prairieserver.prairie.common.cards.LocalEpisodeSpoilerPrefs import org.prairieserver.prairie.common.ui.components.SpoilerImage import org.prairieserver.prairie.common.ui.components.DefaultArtworkKind import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.catalog.EpisodeListItem +import org.prairieserver.prairie.model.catalog.EpisodeCardCaption import org.prairieserver.prairie.model.catalog.hasMachineTranslatedOverview import org.prairieserver.prairie.model.settings.EpisodeSpoilers import org.prairieserver.prairie.tv.ui.components.TvMediaCardActions @@ -82,16 +83,15 @@ import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface import org.prairieserver.prairie.tv.ui.theme.PrairieSecondaryText import org.prairieserver.prairie.tv.ui.theme.DarkSurfaceElevated import org.prairieserver.prairie.tv.ui.theme.ProgressFill -import org.prairieserver.prairie.tv.ui.theme.capsuleCaps +import org.prairieserver.prairie.tv.ui.theme.InterFamily import org.prairieserver.prairie.tv.ui.theme.cardScaled -/** Includes the still, optional caption, and vertical rail padding from the first frame. */ +/** Includes the still, the two-line caption, and vertical rail padding from the first frame. */ @Composable internal fun tvSeriesEpisodeRailHeight(): Dp { - val captionHeight = if (LocalCardPresentation.current.caption.showsTitle) { - 7.dp + with(LocalDensity.current) { 14.sp.toDp() } - } else { - 0.dp + val captionHeight = with(LocalDensity.current) { + SeriesStillToCaption + SeriesCaptionTitleStyle.lineHeight.toDp() + + SeriesCaptionLineGap + SeriesCaptionMetadataStyle.lineHeight.toDp() } return tvEpisodeCardWidth() * (9f / 16f) + captionHeight + 12.dp } @@ -110,16 +110,21 @@ internal fun TvSeriesEpisodeRailSkeleton() { repeat(5) { Column( modifier = Modifier.width(cardWidth), - verticalArrangement = Arrangement.spacedBy(7.dp), + verticalArrangement = Arrangement.spacedBy(SeriesStillToCaption), ) { Box( Modifier.width(cardWidth).height(cardWidth * (9f / 16f)) .background(DarkSurfaceElevated, RoundedCornerShape(8.dp)), ) - if (LocalCardPresentation.current.caption.showsTitle) { + Column(verticalArrangement = Arrangement.spacedBy(SeriesCaptionLineGap)) { Box( Modifier.fillMaxWidth(0.65f) - .height(with(LocalDensity.current) { 14.sp.toDp() }) + .height(with(LocalDensity.current) { SeriesCaptionTitleStyle.lineHeight.toDp() }) + .background(DarkSurfaceElevated, RoundedCornerShape(3.dp)), + ) + Box( + Modifier.fillMaxWidth(0.45f) + .height(with(LocalDensity.current) { SeriesCaptionMetadataStyle.lineHeight.toDp() }) .background(DarkSurfaceElevated, RoundedCornerShape(3.dp)), ) } @@ -153,7 +158,6 @@ internal fun TvDetailEpisodeRail( currentContentId: String? = null, onEpisodeFocused: ((EpisodeListItem) -> Unit)? = null, onDirectionUp: (() -> Boolean)? = null, - hidesEpisodeTitle: Boolean = false, usesSeriesGeometry: Boolean = false, carouselJump: TvEpisodeCarouselJump? = null, hasPreviousEpisodes: Boolean = false, @@ -275,7 +279,6 @@ internal fun TvDetailEpisodeRail( onClick = { onEpisodeSelected(episode) }, onSetWatched = { watched -> onSetWatched(episode.contentId, watched) }, onSetFavorite = { favorite -> onSetFavorite(episode.contentId, favorite) }, - hidesEpisodeTitle = hidesEpisodeTitle, usesSeriesGeometry = usesSeriesGeometry, modifier = Modifier .tvRailPinOnFocus( @@ -354,7 +357,6 @@ private fun TvDetailEpisodeCard( onClick: () -> Unit, onSetWatched: (Boolean) -> Unit, onSetFavorite: (Boolean) -> Unit, - hidesEpisodeTitle: Boolean, usesSeriesGeometry: Boolean, modifier: Modifier = Modifier, ) { @@ -367,9 +369,6 @@ private fun TvDetailEpisodeCard( val visibleOverview = episode.overview ?.takeIf { it.isNotBlank() && !spoilers.hidesOverview(unwatched) } val stillHeight = if (usesSeriesGeometry) cardWidth * (9f / 16f) else 130.dp.cardScaled() - val eyebrowFontSize = if (usesSeriesGeometry) 11.sp else 14.sp - val eyebrowLineHeight = if (usesSeriesGeometry) 14.sp else 17.sp - val caption = LocalCardPresentation.current.caption val cornerRadius = if (usesSeriesGeometry) 8.dp else 5.dp val shape = RoundedCornerShape(cornerRadius) @@ -404,7 +403,7 @@ private fun TvDetailEpisodeCard( onClick = onClick, onLongClick = { menuExpanded = true }, ), - verticalArrangement = Arrangement.spacedBy(if (usesSeriesGeometry) 7.dp else 9.dp), + verticalArrangement = Arrangement.spacedBy(if (usesSeriesGeometry) SeriesStillToCaption else 9.dp), ) { TvMediaCardContextMenu( expanded = menuExpanded, @@ -489,146 +488,103 @@ private fun TvDetailEpisodeCard( } } - // Series keeps the compact tvOS-style single caption line: - // "S1 · E1 · Episode name". Other episode rails retain their richer - // title/metadata/synopsis hierarchy. - if (caption.showsTitle) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp), + // The caption shared with tvOS: "S01E02 · Title" over the air date and + // runtime. On the Series page the focused episode's description sits in + // the hero above the rail, so those cards leave it out. The current + // episode is marked by its still's border, as on tvOS. The card + // recomposes on every frame of its focus animation, so the text is built + // once per episode. + val title = remember(episode) { EpisodeCardCaption.title(episode) } + val metadata = remember(episode) { + EpisodeCardCaption.metadata( + airDate = abbreviatedEpisodeDate(episode.airDate), + runtimeMinutes = episode.runtime, + ) + } + Column(verticalArrangement = Arrangement.spacedBy(if (usesSeriesGeometry) SeriesCaptionLineGap else 4.dp)) { + Text( + text = title, + style = if (usesSeriesGeometry) SeriesCaptionTitleStyle else CaptionTitleStyle, + color = if (isCurrent || isFocused) PrairieOnSurface else PrairieOnSurface.copy(alpha = 0.92f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // Drawn even when empty: other episode rails take their height from + // the cards on screen, so every card keeps both lines. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = metadata.orEmpty(), + style = if (usesSeriesGeometry) SeriesCaptionMetadataStyle else CaptionMetadataStyle, + color = PrairieOnSurface.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + // Only these cards show the description, so only they mark a + // translated one. + if (!usesSeriesGeometry && visibleOverview != null && + hasMachineTranslatedOverview(episode.machineTranslatedFields) ) { - Text( - text = if (usesSeriesGeometry) "S${episode.seasonNumber} · E${episode.episodeNumber}" else "EPISODE ${episode.episodeNumber}", - fontSize = eyebrowFontSize, - lineHeight = eyebrowLineHeight, - fontWeight = FontWeight.Bold, - letterSpacing = if (usesSeriesGeometry) 0.55.sp else 0.75.sp, - color = PrairieOnSurface.copy(alpha = 0.7f), - maxLines = 1, - ) - if (hidesEpisodeTitle) { - episode.title?.trim()?.takeIf { it.isNotEmpty() }?.let { inlineTitle -> - Text( - text = "·", - fontSize = eyebrowFontSize, - lineHeight = eyebrowLineHeight, - fontWeight = FontWeight.Bold, - color = PrairieOnSurface.copy(alpha = 0.7f), - maxLines = 1, - ) - Text( - text = inlineTitle, - modifier = Modifier.weight(1f), - fontSize = 11.5.sp, - lineHeight = eyebrowLineHeight, - fontWeight = FontWeight.SemiBold, - color = PrairieOnSurface.copy(alpha = 0.86f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } else if (isCurrent) { - NowViewingTag() - } - if (!hidesEpisodeTitle && caption.showsMetadata && - visibleOverview != null && - hasMachineTranslatedOverview(episode.machineTranslatedFields) - ) { - TvMachineTranslatedLabel(compact = true, iconSize = 12.dp) - } - } - - if (!hidesEpisodeTitle) { - Text( - text = episode.title?.trim()?.takeIf { it.isNotEmpty() } - ?: "Episode ${episode.episodeNumber}", - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - color = when { - isCurrent -> PrairieOnSurface - isFocused -> PrairieOnSurface - else -> PrairieOnSurface.copy(alpha = 0.92f) - }, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - - if (!hidesEpisodeTitle && caption.showsMetadata) { - episodeMetadataLine(episode)?.let { metadata -> - Text( - text = metadata, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = PrairieOnSurface.copy(alpha = 0.75f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - // tvOS uses lineLimit(3, reservesSpace: true): always reserve - // exactly 3 text lines (minLines/maxLines rather than a fixed dp - // clamp so accessibility text scaling can't clip glyphs), and - // render even when there is no overview so every card keeps - // identical vertical metrics. Spoiler protection blanks it - // the same way. - Text( - text = visibleOverview.orEmpty(), - fontSize = 16.sp, - fontWeight = FontWeight.Normal, - color = PrairieSecondaryText, - minLines = 3, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - lineHeight = 20.sp, - modifier = Modifier.padding(top = 2.dp), - ) + TvMachineTranslatedLabel(compact = true, iconSize = 12.dp) } } + if (!usesSeriesGeometry) { + // Outside the Series page (a season or episode page kept when its + // series can't be loaded) the hero doesn't follow focus, so the + // card keeps the description. Three lines are kept even when + // spoiler protection empties them, so the cards stay one height. + Text( + text = visibleOverview.orEmpty(), + style = CaptionOverviewStyle, + color = PrairieSecondaryText, + minLines = 3, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } } } } -@Composable -private fun NowViewingTag() { - Box( - modifier = Modifier - .clip(RoundedCornerShape(50.dp)) - .background(Color.White) - .padding(horizontal = 7.dp, vertical = 2.dp), - ) { - Text( - text = "NOW VIEWING", - style = capsuleCaps.copy( - fontSize = 14.sp, - lineHeight = 17.sp, - letterSpacing = 0.55.sp, - ), - color = Color.Black, - ) - } -} - -private fun episodeMetadataLine(episode: EpisodeListItem): String? { - val parts = buildList { - abbreviatedEpisodeDate(episode.airDate)?.let(::add) - episodeRuntime(episode.runtime)?.let(::add) - } - return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ") -} - -private fun episodeRuntime(runtimeMinutes: Int): String? { - if (runtimeMinutes <= 0) return null - return if (runtimeMinutes >= 60) { - "${runtimeMinutes / 60}h ${runtimeMinutes % 60}m" - } else { - "${runtimeMinutes}m" - } -} +// tvOS's Series caption (20pt title, 17pt date line) on the 960×540dp canvas. +// tvSeriesEpisodeRailHeight and the skeleton are built from these values. +private val SeriesStillToCaption = 7.dp +private val SeriesCaptionLineGap = 3.dp +private val SeriesCaptionTitleStyle = TextStyle( + fontFamily = InterFamily, + fontSize = 11.5.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.SemiBold, +) +private val SeriesCaptionMetadataStyle = TextStyle( + fontFamily = InterFamily, + fontSize = 10.5.sp, + lineHeight = 13.sp, + fontWeight = FontWeight.Medium, +) +private val CaptionTitleStyle = TextStyle( + fontFamily = InterFamily, + fontSize = 18.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.SemiBold, +) +private val CaptionMetadataStyle = TextStyle( + fontFamily = InterFamily, + fontSize = 14.sp, + lineHeight = 17.sp, + fontWeight = FontWeight.Medium, +) +private val CaptionOverviewStyle = TextStyle( + fontFamily = InterFamily, + fontSize = 16.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Normal, +) -// Shared UTC-instant → device-local formatting (tvOS parity) so the card -// dates agree with the hero facts row. +// Shared with the hero facts row (tvOS parity), so the card dates agree with it. private fun abbreviatedEpisodeDate(raw: String?): String? = TvDetailMetadata.abbreviatedDate(raw) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt index 6175f310ea..91210e80af 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt @@ -43,6 +43,8 @@ import androidx.tv.material3.LocalTextStyle import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage +import org.prairieserver.prairie.common.cards.LocalCardPresentation +import org.prairieserver.prairie.model.settings.CardPosterSize import org.prairieserver.prairie.common.settings.titleLogoUrl import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.tv.R @@ -91,7 +93,15 @@ internal fun TvDetailHero( // tvOS 690pt on a 1080pt canvas. Android TV's 1920×1080 emulator reports // a 960×540dp layout canvas, so the same fraction produces 345dp. val screenHeight = LocalConfiguration.current.screenHeightDp.dp - val heroHeight = screenHeight * HERO_HEIGHT_FRACTION + extraHeight + // At the Large card size the Series episode rail's two-line caption runs + // past the bottom of the pinned first screen, so the Series hero gives up + // that much of its empty top inset; the rest of the page moves up with it. + val seriesLift = if (compactSeries && LocalCardPresentation.current.posterSize == CardPosterSize.Large) { + SERIES_LARGE_CARD_LIFT + } else { + 0.dp + } + val heroHeight = screenHeight * HERO_HEIGHT_FRACTION + extraHeight - seriesLift BoxWithConstraints( modifier = modifier @@ -147,7 +157,7 @@ internal fun TvDetailHero( Column( modifier = Modifier .fillMaxSize() - .padding(top = HERO_TOP_INSET, start = TvDetailHorizontalInset, end = TvDetailHorizontalInset), + .padding(top = HERO_TOP_INSET - seriesLift, start = TvDetailHorizontalInset, end = TvDetailHorizontalInset), horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(if (compactSeries) 2.dp else HERO_CONTENT_SPACING), ) { @@ -537,6 +547,7 @@ private const val HERO_HEIGHT_FRACTION = 690f / 1080f private const val ARTWORK_WIDTH_FRACTION = 0.64f private const val ARTWORK_HEIGHT_FRACTION = 0.70f private val HERO_TOP_INSET = 58.dp +private val SERIES_LARGE_CARD_LIFT = 16.dp private val HERO_CONTENT_MAX_WIDTH = 540.dp private val HERO_CONTENT_SPACING = 9.dp private val EDITORIAL_SPACING = 7.dp diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt index 99b90c71d8..2b044fba85 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt @@ -12,7 +12,6 @@ import java.time.Instant import java.time.LocalDate import java.time.OffsetDateTime import java.time.ZoneId -import java.time.ZoneOffset /** * Pure functions that build the hero metadata for the cinematic detail @@ -138,17 +137,13 @@ internal object TvDetailMetadata { } /** - * tvOS `DetailDateFormatting.abbreviatedDate`: parse the server value as a - * UTC instant (its ISO8601 parsers all assume GMT — including bare - * `yyyy-MM-dd` dates) and render the DEVICE-LOCAL calendar date. Keeping - * that quirk is deliberate: for a viewer west of UTC both clients show - * e.g. "Jul 8" for a `2026-07-09T00:00:00Z` air date, instead of the TV - * app drifting a day ahead of tvOS. + * tvOS `DetailDateFormatting.abbreviatedDate`: the server's air and release + * dates are calendar dates (`yyyy-MM-dd`) and show that day in every time + * zone. An RFC 3339 timestamp converts to its calendar date in [zone]. */ internal fun abbreviatedDate(raw: String?, zone: ZoneId = ZoneId.systemDefault()): String? { val trimmed = raw?.trim()?.takeIf { it.isNotEmpty() } ?: return null - val instant = parseServerInstant(trimmed) ?: return trimmed - val date = instant.atZone(zone).toLocalDate() + val date = parseServerDate(trimmed, zone) ?: return trimmed val monthName = listOf( "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", @@ -156,12 +151,12 @@ internal object TvDetailMetadata { return "$monthName ${date.dayOfMonth}, ${date.year}" } - /** RFC3339 timestamp (with/without fraction or offset) or bare full date, - * as UTC — the same parser cascade as Apple's `DetailDateFormatting`. */ - private fun parseServerInstant(raw: String): Instant? = - runCatching { Instant.parse(raw) }.getOrNull() - ?: runCatching { OffsetDateTime.parse(raw).toInstant() }.getOrNull() - ?: runCatching { LocalDate.parse(raw).atStartOfDay(ZoneOffset.UTC).toInstant() }.getOrNull() + /** A bare `yyyy-MM-dd` as that day, or an RFC 3339 timestamp (`Z` or a + * numeric offset, with or without a fraction) as its date in [zone]. */ + private fun parseServerDate(raw: String, zone: ZoneId): LocalDate? = + runCatching { LocalDate.parse(raw) }.getOrNull() + ?: runCatching { Instant.parse(raw).atZone(zone).toLocalDate() }.getOrNull() + ?: runCatching { OffsetDateTime.parse(raw).atZoneSameInstant(zone).toLocalDate() }.getOrNull() private fun qualityTokens(version: FileVersion?): List { version ?: return emptyList() diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt index 6bb563e24f..6ada2a0cf3 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt @@ -2241,7 +2241,6 @@ private fun EpisodesSection( // Up returns to the hero only when the season chips aren't above // the rail (the chips own the Up traversal when present). onDirectionUp = if (showsSeasonChips) null else onReturnToHero, - hidesEpisodeTitle = isSeries, usesSeriesGeometry = isSeries, carouselJump = state.carouselJump.takeIf { isSeries }, hasPreviousEpisodes = isSeries && state.carouselPreviousSeason != null, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt index 80639498ed..cf9ed03533 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt @@ -43,6 +43,7 @@ import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult import org.prairieserver.prairie.tv.ui.focus.claimFocusOrReport import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.viewmodel.HomeDismissal import org.prairieserver.prairie.viewmodel.HomeViewModel internal fun shouldShowHomeEmptyState( @@ -154,8 +155,7 @@ fun TvHomeScreen( onMarqueeTranslated = viewModel::refreshFromRealtime, onToggleFavorite = viewModel::toggleFavorite, onToggleWatchlist = viewModel::toggleWatchlist, - onDismissContinueWatching = viewModel::dismissContinueWatching, - onDismissNextUp = viewModel::dismissNextUp, + onRemoveFromHome = viewModel::dismissFromHome, ) } } @@ -243,8 +243,7 @@ private fun TvHomeContent( onSetWatched: (String, Boolean) -> Unit = { _, _ -> }, onToggleFavorite: (String, Boolean) -> Unit = { _, _ -> }, onToggleWatchlist: (String, Boolean) -> Unit = { _, _ -> }, - onDismissContinueWatching: (String, String) -> Unit = { _, _ -> }, - onDismissNextUp: (String, String) -> Unit = { _, _ -> }, + onRemoveFromHome: (SectionItem) -> Unit = {}, sectionsFullyResolved: Boolean = true, onMarqueeTranslated: () -> Unit = {}, ) { @@ -298,10 +297,8 @@ private fun TvHomeContent( } }, cardActions = { section, item -> - val isProgressRow = section.isTvProgressRow() val isContinueWatching = section.isTvContinueWatchingRow() - val progressUpdatedAt = item.progressUpdatedAt - val seriesId = item.seriesId + val dismissal = if (section.isTvProgressRow()) HomeDismissal.of(item) else null TvMediaCardActions( onPlay = if (isContinueWatching) { { @@ -318,16 +315,8 @@ private fun TvHomeContent( onSetWatched = { watched -> onSetWatched(item.contentId, watched) }, onToggleFavorite = { fav -> onToggleFavorite(item.contentId, fav) }, onToggleWatchlist = { wl -> onToggleWatchlist(item.contentId, wl) }, - onRemoveFromContinueWatching = when { - !isProgressRow -> null - progressUpdatedAt != null -> { - { onDismissContinueWatching(item.contentId, progressUpdatedAt) } - } - seriesId != null -> { - { onDismissNextUp(item.contentId, seriesId) } - } - else -> null - }, + onRemoveFromHome = dismissal?.let { { onRemoveFromHome(item) } }, + removeFromHomeLabel = dismissal?.menuLabel.orEmpty(), ) }, ) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/theme/Type.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/theme/Type.kt index 248b93f323..eedd6a5684 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/theme/Type.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/theme/Type.kt @@ -168,17 +168,6 @@ val sectionEyebrow = TextStyle( letterSpacing = 0.sp, ) -// Tiny tracked-caps capsule label (e.g. the episode-rail "NOW VIEWING" badge). -// Smallest readable 10-foot caps token — keeps the badge compact while staying -// above the readability floor. -val capsuleCaps = TextStyle( - fontFamily = InterFamily, - fontWeight = FontWeight.Black, - fontSize = 16.sp, - lineHeight = 18.sp, - letterSpacing = 1.6.sp, -) - // Mono uppercase group header (e.g. the grouped-collections grid header). // Wide tracking, monospace; smallest readable 10-foot size. val monoGroupHeader = TextStyle( diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailFocusPolicyTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailFocusPolicyTest.kt index 9ee03af649..73bc1fb05a 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailFocusPolicyTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailFocusPolicyTest.kt @@ -87,8 +87,7 @@ class TvDetailFocusPolicyTest { assertFalse(detailSource.contains("private fun CircleAction(")) assertTrue(detailSource.contains("iconActive = Icons.Filled.SkipPrevious")) assertTrue(detailSource.contains("Modifier.width(170.dp)")) - assertTrue(episodeSource.contains("text = \"NOW VIEWING\"")) - assertTrue(episodeSource.contains("val eyebrowFontSize = if (usesSeriesGeometry) 11.sp else 14.sp")) + assertTrue(episodeSource.contains("EpisodeCardCaption.title(episode)")) assertTrue(episodeSource.contains("fontSize = 11.5.sp")) assertTrue(episodeSource.contains("tvEpisodeCardWidth()")) assertTrue(episodeSource.contains("cardWidth * (9f / 16f)")) diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadataTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadataTest.kt index 4f0ad2b038..fbaf4341c2 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadataTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadataTest.kt @@ -107,10 +107,9 @@ class TvDetailMetadataTest { } @Test - fun airDateRendersInViewerZoneLikeTvOs() { - // tvOS parses the timestamp as a UTC instant and formats the LOCAL - // calendar date, so a midnight-UTC air date shows the previous day for - // viewers west of UTC. The Android hero mirrors that. + fun airDateKeepsCalendarDayLikeTvOs() { + // A timestamp converts to the viewer's zone, so a midnight-UTC instant + // is the previous day west of UTC. assertEquals( "Mar 29, 2026", TvDetailMetadata.abbreviatedDate( @@ -118,10 +117,10 @@ class TvDetailMetadataTest { zone = ZoneId.of("America/Los_Angeles"), ), ) - // Bare full dates are parsed at UTC midnight too (Apple's - // `.withFullDate` ISO8601 parser defaults to GMT). + // A bare calendar date has no zone and shows the same day everywhere, + // like tvOS `DetailDateFormatting`. assertEquals( - "Mar 29, 2026", + "Mar 30, 2026", TvDetailMetadata.abbreviatedDate( "2026-03-30", zone = ZoneId.of("America/Los_Angeles"), diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/theme/TvTypographyReadabilityTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/theme/TvTypographyReadabilityTest.kt index 87f4328d7d..1d3df8b88e 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/theme/TvTypographyReadabilityTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/theme/TvTypographyReadabilityTest.kt @@ -19,6 +19,7 @@ class TvTypographyReadabilityTest { "org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt|fontSize = 12.sp,", "org/prairieserver/prairie/tv/ui/components/TvSquaredButtons.kt|val titleStyle = TextStyle(fontSize = 13.sp, fontWeight = FontWeight.SemiBold)", "org/prairieserver/prairie/tv/ui/screens/detail/TvDetailEpisodeRail.kt|fontSize = 11.5.sp,", + "org/prairieserver/prairie/tv/ui/screens/detail/TvDetailEpisodeRail.kt|fontSize = 10.5.sp,", "org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt|fontSize = 11.5.sp,", "org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt|fontSize = 11.5.sp,", // First-run chrome at tvOS half scale: the SECURE/HTTP badge on the diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/EpisodeCardCaption.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/EpisodeCardCaption.kt new file mode 100644 index 0000000000..2e84571010 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/EpisodeCardCaption.kt @@ -0,0 +1,32 @@ +package org.prairieserver.prairie.model.catalog + +/** + * Text for the caption under an episode card, the same on the Apple and web + * clients: "S01E02 · Pilot", then the air date and runtime. Each app formats the + * air date with its own calendar-date formatter and passes the result in. + */ +object EpisodeCardCaption { + /** "S01E02", zero-padded so codes line up across a rail. */ + private fun code(seasonNumber: Int, episodeNumber: Int): String = + "S${seasonNumber.toString().padStart(2, '0')}E${episodeNumber.toString().padStart(2, '0')}" + + /** "S01E02 · Pilot", or the code alone when the episode has no title. */ + fun title(episode: EpisodeListItem): String { + val code = code(episode.seasonNumber, episode.episodeNumber) + val title = episode.title?.trim().orEmpty() + return if (title.isEmpty()) code else "$code · $title" + } + + /** "Feb 18, 2026 · 52m", or null when neither part is known. */ + fun metadata(airDate: String?, runtimeMinutes: Int): String? = + listOfNotNull(airDate?.takeIf { it.isNotBlank() }, runtime(runtimeMinutes)) + .takeIf { it.isNotEmpty() } + ?.joinToString(" · ") + + /** "52m" or "1h 5m"; null when the runtime is unknown. */ + private fun runtime(minutes: Int): String? = when { + minutes <= 0 -> null + minutes >= 60 -> "${minutes / 60}h ${minutes % 60}m" + else -> "${minutes}m" + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeDismissal.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeDismissal.kt new file mode 100644 index 0000000000..1672bc87fd --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeDismissal.kt @@ -0,0 +1,42 @@ +package org.prairieserver.prairie.viewmodel + +import org.prairieserver.prairie.model.catalog.isEpisodeItemType +import org.prairieserver.prairie.model.section.SectionItem + +/** + * Which Home list a card's dismissal goes to, and so what its menu calls it. + * + * The server keys the two lists differently. An in-progress card is dismissed + * on `continue_watching` against its exact `progress_updated_at`, so resuming + * playback brings it back. A Next Up episode has no progress row; it is + * dismissed on `next_up`, keyed by its series. Progress wins when a card has + * both, as on Apple. + */ +class HomeDismissal private constructor(val surface: String, val anchor: String, val menuLabel: String) { + companion object { + /** Home rows whose cards can be removed. */ + val sectionTypes = setOf("continue_watching", "in_progress", "next_up") + + /** Null when neither list can represent the card, which then has no remove action. */ + fun of(item: SectionItem): HomeDismissal? { + item.progressUpdatedAt?.takeIf { it.isNotBlank() }?.let { + return HomeDismissal("continue_watching", it, "Remove from Continue Watching") + } + item.seriesId?.takeIf { it.isNotBlank() }?.let { + return HomeDismissal("next_up", it, "Remove from Next Up") + } + return null + } + } +} + +/** + * The show the server drops when this card is dismissed from either list: an + * episode's series, or the series itself. Movies and books go one card at a + * time, including an audiobook that belongs to a book series. + */ +internal fun SectionItem.droppedSeriesId(): String? = when { + type.trim().equals("series", ignoreCase = true) -> contentId + isEpisodeItemType(type) -> seriesId?.takeIf { it.isNotBlank() } + else -> null +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt index 581fd46711..22d7d3743c 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt @@ -28,6 +28,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlin.time.TimeSource data class HomeUiState( @@ -518,6 +520,18 @@ class HomeViewModel( private val pendingDismissals = mutableSetOf() + /** Serializes the cached-Home rewrites, so one dismissal cannot write back a card another removed. */ + private val dismissalCacheWrites = Mutex() + + /** A dismissal succeeded and Home has not re-read since. */ + private var reloadAfterDismissals = false + + /** Removes a Continue Watching or Next Up card on the list [HomeDismissal] picks for it. */ + fun dismissFromHome(item: SectionItem) { + val dismissal = HomeDismissal.of(item) ?: return + dismissHomeProgressItem(dismissal.surface, item.contentId, dismissal.anchor) + } + fun dismissContinueWatching(itemId: String, progressUpdatedAt: String) = dismissHomeProgressItem("continue_watching", itemId, progressUpdatedAt) @@ -530,31 +544,67 @@ class HomeViewModel( val observed = _uiState.value.sections fun SectionItem.matches() = contentId == itemId && (if (surface == "continue_watching") progressUpdatedAt == anchor else seriesId == anchor) - fun ResolvedSection.progressRow() = sectionType in setOf("continue_watching", "in_progress", "next_up", "up_next") - fun List.targetRows() = filter { it.progressRow() }.flatMap { section -> + fun List.targetRows() = filter { it.isProgressRow() }.flatMap { section -> section.items.filter { it.contentId == itemId }.map { section.id to it } } val targets = observed.targetRows() - if (anchor.isBlank() || targets.none { it.second.matches() }) return + val dismissed = targets.firstOrNull { it.second.matches() }?.second + if (anchor.isBlank() || dismissed == null) return if (!pendingDismissals.add(itemId)) return + // On /api/v2, dismissing an episode or a series drops the whole show, + // so every card of that series leaves the progress rows with it. + val droppedSeries = dismissed.droppedSeriesId() + fun SectionItem.removed() = matches() || + (droppedSeries != null && (contentId == droppedSeries || seriesId == droppedSeries)) fun observationCurrent() = generation == fetchGeneration && displayedOwner == owner && _uiState.value.sections.targetRows() == targets viewModelScope.launch { try { if (!mayPublish(owner, generation) || !observationCurrent()) return@launch val result = sectionRepository.dismissHomeItem(surface, itemId, anchor, owner) + // The server dropped the show even if this screen moved on while + // the request ran, so re-read either way, for the same profile. + if (result is ApiResult.Success && displayedOwner == owner && sectionRepository.isHomeAuthorityCurrent(owner)) { + reloadAfterDismissals = true + } if (!mayPublish(owner, generation) || !observationCurrent()) return@launch if (result is ApiResult.Success) { - _uiState.update { state -> state.copy(sections = state.sections.map { section -> - if (section.progressRow()) section.copy(items = section.items.filterNot { it.matches() }) else section - }.filter { it.items.isNotEmpty() }) } + _uiState.update { state -> state.copy(sections = state.sections.removingFromProgressRows { it.removed() }) } + // The next cold start shows the cached Home before it re-reads; + // without this the dismissed cards come back until then. + dismissalCacheWrites.withLock { + val artworkFingerprint = homeCache.artworkFingerprint() + val cached = homeCache.getCachedHomeV2(owner) ?: return@withLock + homeCache.cacheHomeV2(cached.sections.removingFromProgressRows { it.removed() }, owner, artworkFingerprint) { + generation == fetchGeneration && displayedOwner == owner + } + } } - } finally { pendingDismissals.remove(itemId) } + } finally { + pendingDismissals.remove(itemId) + // Then re-read Home from the server, as Mark as Watched does; a + // failed read keeps the local removal. It waits for the last + // dismissal in flight, because a read that starts sooner would + // make that one's observation stale and skip its removal. + if (pendingDismissals.isEmpty() && reloadAfterDismissals) { + reloadAfterDismissals = false + if (shown) refresh() else realtimeRefreshPending = true + } + } } } } +/** Rows built from playback state. Next Up appears as its own row and merged into Continue Watching. */ +private fun ResolvedSection.isProgressRow() = sectionType in setOf("continue_watching", "in_progress", "next_up", "up_next") + +/** Drops [isRemoved] cards from the progress rows; a row left empty goes too. */ +private fun List.removingFromProgressRows(isRemoved: (SectionItem) -> Boolean): List = + map { section -> + if (section.isProgressRow()) section.copy(items = section.items.filterNot(isRemoved)) else section + }.filter { it.items.isNotEmpty() } + private fun List.duplicateSectionKeyCount(): Int = size - distinctBy(ResolvedSection::id).size diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeDismissalSeriesDropTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeDismissalSeriesDropTest.kt new file mode 100644 index 0000000000..ae4b64f78f --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeDismissalSeriesDropTest.kt @@ -0,0 +1,158 @@ +package org.prairieserver.prairie.viewmodel + +import androidx.lifecycle.ViewModelStore +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockEngineConfig +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.TextContent +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.prairieserver.prairie.domain.MediaActionsCoordinator +import org.prairieserver.prairie.model.section.ResolvedSection +import org.prairieserver.prairie.model.section.SectionItem +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.api.PersonalDataApi +import org.prairieserver.prairie.network.api.SectionApi +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.network.apiv2.HomeSectionsV2Api +import org.prairieserver.prairie.repository.PersonalDataRepository +import org.prairieserver.prairie.repository.SectionRepository +import org.prairieserver.prairie.repository.port.HomeCachePort +import org.prairieserver.prairie.repository.port.HomeCacheSnapshot +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * On /api/v2 the server drops a whole show when one of its episodes is + * dismissed from either list, so Home must drop every card of that show, on + * screen and in the cache the next cold start reads. Movies and books go one + * card at a time. The removal holds when the re-read that follows fails. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class HomeDismissalSeriesDropTest { + private val owner = AuthScopeSnapshot("server", "profile", "https://prairie.test", "pin", identityGeneration = 1) + private val tokens = object : TokenManager by TokenManagerImpl() { override suspend fun snapshotCurrentScope() = owner } + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest fun setUp() = Dispatchers.setMain(dispatcher) + @AfterTest fun tearDown() = Dispatchers.resetMain() + + private val stamp = "2026-10-18T01:02:03Z" + private val home = """{"sections":[ + {"id":"cw","section_type":"continue_watching","title":"Continue Watching","items":[ + {"content_id":"ep-a1","type":"episode","title":"A1","series_id":"series-a","progress_updated_at":"$stamp"}, + {"content_id":"ep-b2","type":"episode","title":"B2","series_id":"series-b","item_source":"next_up"}, + {"content_id":"book-1","type":"audiobook","title":"Book 1","series_id":"saga","progress_updated_at":"$stamp"}, + {"content_id":"movie-1","type":"movie","title":"Movie","progress_updated_at":"$stamp"}]}, + {"id":"ip","section_type":"in_progress","title":"In Progress","items":[ + {"content_id":"ep-b1","type":"episode","title":"B1","series_id":"series-b","progress_updated_at":"$stamp"}, + {"content_id":"book-2","type":"audiobook","title":"Book 2","series_id":"saga","progress_updated_at":"$stamp"}]}, + {"id":"nu","section_type":"next_up","title":"Next Up","items":[ + {"content_id":"ep-b2","type":"episode","title":"B2","series_id":"series-b"}, + {"content_id":"ep-c2","type":"episode","title":"C2","series_id":"series-c"}]}, + {"id":"new","section_type":"recently_added","title":"Recently Added","items":[ + {"content_id":"series-b","type":"series","title":"Show B"}, + {"content_id":"ep-b2","type":"episode","title":"B2","series_id":"series-b"}]}]}""" + + @Test + fun nextUpEpisodeDropsEveryCardOfItsShowFromProgressRowsAndCache() = scenario( + dismiss = "ep-b2", + expectedRequest = "next_up/ep-b2 series_id=series-b", + expectedRows = mapOf( + "cw" to listOf("ep-a1", "book-1", "movie-1"), + "ip" to listOf("book-2"), + "nu" to listOf("ep-c2"), + "new" to listOf("series-b", "ep-b2"), + ), + ) + + @Test + fun audiobookInABookSeriesGoesAlone() = scenario( + dismiss = "book-1", + expectedRequest = "continue_watching/book-1 progress_updated_at=$stamp", + expectedRows = mapOf( + "cw" to listOf("ep-a1", "ep-b2", "movie-1"), + "ip" to listOf("ep-b1", "book-2"), + "nu" to listOf("ep-b2", "ep-c2"), + "new" to listOf("series-b", "ep-b2"), + ), + ) + + @Test + fun progressWinsOverSeriesForTheSurface() { + val both = SectionItem(contentId = "e", type = "episode", title = "E", seriesId = "s", progressUpdatedAt = stamp) + val nextUp = both.copy(progressUpdatedAt = null) + assertEquals("continue_watching" to stamp, HomeDismissal.of(both)!!.let { it.surface to it.anchor }) + assertEquals("next_up" to "s", HomeDismissal.of(nextUp)!!.let { it.surface to it.anchor }) + assertEquals(null, HomeDismissal.of(nextUp.copy(seriesId = " "))) + } + + private fun scenario(dismiss: String, expectedRequest: String, expectedRows: Map>) = runTest(dispatcher) { + val requests = mutableListOf() + var reads = 0 + val client = HttpClient(MockEngine(MockEngineConfig().apply { + this.dispatcher = this@HomeDismissalSeriesDropTest.dispatcher + addHandler { request -> + if (request.method == HttpMethod.Put) { + val body = PrairieJson.parseToJsonElement((request.body as TextContent).text).jsonObject.entries + .joinToString(" ") { (key, value) -> "$key=${value.jsonPrimitive.content}" } + requests += request.url.encodedPath.removePrefix("/api/v2/home/dismissals/") + " " + body + respond("", HttpStatusCode.NoContent) + } else if (reads++ == 0) { + respond(home, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } else { + // The re-read after the dismissal fails; the removal must hold. + respond("{}", HttpStatusCode.ServiceUnavailable, headersOf(HttpHeaders.ContentType, "application/json")) + } + } + })) { install(ContentNegotiation) { json(PrairieJson) } } + val cache = MemoryHomeCache() + val store = ViewModelStore() + try { + val vm = HomeViewModel( + SectionRepository(SectionApi(client, home = HomeSectionsV2Api(client, tokens, ApiV2Gate.Unrestricted, decodeDispatcher = dispatcher))), + MediaActionsCoordinator(PersonalDataRepository(PersonalDataApi(client))), + homeCache = cache, + ) + store.put("home", vm) + val item = vm.uiState.value.sections.first().items.single { it.contentId == dismiss } + + vm.dismissFromHome(item) + + assertEquals(listOf(expectedRequest), requests) + assertEquals(2, reads) + assertEquals(expectedRows, vm.uiState.value.sections.rows()) + assertEquals(expectedRows, cache.sections?.rows()) + } finally { store.clear(); client.close() } + } + + private fun List.rows() = associate { section -> section.id to section.items.map { it.contentId } } + + private class MemoryHomeCache : HomeCachePort { + var sections: List? = null + + override suspend fun cacheHomeV2(sections: List, owner: AuthScopeSnapshot, artworkFingerprint: String?, stillCurrent: () -> Boolean) { + if (stillCurrent()) this.sections = sections + } + + override suspend fun getCachedHomeV2(owner: AuthScopeSnapshot) = sections?.let { HomeCacheSnapshot(it, 0) } + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeDismissalV2Test.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeDismissalV2Test.kt index 5e6ec78946..93790d42b9 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeDismissalV2Test.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeDismissalV2Test.kt @@ -41,6 +41,9 @@ class HomeDismissalV2Test { var currentStamp = stamp var title = "Original" var sends = 0 + // What the server has dismissed; Home re-reads after a dismissal succeeds. + val dismissedOnServer = mutableSetOf() + var reads = 0 val entered = CompletableDeferred() val release = CompletableDeferred() val client = HttpClient(MockEngine(MockEngineConfig().apply { @@ -58,11 +61,22 @@ class HomeDismissalV2Test { assertEquals(if (stage == "next") "series:a" else stamp, body.values.single().jsonPrimitive.content) entered.complete(Unit); release.await() val status = when (stage) { "wrong_status" -> HttpStatusCode.OK; "failure" -> HttpStatusCode.ServiceUnavailable; else -> HttpStatusCode.NoContent } + if (status == HttpStatusCode.NoContent) dismissedOnServer += request.url.encodedPath.substringAfterLast("%2F") respond(if (status == HttpStatusCode.NoContent) "" else "{}", status, headersOf(HttpHeaders.ContentType, "application/json")) } else { assertEquals("/api/v2/home/sections", request.url.encodedPath) - val second = if (stage == "concurrent") """,{"content_id":"movie/b","type":"movie","title":"Second","progress_updated_at":"$currentStamp"}""" else "" - respond("""{"sections":[{"id":"row","section_type":"continue_watching","title":"Row","items":[{"content_id":"movie/a","type":"episode","title":"$title","series_id":"series:a","progress_updated_at":"$currentStamp"}$second]}]}""", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + reads++ + // In "refresh" the re-read after the stale acknowledgement fails, so + // what remains on screen is the local state alone. + if (stage == "refresh" && reads == 3) { + return@addHandler respond("{}", HttpStatusCode.ServiceUnavailable, headersOf(HttpHeaders.ContentType, "application/json")) + } + val items = listOfNotNull( + """{"content_id":"movie/a","type":"episode","title":"$title","series_id":"series:a","progress_updated_at":"$currentStamp"}""".takeIf { "a" !in dismissedOnServer }, + """{"content_id":"movie/b","type":"movie","title":"Second","progress_updated_at":"$currentStamp"}""".takeIf { stage == "concurrent" && "b" !in dismissedOnServer }, + ) + val sections = if (items.isEmpty()) "" else """{"id":"row","section_type":"continue_watching","title":"Row","items":[${items.joinToString(",")}]}""" + respond("""{"sections":[$sections]}""", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) } } })) { install(ContentNegotiation) { json(PrairieJson) } } @@ -94,8 +108,18 @@ class HomeDismissalV2Test { } release.complete(Unit); runCurrent() when (stage) { - "success", "next", "late", "concurrent" -> assertTrue(vm.uiState.value.sections.isEmpty()) - "refresh" -> assertEquals("New observation", vm.uiState.value.sections.single().items.first().title) + // One re-read after the last dismissal settles, and it agrees with the removal. + "success", "next", "concurrent" -> { + assertTrue(vm.uiState.value.sections.isEmpty()) + assertEquals(2, reads) + } + "late" -> assertTrue(vm.uiState.value.sections.isEmpty()) + // The stale acknowledgement removes nothing locally, and Home still + // re-reads (here the read fails), since the server accepted it. + "refresh" -> { + assertEquals("New observation", vm.uiState.value.sections.single().items.first().title) + assertEquals(3, reads) + } else -> assertEquals("Original", vm.uiState.value.sections.single().items.first().title) } }