0.3.34
This commit is contained in:
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
|
||||
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
||||
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
||||
|
||||
val defaultVersionName = "0.3.33"
|
||||
val defaultVersionName = "0.3.34"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -16,7 +16,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.focusable
|
||||
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.Box
|
||||
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.heightIn
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -323,6 +324,13 @@ internal fun DetailPageScaffold(
|
||||
playFocusRequester: FocusRequester,
|
||||
tabFocusRequester: 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,
|
||||
tabs: List<DetailTab> = emptyList(),
|
||||
selectedTab: DetailTab = DetailTab.OVERVIEW,
|
||||
@@ -393,7 +401,6 @@ internal fun DetailPageScaffold(
|
||||
List(6) { FocusRequester() }
|
||||
}
|
||||
val actionRequesters = allActionRequesters.take(heroActions.size)
|
||||
val backNavigationFocusRequester = remember(item.id) { FocusRequester() }
|
||||
var lastHeroIndex by remember(item.id) { mutableIntStateOf(-1) }
|
||||
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
|
||||
@@ -862,6 +869,7 @@ private fun DetailHero(
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
DetailHeroActions(
|
||||
itemId = item.id,
|
||||
playLabel = playLabel,
|
||||
onPlay = onPlay,
|
||||
playFocusRequester = playFocusRequester,
|
||||
@@ -875,7 +883,7 @@ private fun DetailHero(
|
||||
onPlayFocused()
|
||||
},
|
||||
onBackNavigationFocused = {
|
||||
actionCaption = null
|
||||
actionCaption = backNavigation?.label
|
||||
onPlayFocused()
|
||||
},
|
||||
onActionFocused = { index ->
|
||||
@@ -897,6 +905,7 @@ private fun DetailHero(
|
||||
*/
|
||||
@Composable
|
||||
private fun DetailHeroActions(
|
||||
itemId: String,
|
||||
playLabel: String,
|
||||
onPlay: () -> Unit,
|
||||
playFocusRequester: FocusRequester,
|
||||
@@ -912,29 +921,49 @@ private fun DetailHeroActions(
|
||||
Column {
|
||||
// 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
|
||||
// size [MembySecondaryButton]/[MembyPlayButton]/[DetailCircularAction] give it, and
|
||||
// it is the row's own bounds that give way. Without this, the contextual "Back to
|
||||
// Search Results" button competing for space with Play and the circular actions
|
||||
// could push the row past the width the hero has to offer, and a child measured
|
||||
// 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
|
||||
// overflow (the franchise "Start with…" action included) gets the same safety net,
|
||||
// not a special case for this one contextual button.
|
||||
val actionsScroll = rememberScrollState()
|
||||
// size [MembyPlayButton]/[DetailCircularAction] give it, and it is the row's own
|
||||
// bounds that give way. Scrolling is the one fix that already generalises: any page
|
||||
// with enough actions to overflow (the franchise "Start with…" action included)
|
||||
// gets the same safety net.
|
||||
//
|
||||
// The back-navigation control is a circular icon action, not a labelled pill: a
|
||||
// "Back to Search Results" pill was wide enough that bringing a freshly focused
|
||||
// Play into view scrolled the row past zero, leaving the pill's rounded corner
|
||||
// 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(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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)
|
||||
.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) {
|
||||
MembySecondaryButton(
|
||||
label = backNavigation.label,
|
||||
DetailCircularAction(
|
||||
action = DetailHeroAction(
|
||||
icon = MembyIcon.ArrowBack.mark,
|
||||
description = backNavigation.label,
|
||||
onClick = backNavigation.onClick,
|
||||
),
|
||||
onFocused = onBackNavigationFocused,
|
||||
compact = true,
|
||||
icon = MembyIcon.ArrowBack,
|
||||
modifier = Modifier
|
||||
.testTag("detail-back-to-search")
|
||||
.focusRequester(backNavigationFocusRequester),
|
||||
@@ -953,6 +982,9 @@ private fun DetailHeroActions(
|
||||
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
|
||||
// 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]. */
|
||||
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].
|
||||
*
|
||||
|
||||
@@ -546,7 +546,15 @@ internal fun EpisodeSeasonPane(
|
||||
seasonEpisodes.isEmpty() -> DetailFocusablePane(emptyFocusRequester) {
|
||||
Text("No episodes are available for this season.", color = DetailQuietText, fontSize = 15.sp)
|
||||
}
|
||||
else -> LazyColumn(
|
||||
else -> {
|
||||
// The list is scrolled to the *current* episode, not to the top of the season —
|
||||
// Reacher's Continue Watching card is rarely S01E01. The entry requester has to
|
||||
// follow that scroll target, or it names a row the LazyColumn never composes and
|
||||
// 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.
|
||||
val entryIndex = seasonEpisodes.indexOfFirst { it.id == currentEpisodeId }
|
||||
.let { if (it >= 0) it else 0 }
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
contentPadding = PaddingValues(end = 12.dp, bottom = 18.dp),
|
||||
@@ -559,7 +567,7 @@ internal fun EpisodeSeasonPane(
|
||||
seasonFocusRequester = aboveEpisodes,
|
||||
isFirst = index == 0,
|
||||
isCurrent = episode.id == currentEpisodeId,
|
||||
modifier = if (index == 0) {
|
||||
modifier = if (index == entryIndex) {
|
||||
Modifier.focusRequester(firstEpisodeFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
@@ -569,3 +577,4 @@ internal fun EpisodeSeasonPane(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@ internal fun MediaDetailContent(
|
||||
val selectedTab = detailTab(tabKey, tabs)
|
||||
|
||||
val play = remember(item.id) { FocusRequester() }
|
||||
val backToSearch = remember(item.id) { FocusRequester() }
|
||||
val tabStrip = remember(item.id) { FocusRequester() }
|
||||
// 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
|
||||
@@ -211,6 +212,7 @@ internal fun MediaDetailContent(
|
||||
relatedReady = visibleRelated.isNotEmpty(),
|
||||
content = contentEntry,
|
||||
contentReady = true,
|
||||
heroEntry = if (onBackToSearch != null) backToSearch else play,
|
||||
)
|
||||
|
||||
var confirmation by remember(item.id) { mutableStateOf<String?>(null) }
|
||||
@@ -253,6 +255,7 @@ internal fun MediaDetailContent(
|
||||
backNavigation = onBackToSearch?.let {
|
||||
DetailBackNavigation(label = "Back to Search Results", onClick = it)
|
||||
},
|
||||
backNavigationFocusRequester = backToSearch,
|
||||
confirmation = confirmation,
|
||||
onZoneFocused = { zone ->
|
||||
focusedZone = zone
|
||||
@@ -420,10 +423,17 @@ internal fun RestoreDetailFocus(
|
||||
relatedReady: Boolean,
|
||||
content: FocusRequester? = null,
|
||||
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) {
|
||||
delay(32L)
|
||||
runCatching { play.requestFocus() }
|
||||
runCatching { heroEntry.requestFocus() }
|
||||
}
|
||||
// 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.
|
||||
|
||||
@@ -317,6 +317,7 @@ internal fun SeriesDetailContent(
|
||||
val selectedTab = detailTab(tabKey, tabs)
|
||||
|
||||
val play = remember(item.id) { FocusRequester() }
|
||||
val backToSearch = remember(item.id) { FocusRequester() }
|
||||
val tabStrip = remember(item.id) { FocusRequester() }
|
||||
// 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
|
||||
@@ -378,6 +379,7 @@ internal fun SeriesDetailContent(
|
||||
relatedReady = visibleRelated.isNotEmpty(),
|
||||
content = contentEntry,
|
||||
contentReady = true,
|
||||
heroEntry = if (onBackToSearch != null) backToSearch else play,
|
||||
)
|
||||
|
||||
var confirmation by remember(item.id) { mutableStateOf<String?>(null) }
|
||||
@@ -430,6 +432,7 @@ internal fun SeriesDetailContent(
|
||||
backNavigation = onBackToSearch?.let {
|
||||
DetailBackNavigation(label = "Back to Search Results", onClick = it)
|
||||
},
|
||||
backNavigationFocusRequester = backToSearch,
|
||||
onZoneFocused = { zone ->
|
||||
focusedZone = zone
|
||||
detailPositions.update(item.id) { it.copy(zone = zone) }
|
||||
|
||||
@@ -332,6 +332,7 @@ fun SearchScreen(
|
||||
) {
|
||||
SearchGenres(
|
||||
genres = state.genreSuggestions,
|
||||
selectedGenre = state.genre,
|
||||
entryFocusRequester = genresEntry,
|
||||
keyboardReturnFocusRequester = keyboardReturn,
|
||||
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.normaliseDiscoveryQuery
|
||||
import com.ponzischeme89.memby.data.shouldDiscover
|
||||
import com.ponzischeme89.memby.ui.distinctForKeys
|
||||
import com.ponzischeme89.memby.ui.distinctItems
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
@@ -59,6 +60,15 @@ data class SearchUiState(
|
||||
* and would return the same cards again, for ever.
|
||||
*/
|
||||
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]. */
|
||||
val canLoadMore: Boolean = false,
|
||||
/**
|
||||
@@ -263,7 +273,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
genrePageJob?.cancel()
|
||||
_state.update {
|
||||
it.copy(
|
||||
query = "", results = emptyList(), genreOffset = 0,
|
||||
query = "", results = emptyList(), genreOffset = 0, genreTotal = 0,
|
||||
isLoading = false, hasSearched = false,
|
||||
errorMessage = null,
|
||||
pagingErrorMessage = null,
|
||||
@@ -285,7 +295,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
genrePageJob?.cancel()
|
||||
_state.update {
|
||||
it.copy(
|
||||
query = "", genre = name, results = emptyList(), genreOffset = 0,
|
||||
query = "", genre = name, results = emptyList(), genreOffset = 0, genreTotal = 0,
|
||||
isLoading = true,
|
||||
hasSearched = false, errorMessage = null, isLoadingMore = false,
|
||||
canLoadMore = false,
|
||||
@@ -305,7 +315,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
genrePageJob?.cancel()
|
||||
_state.update {
|
||||
it.copy(
|
||||
genre = null, results = emptyList(), genreOffset = 0,
|
||||
genre = null, results = emptyList(), genreOffset = 0, genreTotal = 0,
|
||||
isLoading = false, hasSearched = false,
|
||||
errorMessage = null, isLoadingMore = false, canLoadMore = false,
|
||||
pagingErrorMessage = null,
|
||||
@@ -447,6 +457,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
current.copy(
|
||||
results = items,
|
||||
genreOffset = read,
|
||||
genreTotal = page.total,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
hasSearched = true,
|
||||
@@ -494,7 +505,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
// than left behind a shorter query they no longer match.
|
||||
_state.update {
|
||||
it.copy(
|
||||
results = emptyList(), genreOffset = 0,
|
||||
results = emptyList(), genreOffset = 0, genreTotal = 0,
|
||||
isLoading = false, hasSearched = false,
|
||||
errorMessage = 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
|
||||
// produced the answer.
|
||||
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
|
||||
// those softer signals bury the title the viewer typed exactly. This
|
||||
// matters especially in the one-column list, where tenth place is ten rows
|
||||
// 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].
|
||||
val curated = curateStrongMatches(term, ranked)
|
||||
SearchTrace.local(id, term, curated.size, cacheHit = false,
|
||||
@@ -575,7 +596,10 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
runCatching { repository.discover(term, DISCOVERY_LIMIT) }
|
||||
.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,
|
||||
cacheHit = false, partial = result.partial,
|
||||
elapsedMs = System.currentTimeMillis() - startedAt)
|
||||
|
||||
@@ -27,12 +27,21 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.ui.FocusScaleContainer
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
|
||||
/** Search's compact discovery strip. Genre colours stay recognisable but sit near-black. */
|
||||
@Composable
|
||||
internal fun SearchGenres(
|
||||
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,
|
||||
keyboardReturnFocusRequester: FocusRequester,
|
||||
resultsFocusRequester: FocusRequester,
|
||||
@@ -67,16 +76,28 @@ internal fun SearchGenres(
|
||||
.then(if (index == 0) Modifier.focusRequester(entryFocusRequester) else Modifier)
|
||||
.focusProperties {
|
||||
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 ->
|
||||
val selected = !focused && genre == selectedGenre
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (focused) colour.copy(alpha = 0.98f) else colour)
|
||||
.border(
|
||||
if (focused) 2.dp else 1.dp,
|
||||
if (focused) Color.White else Color.White.copy(alpha = 0.10f),
|
||||
if (focused || selected) 2.dp else 1.dp,
|
||||
when {
|
||||
focused -> Color.White
|
||||
selected -> MembyAccent
|
||||
else -> Color.White.copy(alpha = 0.10f)
|
||||
},
|
||||
GenreShape,
|
||||
)
|
||||
.padding(horizontal = 11.dp, vertical = 9.dp),
|
||||
@@ -92,7 +113,11 @@ internal fun SearchGenres(
|
||||
)
|
||||
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,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.align(Alignment.CenterEnd),
|
||||
|
||||
@@ -374,9 +374,16 @@ private fun SearchResultsHeading(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
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))
|
||||
Text(resultCount.toString(), color = MembyMutedText, fontSize = 14.sp)
|
||||
Text(displayedCount.toString(), color = MembyMutedText, fontSize = 14.sp)
|
||||
}
|
||||
if (state.isLoading && resultCount > 0) {
|
||||
Spacer(Modifier.width(9.dp))
|
||||
|
||||
Reference in New Issue
Block a user