Fix Home navigation ownership and merge watch lanes
This commit is contained in:
parent
c2f7ccc438
commit
12c201dc55
41 changed files with 671 additions and 307 deletions
|
|
@ -44,12 +44,14 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
|||
@Composable
|
||||
fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
||||
val shell = state.shell
|
||||
val view = screenView(shell)
|
||||
val entryActive = LocalScreenActive.current
|
||||
val focus = remember(state) { object : ShellContentFocus by state {} }
|
||||
DisposableEffect(focus) {
|
||||
shell.contentFocus = focus
|
||||
DisposableEffect(focus, entryActive) {
|
||||
if (entryActive) shell.contentFocus = focus
|
||||
onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } }
|
||||
}
|
||||
LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt }
|
||||
LaunchedEffect(view, state.snapshot.savedAt) { view.savedAt = state.snapshot.savedAt }
|
||||
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
|
||||
val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column)
|
||||
val ratingsReader = LocalScreenRatings.current
|
||||
|
|
@ -78,7 +80,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
|||
return
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val list = shell.view.listState
|
||||
val list = view.listState
|
||||
val note = state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)
|
||||
val heights = remember(state.hasHero, state.lanes, note, state.hasCatalogue) {
|
||||
buildList {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ data class HomeTitle(
|
|||
val held: Boolean = false,
|
||||
val otherOwner: String? = null,
|
||||
val tmdbId: Int? = null,
|
||||
val seriesId: String? = null,
|
||||
val lastPlayedAt: Long? = null,
|
||||
) {
|
||||
val isEpisode: Boolean get() = type == "episode"
|
||||
val displayTitle: String get() = seriesTitle ?: title
|
||||
|
|
|
|||
|
|
@ -18,12 +18,13 @@ fun buildHomeRows(
|
|||
): List<HomeRow> = buildList {
|
||||
if (loading) {
|
||||
add(HomeRow("hero-sk", HomeRowKind.Hero, skeleton = true))
|
||||
listOf(HomeCardKind.Tile, HomeCardKind.Tile, HomeCardKind.Poster, HomeCardKind.Poster).forEachIndexed { index, kind ->
|
||||
listOf(HomeCardKind.Tile, HomeCardKind.Tile, HomeCardKind.Poster).forEachIndexed { index, kind ->
|
||||
add(HomeRow("sk$index", HomeRowKind.Shelf, cardKind = kind, skeleton = true))
|
||||
}
|
||||
return@buildList
|
||||
}
|
||||
val choice = when {
|
||||
// Hero precedence remains independent of the merged shelf's recency order.
|
||||
library.resume.isNotEmpty() -> "resume" to library.resume.first()
|
||||
library.nextUp.isNotEmpty() -> "next" to library.nextUp.first()
|
||||
library.newest.isNotEmpty() -> "new" to library.newest.first()
|
||||
|
|
@ -34,10 +35,10 @@ fun buildHomeRows(
|
|||
if (!libraryAvailable) {
|
||||
add(HomeRow("jf-down", HomeRowKind.State, "Your library can’t be reached", text = "Jellyfin isn’t answering, so nothing can play right now."))
|
||||
} else {
|
||||
listOf(Triple("cw", "Continue watching", library.resume), Triple("nu", "Next up", library.nextUp), Triple("new", "New in your library", library.newest)).forEach { (id, title, source) ->
|
||||
val cards = source.filter { it.id != hero?.id }
|
||||
if (cards.isNotEmpty()) add(HomeRow(id, HomeRowKind.Shelf, title, cards, if (id == "cw") HomeCardKind.Wide else HomeCardKind.Tile))
|
||||
}
|
||||
val watching = continueWatching(library).filter { it.watchIdentity != hero?.watchIdentity }
|
||||
if (watching.isNotEmpty()) add(HomeRow("cw", HomeRowKind.Shelf, "Continue watching", watching, HomeCardKind.Tile))
|
||||
val newest = library.newest.filter { it.id != hero?.id }
|
||||
if (newest.isNotEmpty()) add(HomeRow("new", HomeRowKind.Shelf, "New in your library", newest, HomeCardKind.Tile))
|
||||
listOf("movies", "shows", "anime").forEach { destination ->
|
||||
val titles = library.destinations[destination].orEmpty().sortedWith(compareBy<HomeTitle> { it.played }.thenByDescending { it.addedAt })
|
||||
if (titles.isNotEmpty()) add(HomeRow("lib-$destination", HomeRowKind.Shelf, if (destination == "anime") "Anime" else "Your $destination", titles.take(WolfskinTokens.Behaviour.rowLimit.toInt()), total = library.destinationTotals[destination] ?: titles.size, target = destination))
|
||||
|
|
@ -47,6 +48,27 @@ fun buildHomeRows(
|
|||
addAll(continuation)
|
||||
}
|
||||
|
||||
/** Prefer a resumable episode per series, then sort the chosen items by actual watch activity. */
|
||||
internal fun continueWatching(library: HomeLibrary): List<HomeTitle> {
|
||||
val chosen = linkedMapOf<String, HomeTitle>()
|
||||
fun choose(source: List<HomeTitle>) {
|
||||
for (title in source) {
|
||||
val key = title.watchIdentity
|
||||
val previous = chosen[key]
|
||||
if (previous == null || (previous.resumeSeconds <= 0 && title.resumeSeconds > 0) ||
|
||||
((previous.resumeSeconds > 0) == (title.resumeSeconds > 0) && (title.lastPlayedAt ?: Long.MIN_VALUE) > (previous.lastPlayedAt ?: Long.MIN_VALUE))) {
|
||||
chosen[key] = title
|
||||
}
|
||||
}
|
||||
}
|
||||
choose(library.resume)
|
||||
choose(library.nextUp)
|
||||
return chosen.values.sortedByDescending { it.lastPlayedAt }
|
||||
}
|
||||
|
||||
private val HomeTitle.watchIdentity: String
|
||||
get() = if (isEpisode) seriesId?.let { "series:$it" } ?: seriesTitle?.let { "series-name:$it" } ?: id else id
|
||||
|
||||
/** Authentication failure is distinct from an unreachable signed-in catalogue. */
|
||||
fun homeDiscoverState(status: HomeAvailability): HomeRow = if (status == HomeAvailability.SignedOut) {
|
||||
HomeRow("signin", HomeRowKind.State, "Discover needs sign-in", text = "Sign this TV in to Conductarr to see picks and request titles. Your library plays as usual.", calm = true)
|
||||
|
|
|
|||
|
|
@ -29,11 +29,12 @@ fun HomeScreen(
|
|||
onAction: (HomeAction) -> Unit,
|
||||
) {
|
||||
val view = shell.rememberedView("home")
|
||||
val active = LocalScreenActive.current
|
||||
LaunchedEffect(view, snapshot.savedAt) { view.savedAt = snapshot.savedAt }
|
||||
val action by rememberUpdatedState(onAction)
|
||||
val focus = remember(shell, snapshot.rows, view) { HomeFocus(shell, snapshot.rows) { action(it) } }
|
||||
DisposableEffect(shell, focus) {
|
||||
shell.contentFocus = focus
|
||||
DisposableEffect(shell, focus, active) {
|
||||
if (active) shell.contentFocus = focus
|
||||
onDispose { if (shell.contentFocus === focus) shell.contentFocus = null }
|
||||
}
|
||||
LaunchedEffect(snapshot.rows, view) {
|
||||
|
|
|
|||
|
|
@ -86,9 +86,10 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
|
|||
private fun TileOverlay(title: HomeTitle) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) {
|
||||
val metadata = WolfskinTokens.CardMetadata
|
||||
Column(Modifier.padding(bottom = metadata.bottom + metadata.separator + metadata.resumeLine).fillMaxWidth()
|
||||
.background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f))))
|
||||
.padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) {
|
||||
Column(Modifier.fillMaxWidth()
|
||||
.background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = WolfskinTokens.Title.cardOverlayOpacity))))
|
||||
.padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop,
|
||||
bottom = H.cardOverlayBottom + metadata.bottom + metadata.separator + metadata.resumeLine)) {
|
||||
Text(title.displayTitle, modifier = Modifier.exactLineBox(metadata.titleFocus), color = C.fg, style = metadata.titleFocus, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,10 +39,11 @@ fun LibraryScreen(
|
|||
cachedRatings: Set<String> = emptySet(),
|
||||
ratingsReachable: Boolean = true,
|
||||
offlineArt: ((String) -> ImageBitmap?)? = null,
|
||||
view: RememberedView = shell.view,
|
||||
view: RememberedView = screenView(shell),
|
||||
registerFocus: Boolean = true,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val entryActive = LocalScreenActive.current
|
||||
val context = LocalContext.current
|
||||
val active = !shell.navFocused && !shell.pickerVisible
|
||||
val items = grid.items
|
||||
|
|
@ -50,8 +51,8 @@ fun LibraryScreen(
|
|||
val count = if (grid.catalogue) items.size else grid.source.size
|
||||
var plateKey by remember { mutableStateOf<String?>(null) }
|
||||
val ratingsReader = LocalScreenRatings.current
|
||||
DisposableEffect(shell, grid, registerFocus) {
|
||||
if (registerFocus) shell.contentFocus = grid
|
||||
DisposableEffect(shell, grid, registerFocus, entryActive) {
|
||||
if (registerFocus && entryActive) shell.contentFocus = grid
|
||||
onDispose { if (registerFocus && shell.contentFocus === grid) shell.contentFocus = null }
|
||||
}
|
||||
LaunchedEffect(grid) {
|
||||
|
|
|
|||
|
|
@ -86,10 +86,11 @@ class PersonState(initialPerson: TitlePerson, initialCredits: List<TitleItem>, p
|
|||
/** Portrait, biography and the full continuous grid use the shell's remembered frame. */
|
||||
@Composable
|
||||
fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
val view = shell.view
|
||||
val view = screenView(shell)
|
||||
val entryActive = LocalScreenActive.current
|
||||
LaunchedEffect(view, state.savedAt) { view.savedAt = state.savedAt }
|
||||
val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } }
|
||||
LaunchedEffect(state) { shell.navFocused = false }
|
||||
LaunchedEffect(state, entryActive) { if (entryActive) shell.navFocused = false }
|
||||
Box(modifier.fillMaxSize().background(C.bg)) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) {
|
||||
ShellSurface(shell, handlesContentKeys = false) { PersonPage(state, shell, it) }
|
||||
|
|
@ -101,7 +102,8 @@ fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Mo
|
|||
@Composable
|
||||
private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
val density = LocalDensity.current
|
||||
val list = shell.view.listState
|
||||
val list = screenView(shell).listState
|
||||
val entryActive = LocalScreenActive.current
|
||||
var wheelPending by remember { mutableStateOf(false) }
|
||||
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
|
||||
LaunchedEffect(state.index / columns, shell.navFocused) {
|
||||
|
|
@ -127,7 +129,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
|
|||
if (key != ShellKey.Ok) return@onPreviewKeyEvent true
|
||||
}
|
||||
key?.let { state.key(it, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) } ?: false
|
||||
}.focusable()) {
|
||||
}.focusable(enabled = entryActive)) {
|
||||
LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
|
||||
item(key = "person-header", contentType = "header") {
|
||||
Box(Modifier.height(L.row_phead)) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
internal val LocalScreenView = compositionLocalOf<RememberedView?> { null }
|
||||
internal val LocalScreenActive = compositionLocalOf { true }
|
||||
|
||||
/** Overlapping navigation entries retain their own viewport; only the current entry owns input. */
|
||||
@Composable
|
||||
fun ScreenEntry(shell: ShellState, route: String, active: Boolean, content: @Composable () -> Unit) {
|
||||
remember(shell, shell.person.key, route) { shell.prepareDestinationView(route) }
|
||||
CompositionLocalProvider(LocalScreenView provides shell.rememberedView(route), LocalScreenActive provides active, content = content)
|
||||
}
|
||||
|
||||
/** Direct surface previews use the current view when no navigation entry is installed. */
|
||||
@Composable
|
||||
internal fun screenView(shell: ShellState): RememberedView = LocalScreenView.current ?: shell.view
|
||||
|
|
@ -116,6 +116,9 @@ class ShellState(
|
|||
fun rememberedView(destination: String): RememberedView =
|
||||
views[person.key to destination] ?: RememberedView()
|
||||
|
||||
/** Register an entry before composition; rememberedView also serves read-only snapshot observers. */
|
||||
internal fun prepareDestinationView(destination: String) { retainView(destination) }
|
||||
|
||||
/** External intents and upstream pops remain authoritative over the screen. */
|
||||
fun enterDestination(destination: String) {
|
||||
retainView(destination)
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ fun ShellSurface(
|
|||
) {
|
||||
val focus = remember { FocusRequester() }
|
||||
val contentFocus = remember { FocusRequester() }
|
||||
val active = LocalScreenActive.current
|
||||
val view = screenView(state)
|
||||
val focusManager = LocalFocusManager.current
|
||||
LaunchedEffect(state.toast) {
|
||||
val message = state.toast
|
||||
|
|
@ -69,30 +71,32 @@ fun ShellSurface(
|
|||
if (state.toast == message) state.toast = null
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) {
|
||||
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
|
||||
else contentFocus.requestFocus()
|
||||
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys, active) {
|
||||
if (active) {
|
||||
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
|
||||
else contentFocus.requestFocus()
|
||||
}
|
||||
}
|
||||
Box(modifier.fillMaxSize().background(if (handlesContentKeys || state.pickerVisible) C.bg else Color.Transparent).testTag("wolfskin-shell")
|
||||
.semantics { stateDescription = "${state.person.key}:${state.route}:${if (state.pickerVisible) "picker" else if (state.navFocused) "nav-${state.navIndex}" else "content-${state.settingsRow}"}" }
|
||||
.focusRequester(focus)
|
||||
.onPreviewKeyEvent {
|
||||
if (state.pickerVisible || state.navFocused || state.requestsVisible || handlesContentKeys) state.handleKey(it) else false
|
||||
if (active && (state.pickerVisible || state.navFocused || state.requestsVisible || handlesContentKeys)) state.handleKey(it) else false
|
||||
}
|
||||
.onKeyEvent {
|
||||
if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) {
|
||||
if (active && it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) {
|
||||
if (!focusManager.moveFocus(FocusDirection.Up)) {
|
||||
state.navFocused = true
|
||||
state.navIndex = state.currentTab
|
||||
}
|
||||
true
|
||||
} else false
|
||||
}.focusable()) {
|
||||
}.focusable(enabled = active)) {
|
||||
if (state.pickerVisible) {
|
||||
WhosWatching(state)
|
||||
} else {
|
||||
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
||||
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
||||
NavBar(state, view.listState.firstVisibleItemIndex > 0 || view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
||||
if (state.requestsVisible) ComingInSheet(state)
|
||||
overlay()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
|||
/** The shared shell remains authoritative; title sheets overlay it and trap all input. */
|
||||
@Composable
|
||||
fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
val view = shell.view
|
||||
val view = screenView(shell)
|
||||
LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt }
|
||||
Box(modifier.fillMaxSize().onPreviewKeyEvent { event ->
|
||||
// No content target exists during an initial load; nav Down must not enter a skeleton.
|
||||
|
|
@ -87,15 +87,16 @@ internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDism
|
|||
@Composable
|
||||
fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
val density = LocalDensity.current
|
||||
val list = shell.view.listState
|
||||
val list = screenView(shell).listState
|
||||
val entryActive = LocalScreenActive.current
|
||||
var wheelPending by remember { mutableStateOf(false) }
|
||||
val focus = remember { FocusRequester() }
|
||||
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
|
||||
val compactSeries = state.data.status == TitleReadStatus.Ready && state.data.item.held &&
|
||||
state.data.item.kind == "tv" && state.data.item.seasons > 0
|
||||
val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead
|
||||
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
|
||||
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
|
||||
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible, entryActive) {
|
||||
if (entryActive && state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
|
||||
}
|
||||
val displayedRows = remember(state.data) {
|
||||
buildList {
|
||||
|
|
@ -143,7 +144,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
|||
if (key != ShellKey.Ok) return@onPreviewKeyEvent true
|
||||
}
|
||||
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||
}.focusable()) {
|
||||
}.focusable(enabled = entryActive)) {
|
||||
// This remains visible behind the scrolled rows; it is not an off-screen header image.
|
||||
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
|
||||
Box(Modifier.fillMaxSize().drawBehind { drawRect(C.bg.copy(alpha = dim)) })
|
||||
|
|
|
|||
|
|
@ -586,7 +586,7 @@ object WolfskinTokens {
|
|||
val factsGap = 11.0.dp
|
||||
val overviewBottom = 15.0.dp
|
||||
val cardPadding = 11.0.dp
|
||||
val cardOverlayTop = 27.0.dp
|
||||
val cardOverlayTop = 18.0.dp
|
||||
val cardOverlayBottom = 9.0.dp
|
||||
val sectionTop = 20.0.dp
|
||||
val sectionAvatar = 28.0.dp
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue