0.3.29 - More search fixes

This commit is contained in:
ponzischeme89
2026-08-26 10:31:25 +12:00
parent 0bef5b8927
commit 263177a9e4
7 changed files with 85 additions and 23 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.28" val defaultVersionName = "0.3.29"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -6,6 +6,7 @@ import com.ponzischeme89.memby.ui.theme.mark
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -40,8 +41,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
@@ -55,6 +58,8 @@ import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -66,6 +71,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.FocusScaleContainer import com.ponzischeme89.memby.ui.FocusScaleContainer
@@ -559,15 +565,7 @@ internal fun GenreRail(
size = androidx.compose.ui.geometry.Size(1f, size.height), size = androidx.compose.ui.geometry.Size(1f, size.height),
) )
} }
.focusGroup() .focusGroup(),
.onKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
// Right belongs to the rail as a whole: whichever genre holds focus, the
// press means "into the grid", and it has to scroll the remembered card
// back into composition before anything can be focused. A focusProperties
// target could not do either.
if (event.key == Key.DirectionRight) onEnterContent() else false
},
) { ) {
topContent?.invoke() topContent?.invoke()
Text( Text(
@@ -585,6 +583,17 @@ internal fun GenreRail(
// focused item grows, which took what little clearance there was. // focused item grows, which took what little clearance there was.
contentPadding = PaddingValues(start = 12.dp, end = 14.dp, top = 6.dp, bottom = 48.dp), contentPadding = PaddingValues(start = 12.dp, end = 14.dp, top = 6.dp, bottom = 48.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.onKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
// Right belongs to the genre rail as a whole: whichever genre holds focus,
// the press means "into the grid", and it has to scroll the remembered card
// back into composition before anything can be focused. A focusProperties
// target could not do either. Scoped to this list rather than the whole
// rail column, or a Right press on a Services icon — which must walk the
// strip instead — was intercepted here before it ever reached that icon's
// own focusProperties.
if (event.key == Key.DirectionRight) onEnterContent() else false
},
) { ) {
rowItemsIndexed(categories, key = { _, category -> category.id }) { index, category -> rowItemsIndexed(categories, key = { _, category -> category.id }) { index, category ->
val requester = requesterFor(category.id) val requester = requesterFor(category.id)
@@ -895,15 +904,25 @@ private fun ServiceIconButton(
}, },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
when (service.icon) { when (val mark = serviceMark(service.icon)) {
GenreCategoryIcon.ALL_SERVICES -> Icon( // A real brand mark fills the whole circle rather than sitting inside it —
// the two supplied so far already carry their own dark background, so
// drawing the tinted circle underneath and clipping this on top of it reads
// as one shortcut rather than an icon pasted onto an unrelated one.
is ServiceMark.Artwork -> Image(
painter = painterResource(mark.drawableRes),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().clip(CircleShape),
)
is ServiceMark.Glyph -> Icon(
MembyIcon.Studio.mark, MembyIcon.Studio.mark,
contentDescription = null, contentDescription = null,
tint = Color.White, tint = Color.White,
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
) )
else -> Text( is ServiceMark.Letters -> Text(
serviceMark(service.icon), mark.text,
color = Color.White, color = Color.White,
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.Black, fontWeight = FontWeight.Black,
@@ -918,7 +937,8 @@ private fun ServiceIconButton(
/** /**
* A quiet, brand-adjacent tint behind each mark — enough to tell the shortcuts apart at a * A quiet, brand-adjacent tint behind each mark — enough to tell the shortcuts apart at a
* glance, deliberately not an attempt at exact brand colour. The ring above it, not this, * glance, deliberately not an attempt at exact brand colour. The ring above it, not this,
* is what carries the focus treatment. * is what carries the focus treatment. A service with its own [ServiceMark.Artwork] paints
* over this entirely, so its value there is moot rather than wrong.
*/ */
private fun serviceTint(icon: GenreCategoryIcon): Color = when (icon) { private fun serviceTint(icon: GenreCategoryIcon): Color = when (icon) {
GenreCategoryIcon.APPLE_TV -> Color(0xFF2B2B2E) GenreCategoryIcon.APPLE_TV -> Color(0xFF2B2B2E)
@@ -927,12 +947,19 @@ private fun serviceTint(icon: GenreCategoryIcon): Color = when (icon) {
else -> MembySurfaceRaised else -> MembySurfaceRaised
} }
/** The short mark drawn inside a service's circle. */ /** What is drawn inside a service's circle: real artwork where it exists, a mark otherwise. */
private fun serviceMark(icon: GenreCategoryIcon): String = when (icon) { private sealed interface ServiceMark {
GenreCategoryIcon.APPLE_TV -> "tv+" data class Artwork(val drawableRes: Int) : ServiceMark
GenreCategoryIcon.NETFLIX -> "N" data object Glyph : ServiceMark
GenreCategoryIcon.DISNEY_PLUS -> "D+" data class Letters(val text: String) : ServiceMark
else -> "?" }
private fun serviceMark(icon: GenreCategoryIcon): ServiceMark = when (icon) {
GenreCategoryIcon.ALL_SERVICES -> ServiceMark.Glyph
GenreCategoryIcon.APPLE_TV -> ServiceMark.Artwork(R.drawable.ic_service_apple_tv)
GenreCategoryIcon.NETFLIX -> ServiceMark.Artwork(R.drawable.ic_service_netflix)
GenreCategoryIcon.DISNEY_PLUS -> ServiceMark.Artwork(R.drawable.ic_service_disney_plus)
else -> ServiceMark.Letters("?")
} }
@Composable @Composable
@@ -302,7 +302,21 @@ fun SearchScreen(
onClear = viewModel::clearQuery, onClear = viewModel::clearQuery,
// A completed transcription is a submitted query, not a keystroke: the // A completed transcription is a submitted query, not a keystroke: the
// debounce exists to guess at somebody having finished, and here they have. // debounce exists to guess at somebody having finished, and here they have.
onVoiceResult = viewModel::onVoiceResult, //
// Voice is also the one path through this screen that leaves the app: the
// system recognizer is its own Activity, and the round trip back can land
// Compose focus somewhere other than where it was — nowhere at all, or on a
// node the incoming results are about to remove. Every keystroke on the
// on-screen keyboard never has this problem, because focus never left the
// window. Re-seating focus explicitly on the button that started it — a
// stable target that is always mounted, never inside the result list this is
// about to replace — is what makes Down/Right into the new results resolve
// from a real node instead of from whatever focus happened to settle on.
onVoiceResult = { spoken ->
runCatching { contentFocusRequester.requestFocus() }
focusInResults = false
viewModel.onVoiceResult(spoken)
},
onSubmit = viewModel::submitQuery, onSubmit = viewModel::submitQuery,
voiceAvailable = voiceAvailable, voiceAvailable = voiceAvailable,
voiceFocusRequester = contentFocusRequester, voiceFocusRequester = contentFocusRequester,
@@ -899,7 +913,11 @@ private fun LoadingDot() {
internal fun searchResultsHaveFocusTarget(state: SearchUiState): Boolean = internal fun searchResultsHaveFocusTarget(state: SearchUiState): Boolean =
state.results.any { it.isMovie || it.isSeries } || state.results.any { it.isMovie || it.isSeries } ||
(state.errorMessage != null && state.results.isEmpty()) || (state.errorMessage != null && state.results.isEmpty()) ||
state.discovery.isNotEmpty() // Not state.discovery itself: SearchResults only ever attaches a focus requester
// to what discoverySection leaves *after* dropping anything the library already
// answered for. A response that arrived but was entirely deduplicated away is the
// same as no response at all from this function's point of view.
discoverySection(state.results, 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

@@ -39,6 +39,23 @@ class SearchFocusTest {
assertTrue(searchResultsHaveFocusTarget(state)) assertTrue(searchResultsHaveFocusTarget(state))
} }
@Test
fun `discovery entirely deduplicated against the library claims no target`() {
// SearchResults only ever attaches a focus requester to what discoverySection
// leaves after dropping anything the library already answered for. results here
// is an episode — excluded from the "library results claim a target" check above,
// same as SearchResults' own item filter — so the only thing that could claim a
// target is discovery, and it is entirely a duplicate of that episode.
val libraryEpisode = BaseItem(id = "1", name = "Pilot", type = "Episode")
val duplicateDiscovery = libraryEpisode.copy(id = "2", membySource = "radarr")
val state = SearchUiState(
query = "reacher",
results = listOf(libraryEpisode),
discovery = listOf(duplicateDiscovery),
)
assertFalse(searchResultsHaveFocusTarget(state))
}
@Test @Test
fun `an error with nothing found claims a target for its retry chip`() { fun `an error with nothing found claims a target for its retry chip`() {
val state = SearchUiState(query = "reacher", errorMessage = "Could not reach the server.") val state = SearchUiState(query = "reacher", errorMessage = "Could not reach the server.")