Fix TV episode navigation and live resume freshness
This commit is contained in:
parent
54c23e076a
commit
8e40ac0869
34 changed files with 793 additions and 101 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue