Fix TV episode navigation and live resume freshness

This commit is contained in:
justinzeus 2026-11-06 22:41:16 +01:00
parent 54c23e076a
commit 8e40ac0869
34 changed files with 793 additions and 101 deletions

View file

@ -69,7 +69,7 @@ class HomeFocus(
private fun activate(row: HomeRow) {
when (row.kind) {
HomeRowKind.Hero -> row.cards.firstOrNull()?.let { title ->
onAction(if (column == 0 && row.heroKind != "pick") HomeAction.Play(title, row.heroKind == "resume") else HomeAction.Details(title))
onAction(if (column == 0 && row.heroKind != "pick" && row.watchReady) HomeAction.Play(title) else HomeAction.Details(title))
}
HomeRowKind.Shelf -> if (column < row.cards.size) onAction(HomeAction.Details(row.cards[column])) else onAction(HomeAction.SeeAll(row))
HomeRowKind.State -> onAction(HomeAction.Retry)

View file

@ -43,9 +43,10 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
Text(title.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
Spacer(Modifier.height(H.overviewBottom))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
val primary = if (row.heroKind == "pick") "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) }
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
val detailsOnly = row.heroKind == "pick" || !row.watchReady
val primary = if (detailsOnly) "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
PickerButton(primary, focused && column == 0, true, if (detailsOnly) "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) }
if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
}
}
} else {

View file

@ -67,9 +67,10 @@ data class HomeRow(
val skeleton: Boolean = false,
val calm: Boolean = false,
val allCards: List<HomeTitle> = cards,
val watchReady: Boolean = true,
) {
val hasSeeAll: Boolean get() = total > cards.size
val count: Int get() = when (kind) { HomeRowKind.Hero -> if (heroKind == "pick") 1 else 2; HomeRowKind.Shelf -> cards.size + if (hasSeeAll) 1 else 0; HomeRowKind.State, HomeRowKind.Explore -> 1; else -> 0 }
val count: Int get() = when (kind) { HomeRowKind.Hero -> if (heroKind == "pick" || !watchReady) 1 else 2; HomeRowKind.Shelf -> cards.size + if (hasSeeAll) 1 else 0; HomeRowKind.State, HomeRowKind.Explore -> 1; else -> 0 }
val focusable: Boolean get() = !skeleton && count > 0
}
@ -84,7 +85,7 @@ data class HomeSnapshot(
/** Home emits integration intents, never owns the upstream player or later-slice pages. */
sealed interface HomeAction {
data class Play(val title: HomeTitle, val resume: Boolean) : HomeAction
data class Play(val title: HomeTitle) : HomeAction
data class Details(val title: HomeTitle) : HomeAction
data class SeeAll(val row: HomeRow) : HomeAction
data object Discover : HomeAction

View file

@ -91,6 +91,7 @@ data class TitlePageData(
val episodesStatus: TitleReadStatus = TitleReadStatus.Ready,
val requestsAllowed: Boolean = true,
val requestFor: String? = null,
val watchReady: Boolean = true,
)
/** Effects are handled by the app's authenticated facade and navigation layer. */
@ -101,7 +102,7 @@ sealed interface TitleAction {
data class Watched(val item: TitleItem, val watched: Boolean) : TitleAction
data class Trailer(val url: String) : TitleAction
data class Person(val person: TitlePerson) : TitleAction
data class OpenTitle(val item: TitleItem) : TitleAction
data class OpenTitle(val item: TitleItem, val episodeSelection: Boolean = false) : TitleAction
data class Season(val number: Int) : TitleAction
data class Request(val item: TitleItem, val forSomeoneElse: Boolean) : TitleAction
data class Hide(val item: TitleItem, val hidden: Boolean) : TitleAction

View file

@ -182,7 +182,12 @@ private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) {
@Composable
private fun TitleSeasons(state: TitleState, active: Boolean) {
val focusOwner = LocalTitleFocusOwner.current
androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
val list = androidx.compose.foundation.lazy.rememberLazyListState()
val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season)
LaunchedEffect(visibleChip, state.data.seasonNumbers) {
if (visibleChip >= 0) list.animateScrollToItem(visibleChip)
}
androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
items(state.data.seasonNumbers.size, key = { "season-${state.data.seasonNumbers[it]}" }, contentType = { "chip" }) { index ->
val number = state.data.seasonNumbers[index]
val focused = active && state.column == index

View file

@ -31,6 +31,8 @@ class TitleState(
var watched by mutableStateOf(initial.watchTarget.watched)
var toast by mutableStateOf<String?>(null)
private var armedCard: String? = null
private var seasonChosen = false
val requestedSeason: Int? get() = season.takeIf { seasonChosen }
private var okHeld = false
val target: TitleItem? get() = data.playTarget ?: data.item.takeIf { it.kind == "movie" && it.held }
val version: TitleVersion? get() = data.versions.firstOrNull { it.id == versionId } ?: data.versions.firstOrNull()
@ -56,7 +58,7 @@ class TitleState(
val pendingAudio = audioIndex != data.selectedAudio
val pendingSubtitle = subtitleIndex != data.selectedSubtitle
data = value
if (wasLoading || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1
if (wasLoading || (!seasonChosen && !columns.containsKey("seasons")) || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1
if (!pendingWatched) watched = value.watchTarget.watched
if (!pendingVersion) versionId = value.selectedVersion
if (!pendingAudio) audioIndex = value.selectedAudio
@ -152,7 +154,8 @@ class TitleState(
val next = rows[index]
val x = centers[row]?.get(column)
val nearest = if (x != null) centers[next]?.entries?.minByOrNull { kotlin.math.abs(it.value - x) }?.key else null
focus(next, (columns[next] ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0)))
val selected = if (next == "seasons") data.seasonNumbers.indexOf(season).takeIf { it >= 0 } else null
focus(next, (columns[next] ?: selected ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0)))
}
private fun rowSize(id: String): Int = when (id) {
@ -168,15 +171,15 @@ class TitleState(
when (row) {
"cast" -> data.cast.getOrNull(column)?.let { onAction(TitleAction.Person(it)) }
"related" -> data.related.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) }
"episodes" -> data.episodes.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) }
"seasons" -> { season = data.seasonNumbers[column]; onAction(TitleAction.Season(season)) }
"episodes" -> data.episodes.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it, episodeSelection = true)) }
"seasons" -> { seasonChosen = true; season = data.seasonNumbers[column]; onAction(TitleAction.Season(season)) }
"header" -> options.getOrNull(column)?.let { activateOption(it.id) }
}
}
private fun activateOption(id: String) {
when (id) {
"play", "restart" -> target?.let { onAction(TitleAction.Play(it, id == "restart")) }
"play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) }
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
"more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo }
@ -260,7 +263,8 @@ fun titleOptions(state: TitleState): List<TitleOption> = buildList {
return@buildList
}
if (item.held) {
if (target != null) {
if (!data.watchReady) add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
if (target != null && data.watchReady) {
val verb = if (target.resumeSeconds > 0) "Resume" else "Play"
add(TitleOption("play", verb + if (target.kind == "episode") " ${target.episodeLabel}" else "", "play", primary = true, icon = false))
if (target.resumeSeconds > 0) add(TitleOption("restart", "Restart", "restart", "play from the beginning"))

View file

@ -36,8 +36,8 @@ class HomeKeysTest {
for (key in 0 until keys.length()) press(keys.getString(key))
assertEquals(path.getString("goal"), path.getInt("presses"), keys.length())
when (index) {
0 -> { assertEquals("mindel", shell.person.key); assertTrue(action is HomeAction.Play); assertTrue((action as HomeAction.Play).resume) }
1 -> { assertTrue(action is HomeAction.Play); assertFalse((action as HomeAction.Play).resume); assertEquals("Yu Yu Hakusho", (action as HomeAction.Play).title.seriesTitle) }
0 -> { assertEquals("mindel", shell.person.key); assertTrue(action is HomeAction.Play) }
1 -> { assertTrue(action is HomeAction.Play); assertEquals("Yu Yu Hakusho", (action as HomeAction.Play).title.seriesTitle) }
2 -> assertEquals("trending", fixture.snapshot("justin").rows[shell.view.row].id)
3 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("trending", row.id); assertEquals(row.cards.size, shell.view.cards[row.id]); assertTrue(row.hasSeeAll) }
4 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("lib-shows", row.id); assertEquals(2, shell.view.cards[row.id]); assertFalse(shell.navFocused) }
@ -170,7 +170,6 @@ class HomeKeysTest {
assertEquals(0, shell.view.cards["hero"])
press("OK")
val play = action as HomeAction.Play
assertFalse(play.resume)
assertEquals("Yu Yu Hakusho", play.title.seriesTitle)
}