0.3.28 - More search fixes

This commit is contained in:
ponzischeme89
2026-08-26 09:35:12 +12:00
parent 9d11bb7282
commit 0bef5b8927
3 changed files with 99 additions and 7 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.27" val defaultVersionName = "0.3.28"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -187,11 +187,17 @@ fun SearchScreen(
searchTracked = false searchTracked = false
} }
} }
// Must name only destinations that actually exist right now: the "Finding more
// titles…" footer shown while state.discoveryLoading is true is a label, never a
// focus target, so it cannot count here on its own. Counting it was the bug — the
// keyboard's Right key would claim a results target that no composable had actually
// attached, and a library search with nothing in it (waiting on a still-loading
// Sonarr/Radarr lookup) left the remote unable to move off the keyboard until that
// lookup settled, however long it took.
val hasResultsTarget = when { val hasResultsTarget = when {
state.errorMessage != null && state.results.isEmpty() -> true state.errorMessage != null && state.results.isEmpty() -> true
state.isDiscovery -> state.genreSuggestions.isNotEmpty() state.isDiscovery -> state.genreSuggestions.isNotEmpty()
else -> state.results.any { it.isMovie || it.isSeries } || state.discovery.isNotEmpty() || else -> searchResultsHaveFocusTarget(state)
state.discoveryLoading
} }
LaunchedEffect(voiceAvailable) { runCatching { contentFocusRequester.requestFocus() } } LaunchedEffect(voiceAvailable) { runCatching { contentFocusRequester.requestFocus() } }
@@ -269,7 +275,19 @@ fun SearchScreen(
SearchPane( SearchPane(
state = state, state = state,
navigationFocusRequester = navigationFocusRequester, navigationFocusRequester = navigationFocusRequester,
resultsEntry = if (state.genreSuggestions.isNotEmpty()) genresEntry else searchResultsEntry, // The genre strip is a discovery-mode shortcut, not a waypoint into search
// results: routing Right through it only while nothing is typed (or browsed)
// means an active search always reaches its results in exactly one press,
// regardless of whether the genre suggestions have finished loading yet. Making
// this depend on genreSuggestions alone meant the very first search — before
// Home's rows had warmed the suggestion list — went straight to results, while
// every search after that (once the list populated) gained an extra hop through
// the strip that the viewer had no reason to expect.
resultsEntry = if (state.isDiscovery && state.genreSuggestions.isNotEmpty()) {
genresEntry
} else {
searchResultsEntry
},
keyboardEntry = keyboardEntry, keyboardEntry = keyboardEntry,
keyboardReturn = keyboardReturn, keyboardReturn = keyboardReturn,
lastKeyIndex = lastKeyIndex, lastKeyIndex = lastKeyIndex,
@@ -303,9 +321,7 @@ fun SearchScreen(
entryFocusRequester = genresEntry, entryFocusRequester = genresEntry,
keyboardReturnFocusRequester = keyboardReturn, keyboardReturnFocusRequester = keyboardReturn,
resultsFocusRequester = searchResultsEntry, resultsFocusRequester = searchResultsEntry,
resultsHaveFocusTarget = state.results.any { it.isMovie || it.isSeries } || resultsHaveFocusTarget = searchResultsHaveFocusTarget(state),
(state.errorMessage != null && state.results.isEmpty()) ||
state.discovery.isNotEmpty(),
onFocused = { onFocused = {
focusInResults = true focusInResults = true
onContentFocused() onContentFocused()
@@ -868,6 +884,23 @@ private fun LoadingDot() {
) )
} }
/**
* Whether the results pane, as [SearchResults] would render [state] right now, has any
* composable actually willing to take focus.
*
* This is the one rule both the keyboard's rightward escape and the genre strip's
* downward one must agree with — a destination named to either of them that this
* function does not also call reachable is a D-pad key that quietly does nothing. It
* deliberately excludes [SearchUiState.discoveryLoading] and [SearchUiState.isLoadingMore]
* and their footer notes: those are text, not focus targets, and a search whose library
* half came back empty while Sonarr/Radarr was still being asked must not claim a
* destination that will not exist until that lookup settles.
*/
internal fun searchResultsHaveFocusTarget(state: SearchUiState): Boolean =
state.results.any { it.isMovie || it.isSeries } ||
(state.errorMessage != null && state.results.isEmpty()) ||
state.discovery.isNotEmpty()
private const val KEYBOARD_COLUMNS = 6 private const val KEYBOARD_COLUMNS = 6
private const val ACTION_ROW_INDEX = 36 private const val ACTION_ROW_INDEX = 36
@@ -0,0 +1,59 @@
package com.ponzischeme89.memby.ui.search
import com.ponzischeme89.memby.data.model.BaseItem
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pins [searchResultsHaveFocusTarget]: whatever it reports must always match whether
* [SearchResults] actually attaches a focus requester to something. A search whose
* library half came back empty while Sonarr/Radarr was still being asked used to report
* a target it had not built yet — the "Finding more titles…" footer is a label, not a
* focus destination — which left the keyboard's Right key pointed at nothing and the
* remote stuck unable to leave it until that lookup settled.
*/
class SearchFocusTest {
private fun movie(id: String) = BaseItem(id = id, name = id, type = "Movie")
@Test
fun `discovery loading with nothing else does not claim a target`() {
val state = SearchUiState(
query = "zqx",
hasSearched = true,
discoveryLoading = true,
)
assertFalse(searchResultsHaveFocusTarget(state))
}
@Test
fun `library results claim a target`() {
val state = SearchUiState(query = "reacher", results = listOf(movie("1")))
assertTrue(searchResultsHaveFocusTarget(state))
}
@Test
fun `discovery results alone claim a target`() {
val state = SearchUiState(query = "reacher", discovery = listOf(movie("1")))
assertTrue(searchResultsHaveFocusTarget(state))
}
@Test
fun `an error with nothing found claims a target for its retry chip`() {
val state = SearchUiState(query = "reacher", errorMessage = "Could not reach the server.")
assertTrue(searchResultsHaveFocusTarget(state))
}
@Test
fun `an empty result with nothing loading claims no target`() {
val state = SearchUiState(query = "reacher", hasSearched = true)
assertFalse(searchResultsHaveFocusTarget(state))
}
@Test
fun `loading more pages does not by itself claim a target`() {
val state = SearchUiState(query = "reacher", isLoadingMore = true)
assertFalse(searchResultsHaveFocusTarget(state))
}
}