From 6e367fa723ace6803fc046011bcc0b8214912ba8 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:47:09 -0400 Subject: [PATCH 1/3] fix(home): let phones remove Next Up cards from Home (#632) * fix(home): let phones remove Next Up cards from Home Phone and tablet Home offered no remove action on Next Up cards, in the separate Next Up row or in the combined Continue Watching row: the row only wired removal for cards with a resume point. Android TV offered it but called it "Remove from Continue Watching". A shared HomeDismissal now picks each card's list the way Apple and TV already did: a card with progress_updated_at is removed from Continue Watching, otherwise a card with a series_id is removed from Next Up, and a card with neither gets no remove action. The menu names that list, so Next Up cards read "Remove from Next Up" on phone, tablet, and TV, in both rows. On /api/v2 the server drops the whole show when one of its episodes is dismissed, so Home now removes every card of that series from the Continue Watching, In Progress, and Next Up rows and from the cached Home, then re-reads Home from the server as Mark as Watched does. A failed re-read keeps the local removal. Movies and books still go one card at a time. Fixes #629 Co-Authored-By: Claude Opus 5.5 * fix(home): re-read Home after every accepted dismissal The re-read after a dismissal was armed only after the local removal and cache rewrite, so it was skipped when Home had moved on while the request ran (a refresh or realtime read landed first) or when the cache write failed, though the server had already dropped the show. Arm it as soon as the server accepts the dismissal for the same profile. The surface test no longer asserts menu wording. Co-Authored-By: Claude Opus 5.5 * fix(home): re-read only for the profile that dismissed, and test the stale case honestly Arm the re-read only while the dismissing profile still holds Home, so a profile switch during the request doesn't add a read for the new profile. The stale-acknowledgement test now fails the re-read, so it proves both that the old acknowledgement removes nothing locally and that Home still re-reads. Before, the re-read restored the card and would have hidden a wrong local removal. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../android/ui/components/MediaCardActions.kt | 10 +- .../android/ui/screens/home/HomeScreen.kt | 6 +- .../android/ui/screens/home/HomeSectionRow.kt | 11 +- .../tv/ui/components/TvMediaCardActions.kt | 16 +- .../silo/tv/ui/screens/home/TvHomeScreen.kt | 23 +-- .../silo/viewmodel/HomeDismissal.kt | 42 +++++ .../silo/viewmodel/HomeViewModel.kt | 64 ++++++- .../viewmodel/HomeDismissalSeriesDropTest.kt | 158 ++++++++++++++++++ .../silo/viewmodel/HomeDismissalV2Test.kt | 32 +++- 9 files changed, 314 insertions(+), 48 deletions(-) create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeDismissal.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeDismissalSeriesDropTest.kt diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MediaCardActions.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MediaCardActions.kt index 81bde93d6a..891fca0ee3 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/components/MediaCardActions.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/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/siloserver/silo/android/ui/screens/home/HomeScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/home/HomeScreen.kt index 99402f1d2e..8a09506956 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/home/HomeScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/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/siloserver/silo/android/ui/screens/home/HomeSectionRow.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/home/HomeSectionRow.kt index fdba9ef7f2..27ebadfc4c 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/home/HomeSectionRow.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/home/HomeSectionRow.kt @@ -9,6 +9,7 @@ import org.siloserver.silo.android.ui.components.MediaCardActions import org.siloserver.silo.android.ui.components.MediaRow import org.siloserver.silo.model.section.ResolvedSection import org.siloserver.silo.model.section.SectionItem +import org.siloserver.silo.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/siloserver/silo/tv/ui/components/TvMediaCardActions.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMediaCardActions.kt index 3bab936954..c5c19e41b8 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/components/TvMediaCardActions.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/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/siloserver/silo/tv/ui/screens/home/TvHomeScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/home/TvHomeScreen.kt index e8b2cd8ebd..31e32339dd 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/home/TvHomeScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/home/TvHomeScreen.kt @@ -43,6 +43,7 @@ import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts import org.siloserver.silo.tv.ui.focus.TvObservedFocusResult import org.siloserver.silo.tv.ui.focus.claimFocusOrReport import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved +import org.siloserver.silo.viewmodel.HomeDismissal import org.siloserver.silo.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/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeDismissal.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeDismissal.kt new file mode 100644 index 0000000000..a6980c8ce0 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeDismissal.kt @@ -0,0 +1,42 @@ +package org.siloserver.silo.viewmodel + +import org.siloserver.silo.model.catalog.isEpisodeItemType +import org.siloserver.silo.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/siloserver/silo/viewmodel/HomeViewModel.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeViewModel.kt index 74d10622d0..23f9031f81 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/viewmodel/HomeViewModel.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/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/siloserver/silo/viewmodel/HomeDismissalSeriesDropTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeDismissalSeriesDropTest.kt new file mode 100644 index 0000000000..9fd9a55561 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeDismissalSeriesDropTest.kt @@ -0,0 +1,158 @@ +package org.siloserver.silo.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.siloserver.silo.domain.MediaActionsCoordinator +import org.siloserver.silo.model.section.ResolvedSection +import org.siloserver.silo.model.section.SectionItem +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.SiloJson +import org.siloserver.silo.network.TokenManager +import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.api.PersonalDataApi +import org.siloserver.silo.network.api.SectionApi +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.network.apiv2.HomeSectionsV2Api +import org.siloserver.silo.repository.PersonalDataRepository +import org.siloserver.silo.repository.SectionRepository +import org.siloserver.silo.repository.port.HomeCachePort +import org.siloserver.silo.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://silo.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 = SiloJson.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(SiloJson) } } + 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/siloserver/silo/viewmodel/HomeDismissalV2Test.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeDismissalV2Test.kt index d970ff850a..ce9bbab5a2 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/viewmodel/HomeDismissalV2Test.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/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(SiloJson) } } @@ -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) } } From 0ceab7e38e22dd14878db5e31aca361af3fb2f4a Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:05:00 -0400 Subject: [PATCH 2/3] fix(tv): show air dates on the stored day in every time zone (#639) * fix(tv): show air dates on the stored day in every time zone The server sends air and release dates as calendar dates (yyyy-MM-dd). TvDetailMetadata.abbreviatedDate read them as UTC midnight and converted to the device zone, so the episode hero, the episode rail, and the Aired fact row showed the previous day west of UTC. The Released and First Aired rows and the phone app already kept the stored day. A bare date is now that day everywhere, matching tvOS DetailDateFormatting; RFC 3339 timestamps still convert to the device zone. Co-Authored-By: Claude Opus 5.5 * docs(tv): describe the timestamp forms abbreviatedDate accepts Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../ui/screens/detail/TvDetailEpisodeRail.kt | 3 +-- .../tv/ui/screens/detail/TvDetailMetadata.kt | 25 ++++++++----------- .../ui/screens/detail/TvDetailMetadataTest.kt | 13 +++++----- 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt index 8d6c1537a9..c3c8337dd5 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt @@ -627,8 +627,7 @@ private fun episodeRuntime(runtimeMinutes: Int): String? { } } -// 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/siloserver/silo/tv/ui/screens/detail/TvDetailMetadata.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailMetadata.kt index de32a358ac..35220b39fb 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailMetadata.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/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/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailMetadataTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailMetadataTest.kt index 9d93c90af7..8403d80731 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailMetadataTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/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"), From b468e2a952244451a93b2fc0104004d5c1ecb700 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:36:23 -0400 Subject: [PATCH 3/3] feat(episodes): give every episode card the iPhone caption (#644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tv): show air dates on the stored day in every time zone The server sends air and release dates as calendar dates (yyyy-MM-dd). TvDetailMetadata.abbreviatedDate read them as UTC midnight and converted to the device zone, so the episode hero, the episode rail, and the Aired fact row showed the previous day west of UTC. The Released and First Aired rows and the phone app already kept the stored day. A bare date is now that day everywhere, matching tvOS DetailDateFormatting; RFC 3339 timestamps still convert to the device zone. Co-Authored-By: Claude Opus 5.5 * docs(tv): describe the timestamp forms abbreviatedDate accepts Co-Authored-By: Claude Opus 5.5 * feat(episodes): give every episode card the iPhone caption Phone, tablet, and foldable episode cards show NOW VIEWING, "S01E02 · Title", the air date and runtime, and up to three lines of description, the caption the iPhone card uses. Every card keeps the same lines, so the titles line up and a rail keeps its height as it scrolls. Android TV episode cards show "S01E02 · Title" over the air date and runtime, as on Apple TV; the focused episode's description stays in the hero. At the Large card size the Series hero starts 16dp higher so the taller rail stays on the first screen. Episode cards no longer read the card caption setting, matching iOS. Co-Authored-By: Claude Opus 5.5 * fix(tv): keep episode descriptions on rails outside the Series page A season or episode page that stays up because its series can't be loaded shows its own item in the hero, so focusing another episode in its rail didn't show that episode's description anywhere. Cards on those rails keep three lines of description, hidden by spoiler protection as before; Series cards still leave it to the hero. Co-Authored-By: Claude Opus 5.5 * fix(tv): mark translated descriptions on rails outside the Series page Cards on those rails show the episode description again, so they show the compact "Translated by AI" marker beside the date line when that description is machine-translated, as the phone card and the TV hero do. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../android/ui/screens/detail/EpisodeList.kt | 148 +++++------ .../ui/screens/detail/SeasonEpisodePager.kt | 11 +- .../ui/screens/detail/SeriesDetailContent.kt | 1 - .../ui/screens/detail/TvDetailEpisodeRail.kt | 251 ++++++++---------- .../silo/tv/ui/screens/detail/TvDetailHero.kt | 15 +- .../ui/screens/detail/TvItemDetailScreen.kt | 1 - .../org/siloserver/silo/tv/ui/theme/Type.kt | 11 - .../screens/detail/TvDetailFocusPolicyTest.kt | 3 +- .../ui/theme/TvTypographyReadabilityTest.kt | 1 + .../silo/model/catalog/EpisodeCardCaption.kt | 32 +++ 10 files changed, 223 insertions(+), 251 deletions(-) create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/EpisodeCardCaption.kt diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/EpisodeList.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/EpisodeList.kt index 00efc7b6af..09661e3006 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/EpisodeList.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/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.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs import org.siloserver.silo.common.ui.components.SpoilerImage import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage +import org.siloserver.silo.model.catalog.EpisodeCardCaption import org.siloserver.silo.model.catalog.EpisodeListItem import org.siloserver.silo.model.catalog.hasMachineTranslatedOverview import org.siloserver.silo.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, + ) + } } SiloDropdownMenu( 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/siloserver/silo/android/ui/screens/detail/SeasonEpisodePager.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeasonEpisodePager.kt index 54e08abedc..7e5fc76822 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeasonEpisodePager.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/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/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt index e8e49d5750..6f4e67db7e 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/SeriesDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/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/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt index c3c8337dd5..b8bb14e1b9 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/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.siloserver.silo.common.cards.LocalCardPresentation import org.siloserver.silo.common.cards.LocalEpisodeSpoilerPrefs import org.siloserver.silo.common.ui.components.SpoilerImage import org.siloserver.silo.common.ui.components.DefaultArtworkKind import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.catalog.EpisodeListItem +import org.siloserver.silo.model.catalog.EpisodeCardCaption import org.siloserver.silo.model.catalog.hasMachineTranslatedOverview import org.siloserver.silo.model.settings.EpisodeSpoilers import org.siloserver.silo.tv.ui.components.TvMediaCardActions @@ -82,16 +83,15 @@ import org.siloserver.silo.tv.ui.theme.SiloOnSurface import org.siloserver.silo.tv.ui.theme.SiloSecondaryText import org.siloserver.silo.tv.ui.theme.DarkSurfaceElevated import org.siloserver.silo.tv.ui.theme.ProgressFill -import org.siloserver.silo.tv.ui.theme.capsuleCaps +import org.siloserver.silo.tv.ui.theme.InterFamily import org.siloserver.silo.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,143 +488,101 @@ 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) SiloOnSurface else SiloOnSurface.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 = SiloOnSurface.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 = SiloOnSurface.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 = SiloOnSurface.copy(alpha = 0.7f), - maxLines = 1, - ) - Text( - text = inlineTitle, - modifier = Modifier.weight(1f), - fontSize = 11.5.sp, - lineHeight = eyebrowLineHeight, - fontWeight = FontWeight.SemiBold, - color = SiloOnSurface.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 -> SiloOnSurface - isFocused -> SiloOnSurface - else -> SiloOnSurface.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 = SiloOnSurface.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 = SiloSecondaryText, - 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 = SiloSecondaryText, + 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 with the hero facts row (tvOS parity), so the card dates agree with it. private fun abbreviatedEpisodeDate(raw: String?): String? = diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt index 71ffd00859..4607f27478 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/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.siloserver.silo.common.cards.LocalCardPresentation +import org.siloserver.silo.model.settings.CardPosterSize import org.siloserver.silo.common.settings.titleLogoUrl import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.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/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt index 3b8647ae26..32b8311e80 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/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/siloserver/silo/tv/ui/theme/Type.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/theme/Type.kt index 7bd02290c5..d915e37d43 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/theme/Type.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/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/siloserver/silo/tv/ui/screens/detail/TvDetailFocusPolicyTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailFocusPolicyTest.kt index d19a09f9c3..ec19ca40db 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvDetailFocusPolicyTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/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/siloserver/silo/tv/ui/theme/TvTypographyReadabilityTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/theme/TvTypographyReadabilityTest.kt index 0682f425f0..54dce661fd 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/theme/TvTypographyReadabilityTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/theme/TvTypographyReadabilityTest.kt @@ -19,6 +19,7 @@ class TvTypographyReadabilityTest { "org/siloserver/silo/tv/ui/screens/detail/TvDetailHero.kt|fontSize = 12.sp,", "org/siloserver/silo/tv/ui/components/TvSquaredButtons.kt|val titleStyle = TextStyle(fontSize = 13.sp, fontWeight = FontWeight.SemiBold)", "org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt|fontSize = 11.5.sp,", + "org/siloserver/silo/tv/ui/screens/detail/TvDetailEpisodeRail.kt|fontSize = 10.5.sp,", "org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt|fontSize = 11.5.sp,", "org/siloserver/silo/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/siloserver/silo/model/catalog/EpisodeCardCaption.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/EpisodeCardCaption.kt new file mode 100644 index 0000000000..b5fb483511 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/catalog/EpisodeCardCaption.kt @@ -0,0 +1,32 @@ +package org.siloserver.silo.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" + } +}