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?)
|
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))
|
||||||
|
|||||||
Reference in New Issue
Block a user