This commit is contained in:
ponzischeme89
2026-08-27 11:04:51 +12:00
parent b0b8990f90
commit 6b2bf767e2
9 changed files with 167 additions and 50 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?) val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO" ?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
val defaultVersionName = "0.3.33" val defaultVersionName = "0.3.34"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -16,7 +16,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
@@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -323,6 +324,13 @@ internal fun DetailPageScaffold(
playFocusRequester: FocusRequester, playFocusRequester: FocusRequester,
tabFocusRequester: FocusRequester, tabFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester, contentFocusRequester: FocusRequester,
/**
* Owned by the caller, like every other focus target here — the one exception used to be
* created inside this scaffold, which meant a page could not also hand it to
* [RestoreDetailFocus] as the control that should hold focus on the very first frame.
* Defaults for pages that never set [backNavigation] at all.
*/
backNavigationFocusRequester: FocusRequester = remember(item.id) { FocusRequester() },
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
tabs: List<DetailTab> = emptyList(), tabs: List<DetailTab> = emptyList(),
selectedTab: DetailTab = DetailTab.OVERVIEW, selectedTab: DetailTab = DetailTab.OVERVIEW,
@@ -393,7 +401,6 @@ internal fun DetailPageScaffold(
List(6) { FocusRequester() } List(6) { FocusRequester() }
} }
val actionRequesters = allActionRequesters.take(heroActions.size) val actionRequesters = allActionRequesters.take(heroActions.size)
val backNavigationFocusRequester = remember(item.id) { FocusRequester() }
var lastHeroIndex by remember(item.id) { mutableIntStateOf(-1) } var lastHeroIndex by remember(item.id) { mutableIntStateOf(-1) }
var focusedZone by remember(item.id) { mutableStateOf(DetailZone.PLAY) } var focusedZone by remember(item.id) { mutableStateOf(DetailZone.PLAY) }
// Reported on the way *in* to a band, never on every focus move inside one. The pane's // Reported on the way *in* to a band, never on every focus move inside one. The pane's
@@ -862,6 +869,7 @@ private fun DetailHero(
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
DetailHeroActions( DetailHeroActions(
itemId = item.id,
playLabel = playLabel, playLabel = playLabel,
onPlay = onPlay, onPlay = onPlay,
playFocusRequester = playFocusRequester, playFocusRequester = playFocusRequester,
@@ -875,7 +883,7 @@ private fun DetailHero(
onPlayFocused() onPlayFocused()
}, },
onBackNavigationFocused = { onBackNavigationFocused = {
actionCaption = null actionCaption = backNavigation?.label
onPlayFocused() onPlayFocused()
}, },
onActionFocused = { index -> onActionFocused = { index ->
@@ -897,6 +905,7 @@ private fun DetailHero(
*/ */
@Composable @Composable
private fun DetailHeroActions( private fun DetailHeroActions(
itemId: String,
playLabel: String, playLabel: String,
onPlay: () -> Unit, onPlay: () -> Unit,
playFocusRequester: FocusRequester, playFocusRequester: FocusRequester,
@@ -912,29 +921,49 @@ private fun DetailHeroActions(
Column { Column {
// Scrollable rather than merely wide: a Row with more content than the hero has // Scrollable rather than merely wide: a Row with more content than the hero has
// room for does not shrink its children to fit — each button keeps the intrinsic // room for does not shrink its children to fit — each button keeps the intrinsic
// size [MembySecondaryButton]/[MembyPlayButton]/[DetailCircularAction] give it, and // size [MembyPlayButton]/[DetailCircularAction] give it, and it is the row's own
// it is the row's own bounds that give way. Without this, the contextual "Back to // bounds that give way. Scrolling is the one fix that already generalises: any page
// Search Results" button competing for space with Play and the circular actions // with enough actions to overflow (the franchise "Start with…" action included)
// could push the row past the width the hero has to offer, and a child measured // gets the same safety net.
// with less room than it needs does not politely shrink — it distorts. Scrolling is //
// also the one fix that already generalises: any page with enough actions to // The back-navigation control is a circular icon action, not a labelled pill: a
// overflow (the franchise "Start with…" action included) gets the same safety net, // "Back to Search Results" pill was wide enough that bringing a freshly focused
// not a special case for this one contextual button. // Play into view scrolled the row past zero, leaving the pill's rounded corner
val actionsScroll = rememberScrollState() // sheared off against Play's own edge — reading as a deformed Play button rather
// than a clipped neighbour. A 48dp circle, the same size every other secondary
// action here uses, never has to compete with Play for room in the first place.
//
// Keyed on the item: unkeyed, the scroll offset a wide row needed for one title
// would survive into the next composed in the same slot, opening it with Play
// already scrolled out of view for no reason of its own.
val actionsScroll = remember(itemId) { ScrollState(0) }
Row( Row(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
// Cancels the leading Spacer below, so the first button still rests at
// the same x it always has — only the room around it changes.
.offset(x = -ActionsRowEdgeInset)
.horizontalScroll(actionsScroll) .horizontalScroll(actionsScroll)
.focusGroup(), .focusGroup(),
) { ) {
// `horizontalScroll` clips its content to its own left/right bounds so that
// scrolled-past content is actually hidden — and the leftmost button sits
// flush against that left edge. On focus it grows a few percent around its own
// centre (plus a wider elevation shadow), so without this gap its left edge
// was sheared off by the container's clip rather than by anything about the
// button itself. The gap is scrollable content, so it sits inside the clip
// boundary rather than just moving the boundary, and the offset above puts the
// resting row back where it was before the gap was added.
Spacer(Modifier.width(ActionsRowEdgeInset))
if (backNavigation != null) { if (backNavigation != null) {
MembySecondaryButton( DetailCircularAction(
label = backNavigation.label, action = DetailHeroAction(
onClick = backNavigation.onClick, icon = MembyIcon.ArrowBack.mark,
description = backNavigation.label,
onClick = backNavigation.onClick,
),
onFocused = onBackNavigationFocused, onFocused = onBackNavigationFocused,
compact = true,
icon = MembyIcon.ArrowBack,
modifier = Modifier modifier = Modifier
.testTag("detail-back-to-search") .testTag("detail-back-to-search")
.focusRequester(backNavigationFocusRequester), .focusRequester(backNavigationFocusRequester),
@@ -953,6 +982,9 @@ private fun DetailHeroActions(
modifier = Modifier.focusRequester(actionRequesters[index]), modifier = Modifier.focusRequester(actionRequesters[index]),
) )
} }
// The same gap at the far end, for a row that overflows and is scrolled all
// the way to its last action.
Spacer(Modifier.width(ActionsRowEdgeInset))
} }
// Reserved, not conditional: a line that appears when focus reaches the second // Reserved, not conditional: a line that appears when focus reaches the second
// button would move the whole hero every time somebody pressed Right. // button would move the whole hero every time somebody pressed Right.
@@ -973,6 +1005,12 @@ private fun DetailHeroActions(
/** Reserved for the focused action's description; see [DetailHeroActions]. */ /** Reserved for the focused action's description; see [DetailHeroActions]. */
private val DetailActionCaptionHeight = 22.dp private val DetailActionCaptionHeight = 22.dp
/**
* Room at each end of the actions row's scrollable content, clear of the horizontal-scroll
* clip boundary — see the comment where it's used in [DetailHeroActions].
*/
private val ActionsRowEdgeInset = 16.dp
/** /**
* The hero synopsis' line box, shared by the text style and by [wholeLines]. * The hero synopsis' line box, shared by the text style and by [wholeLines].
* *
@@ -546,25 +546,34 @@ internal fun EpisodeSeasonPane(
seasonEpisodes.isEmpty() -> DetailFocusablePane(emptyFocusRequester) { seasonEpisodes.isEmpty() -> DetailFocusablePane(emptyFocusRequester) {
Text("No episodes are available for this season.", color = DetailQuietText, fontSize = 15.sp) Text("No episodes are available for this season.", color = DetailQuietText, fontSize = 15.sp)
} }
else -> LazyColumn( else -> {
state = listState, // The list is scrolled to the *current* episode, not to the top of the season —
verticalArrangement = Arrangement.spacedBy(10.dp), // Reacher's Continue Watching card is rarely S01E01. The entry requester has to
contentPadding = PaddingValues(end = 12.dp, bottom = 18.dp), // follow that scroll target, or it names a row the LazyColumn never composes and
modifier = Modifier.fillMaxSize(), // every attempt to focus into the list (initial restore, Down from the strip or
) { // the hero) throws and silently falls through instead of landing on a real card.
itemsIndexed(seasonEpisodes, key = { _, episode -> episode.id }) { index, episode -> val entryIndex = seasonEpisodes.indexOfFirst { it.id == currentEpisodeId }
EpisodeCard( .let { if (it >= 0) it else 0 }
episode = episode, LazyColumn(
onClick = { onPlay(episode) }, state = listState,
seasonFocusRequester = aboveEpisodes, verticalArrangement = Arrangement.spacedBy(10.dp),
isFirst = index == 0, contentPadding = PaddingValues(end = 12.dp, bottom = 18.dp),
isCurrent = episode.id == currentEpisodeId, modifier = Modifier.fillMaxSize(),
modifier = if (index == 0) { ) {
Modifier.focusRequester(firstEpisodeFocusRequester) itemsIndexed(seasonEpisodes, key = { _, episode -> episode.id }) { index, episode ->
} else { EpisodeCard(
Modifier episode = episode,
}, onClick = { onPlay(episode) },
) seasonFocusRequester = aboveEpisodes,
isFirst = index == 0,
isCurrent = episode.id == currentEpisodeId,
modifier = if (index == entryIndex) {
Modifier.focusRequester(firstEpisodeFocusRequester)
} else {
Modifier
},
)
}
} }
} }
} }
@@ -167,6 +167,7 @@ internal fun MediaDetailContent(
val selectedTab = detailTab(tabKey, tabs) val selectedTab = detailTab(tabKey, tabs)
val play = remember(item.id) { FocusRequester() } val play = remember(item.id) { FocusRequester() }
val backToSearch = remember(item.id) { FocusRequester() }
val tabStrip = remember(item.id) { FocusRequester() } val tabStrip = remember(item.id) { FocusRequester() }
// One requester per pane. Sharing a single "information pane" requester between two // One requester per pane. Sharing a single "information pane" requester between two
// panes attached it to two nodes at once for the 80ms AnimatedContent spends fading the // panes attached it to two nodes at once for the 80ms AnimatedContent spends fading the
@@ -211,6 +212,7 @@ internal fun MediaDetailContent(
relatedReady = visibleRelated.isNotEmpty(), relatedReady = visibleRelated.isNotEmpty(),
content = contentEntry, content = contentEntry,
contentReady = true, contentReady = true,
heroEntry = if (onBackToSearch != null) backToSearch else play,
) )
var confirmation by remember(item.id) { mutableStateOf<String?>(null) } var confirmation by remember(item.id) { mutableStateOf<String?>(null) }
@@ -253,6 +255,7 @@ internal fun MediaDetailContent(
backNavigation = onBackToSearch?.let { backNavigation = onBackToSearch?.let {
DetailBackNavigation(label = "Back to Search Results", onClick = it) DetailBackNavigation(label = "Back to Search Results", onClick = it)
}, },
backNavigationFocusRequester = backToSearch,
confirmation = confirmation, confirmation = confirmation,
onZoneFocused = { zone -> onZoneFocused = { zone ->
focusedZone = zone focusedZone = zone
@@ -420,10 +423,17 @@ internal fun RestoreDetailFocus(
relatedReady: Boolean, relatedReady: Boolean,
content: FocusRequester? = null, content: FocusRequester? = null,
contentReady: Boolean = false, contentReady: Boolean = false,
/**
* What actually holds focus on the first frame — Play, unless the page opened with a
* contextual back action (`DetailBackNavigation`) ahead of it, in which case that is
* the control the remote should land on. Defaults to [play] for every page that has
* nothing to put before it.
*/
heroEntry: FocusRequester = play,
) { ) {
LaunchedEffect(itemId) { LaunchedEffect(itemId) {
delay(32L) delay(32L)
runCatching { play.requestFocus() } runCatching { heroEntry.requestFocus() }
} }
// One shot. The readiness flags flip when the network lands, and re-running then would // One shot. The readiness flags flip when the network lands, and re-running then would
// haul focus out from under a viewer who has already started moving around the page. // haul focus out from under a viewer who has already started moving around the page.
@@ -317,6 +317,7 @@ internal fun SeriesDetailContent(
val selectedTab = detailTab(tabKey, tabs) val selectedTab = detailTab(tabKey, tabs)
val play = remember(item.id) { FocusRequester() } val play = remember(item.id) { FocusRequester() }
val backToSearch = remember(item.id) { FocusRequester() }
val tabStrip = remember(item.id) { FocusRequester() } val tabStrip = remember(item.id) { FocusRequester() }
// One requester per pane: they used to share a single "information pane" requester, // One requester per pane: they used to share a single "information pane" requester,
// which left it attached to two live nodes while AnimatedContent faded the outgoing one // which left it attached to two live nodes while AnimatedContent faded the outgoing one
@@ -378,6 +379,7 @@ internal fun SeriesDetailContent(
relatedReady = visibleRelated.isNotEmpty(), relatedReady = visibleRelated.isNotEmpty(),
content = contentEntry, content = contentEntry,
contentReady = true, contentReady = true,
heroEntry = if (onBackToSearch != null) backToSearch else play,
) )
var confirmation by remember(item.id) { mutableStateOf<String?>(null) } var confirmation by remember(item.id) { mutableStateOf<String?>(null) }
@@ -430,6 +432,7 @@ internal fun SeriesDetailContent(
backNavigation = onBackToSearch?.let { backNavigation = onBackToSearch?.let {
DetailBackNavigation(label = "Back to Search Results", onClick = it) DetailBackNavigation(label = "Back to Search Results", onClick = it)
}, },
backNavigationFocusRequester = backToSearch,
onZoneFocused = { zone -> onZoneFocused = { zone ->
focusedZone = zone focusedZone = zone
detailPositions.update(item.id) { it.copy(zone = zone) } detailPositions.update(item.id) { it.copy(zone = zone) }
@@ -332,6 +332,7 @@ fun SearchScreen(
) { ) {
SearchGenres( SearchGenres(
genres = state.genreSuggestions, genres = state.genreSuggestions,
selectedGenre = state.genre,
entryFocusRequester = genresEntry, entryFocusRequester = genresEntry,
keyboardReturnFocusRequester = keyboardReturn, keyboardReturnFocusRequester = keyboardReturn,
resultsFocusRequester = searchResultsEntry, resultsFocusRequester = searchResultsEntry,
@@ -13,6 +13,7 @@ import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.data.normaliseDiscoveryQuery import com.ponzischeme89.memby.data.normaliseDiscoveryQuery
import com.ponzischeme89.memby.data.shouldDiscover import com.ponzischeme89.memby.data.shouldDiscover
import com.ponzischeme89.memby.ui.distinctForKeys
import com.ponzischeme89.memby.ui.distinctItems import com.ponzischeme89.memby.ui.distinctItems
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
@@ -59,6 +60,15 @@ data class SearchUiState(
* and would return the same cards again, for ever. * and would return the same cards again, for ever.
*/ */
val genreOffset: Int = 0, val genreOffset: Int = 0,
/**
* How many titles this genre actually holds, from the backend's own count — never
* [SearchUiState.results]' size. The heading shows this rather than how much has
* loaded so far, or a genre with hundreds of titles reads as having exactly 48 the
* moment the first page lands, which looks identical to "that is all of them" and
* only ever corrects itself if somebody happens to scroll far enough to notice it
* change. Zero before a page has answered.
*/
val genreTotal: Int = 0,
/** There is more of this genre to ask for. See [hasMoreGenreItems]. */ /** There is more of this genre to ask for. See [hasMoreGenreItems]. */
val canLoadMore: Boolean = false, val canLoadMore: Boolean = false,
/** /**
@@ -263,7 +273,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
genrePageJob?.cancel() genrePageJob?.cancel()
_state.update { _state.update {
it.copy( it.copy(
query = "", results = emptyList(), genreOffset = 0, query = "", results = emptyList(), genreOffset = 0, genreTotal = 0,
isLoading = false, hasSearched = false, isLoading = false, hasSearched = false,
errorMessage = null, errorMessage = null,
pagingErrorMessage = null, pagingErrorMessage = null,
@@ -285,7 +295,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
genrePageJob?.cancel() genrePageJob?.cancel()
_state.update { _state.update {
it.copy( it.copy(
query = "", genre = name, results = emptyList(), genreOffset = 0, query = "", genre = name, results = emptyList(), genreOffset = 0, genreTotal = 0,
isLoading = true, isLoading = true,
hasSearched = false, errorMessage = null, isLoadingMore = false, hasSearched = false, errorMessage = null, isLoadingMore = false,
canLoadMore = false, canLoadMore = false,
@@ -305,7 +315,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
genrePageJob?.cancel() genrePageJob?.cancel()
_state.update { _state.update {
it.copy( it.copy(
genre = null, results = emptyList(), genreOffset = 0, genre = null, results = emptyList(), genreOffset = 0, genreTotal = 0,
isLoading = false, hasSearched = false, isLoading = false, hasSearched = false,
errorMessage = null, isLoadingMore = false, canLoadMore = false, errorMessage = null, isLoadingMore = false, canLoadMore = false,
pagingErrorMessage = null, pagingErrorMessage = null,
@@ -447,6 +457,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
current.copy( current.copy(
results = items, results = items,
genreOffset = read, genreOffset = read,
genreTotal = page.total,
isLoading = false, isLoading = false,
isLoadingMore = false, isLoadingMore = false,
hasSearched = true, hasSearched = true,
@@ -494,7 +505,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
// than left behind a shorter query they no longer match. // than left behind a shorter query they no longer match.
_state.update { _state.update {
it.copy( it.copy(
results = emptyList(), genreOffset = 0, results = emptyList(), genreOffset = 0, genreTotal = 0,
isLoading = false, hasSearched = false, isLoading = false, hasSearched = false,
errorMessage = null, errorMessage = null,
requestMessage = null, requestMessage = null,
@@ -527,11 +538,21 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
// here. Drop it unless the field still asks the exact question that // here. Drop it unless the field still asks the exact question that
// produced the answer. // produced the answer.
if (!_state.value.answers(term)) return@onSuccess if (!_state.value.answers(term)) return@onSuccess
// Emby ids are enough to satisfy distinctItems(), but the results list is
// rendered keyed on cross-provider identity (searchResultKey in
// SearchResults.kt, the same shape as searchIdentity below) so that a title
// the library and Sonarr/Radarr both know about is one card, not two. Two
// distinct Emby items sharing one TMDb id — a duplicate library import is
// the common case — survive distinctItems() but collide on that render key,
// which crashes the LazyColumn ("deduplicate, never disambiguate" in
// ui/ListKeys.kt). Applied here, at the point the data enters state, rather
// than in the composable.
val deduped = found.distinctItems().distinctForKeys(::searchIdentity)
// Keep the backend's relevance and personalisation order, but never let // Keep the backend's relevance and personalisation order, but never let
// those softer signals bury the title the viewer typed exactly. This // those softer signals bury the title the viewer typed exactly. This
// matters especially in the one-column list, where tenth place is ten rows // matters especially in the one-column list, where tenth place is ten rows
// away rather than the second grid row. // away rather than the second grid row.
val ranked = rankSearchResults(term, found.distinctItems()) val ranked = rankSearchResults(term, deduped)
// A decisive exact match must read as decisive: see [curateStrongMatches]. // A decisive exact match must read as decisive: see [curateStrongMatches].
val curated = curateStrongMatches(term, ranked) val curated = curateStrongMatches(term, ranked)
SearchTrace.local(id, term, curated.size, cacheHit = false, SearchTrace.local(id, term, curated.size, cacheHit = false,
@@ -575,7 +596,10 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
val startedAt = System.currentTimeMillis() val startedAt = System.currentTimeMillis()
runCatching { repository.discover(term, DISCOVERY_LIMIT) } runCatching { repository.discover(term, DISCOVERY_LIMIT) }
.onSuccess { result -> .onSuccess { result ->
val items = result.items.distinctItems() // Same render key, same failure mode as the library list above: Sonarr and
// Radarr can each offer a candidate for one film, and both surviving
// distinctItems() only means their (often synthetic) ids differ.
val items = result.items.distinctItems().distinctForKeys(::searchIdentity)
SearchTrace.discovery(state.value.searchId, term, items.size, SearchTrace.discovery(state.value.searchId, term, items.size,
cacheHit = false, partial = result.partial, cacheHit = false, partial = result.partial,
elapsedMs = System.currentTimeMillis() - startedAt) elapsedMs = System.currentTimeMillis() - startedAt)
@@ -27,12 +27,21 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.ponzischeme89.memby.ui.FocusScaleContainer import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyOnSurface import com.ponzischeme89.memby.ui.theme.MembyOnSurface
/** Search's compact discovery strip. Genre colours stay recognisable but sit near-black. */ /** Search's compact discovery strip. Genre colours stay recognisable but sit near-black. */
@Composable @Composable
internal fun SearchGenres( internal fun SearchGenres(
genres: List<String>, genres: List<String>,
/**
* The genre currently open below the strip, or null while browsing. Focus is
* momentary — it moves the instant the remote does, most obviously the moment the
* shelf takes it on load — but which genre is *open* is a fact that outlives that
* move, and a chip that only lit up while literally under the D-pad gave no answer
* to "which genre am I looking at" the moment somebody scrolled into the results.
*/
selectedGenre: String?,
entryFocusRequester: FocusRequester, entryFocusRequester: FocusRequester,
keyboardReturnFocusRequester: FocusRequester, keyboardReturnFocusRequester: FocusRequester,
resultsFocusRequester: FocusRequester, resultsFocusRequester: FocusRequester,
@@ -67,16 +76,28 @@ internal fun SearchGenres(
.then(if (index == 0) Modifier.focusRequester(entryFocusRequester) else Modifier) .then(if (index == 0) Modifier.focusRequester(entryFocusRequester) else Modifier)
.focusProperties { .focusProperties {
if (index == 0) left = keyboardReturnFocusRequester if (index == 0) left = keyboardReturnFocusRequester
if (resultsHaveFocusTarget) down = resultsFocusRequester // Explicit either way: with nothing below to search results for
// (the empty/browsing state, before anything has been typed),
// leaving `down` unset hands the press to Compose's default
// nearest-neighbour search, which can land on an arbitrary
// keyboard key or fail to move at all. The keyboard's own
// "last key used" target is the one every other escape from
// this strip already returns to.
down = if (resultsHaveFocusTarget) resultsFocusRequester else keyboardReturnFocusRequester
}, },
) { focused -> ) { focused ->
val selected = !focused && genre == selectedGenre
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(if (focused) colour.copy(alpha = 0.98f) else colour) .background(if (focused) colour.copy(alpha = 0.98f) else colour)
.border( .border(
if (focused) 2.dp else 1.dp, if (focused || selected) 2.dp else 1.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.10f), when {
focused -> Color.White
selected -> MembyAccent
else -> Color.White.copy(alpha = 0.10f)
},
GenreShape, GenreShape,
) )
.padding(horizontal = 11.dp, vertical = 9.dp), .padding(horizontal = 11.dp, vertical = 9.dp),
@@ -92,7 +113,11 @@ internal fun SearchGenres(
) )
Text( Text(
"", "",
color = Color.White.copy(alpha = if (focused) 1f else 0.72f), color = when {
focused -> Color.White
selected -> MembyAccent
else -> Color.White.copy(alpha = 0.72f)
},
fontSize = 22.sp, fontSize = 22.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
modifier = Modifier.align(Alignment.CenterEnd), modifier = Modifier.align(Alignment.CenterEnd),
@@ -374,9 +374,16 @@ private fun SearchResultsHeading(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false), modifier = Modifier.weight(1f, fill = false),
) )
if (resultCount > 0 && !state.isLoading) { // A genre shelf's count is the backend's own total, never how much has loaded —
// a genre holding hundreds of titles would otherwise read as holding exactly 48
// the moment the first page lands, indistinguishable from that genuinely being
// all of them, and only ever correct itself if somebody scrolls far enough to
// notice it change underneath them. A plain search has no such distinction: the
// backend answers everything at once, so what loaded is the whole count.
val displayedCount = if (state.genre != null) state.genreTotal else resultCount
if (displayedCount > 0 && !state.isLoading) {
Spacer(Modifier.width(9.dp)) Spacer(Modifier.width(9.dp))
Text(resultCount.toString(), color = MembyMutedText, fontSize = 14.sp) Text(displayedCount.toString(), color = MembyMutedText, fontSize = 14.sp)
} }
if (state.isLoading && resultCount > 0) { if (state.isLoading && resultCount > 0) {
Spacer(Modifier.width(9.dp)) Spacer(Modifier.width(9.dp))