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?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
val defaultVersionName = "0.3.28"
val defaultVersionName = "0.3.29"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -6,6 +6,7 @@ import com.ponzischeme89.memby.ui.theme.mark
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
@@ -40,8 +41,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
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.onKeyEvent
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.semantics
import androidx.compose.ui.text.font.FontWeight
@@ -66,6 +71,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.FocusScaleContainer
@@ -559,15 +565,7 @@ internal fun GenreRail(
size = androidx.compose.ui.geometry.Size(1f, size.height),
)
}
.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
},
.focusGroup(),
) {
topContent?.invoke()
Text(
@@ -585,6 +583,17 @@ internal fun GenreRail(
// focused item grows, which took what little clearance there was.
contentPadding = PaddingValues(start = 12.dp, end = 14.dp, top = 6.dp, bottom = 48.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 ->
val requester = requesterFor(category.id)
@@ -895,15 +904,25 @@ private fun ServiceIconButton(
},
contentAlignment = Alignment.Center,
) {
when (service.icon) {
GenreCategoryIcon.ALL_SERVICES -> Icon(
when (val mark = serviceMark(service.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,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(22.dp),
)
else -> Text(
serviceMark(service.icon),
is ServiceMark.Letters -> Text(
mark.text,
color = Color.White,
fontSize = 13.sp,
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
* 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) {
GenreCategoryIcon.APPLE_TV -> Color(0xFF2B2B2E)
@@ -927,12 +947,19 @@ private fun serviceTint(icon: GenreCategoryIcon): Color = when (icon) {
else -> MembySurfaceRaised
}
/** The short mark drawn inside a service's circle. */
private fun serviceMark(icon: GenreCategoryIcon): String = when (icon) {
GenreCategoryIcon.APPLE_TV -> "tv+"
GenreCategoryIcon.NETFLIX -> "N"
GenreCategoryIcon.DISNEY_PLUS -> "D+"
else -> "?"
/** What is drawn inside a service's circle: real artwork where it exists, a mark otherwise. */
private sealed interface ServiceMark {
data class Artwork(val drawableRes: Int) : ServiceMark
data object Glyph : ServiceMark
data class Letters(val text: String) : ServiceMark
}
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
@@ -302,7 +302,21 @@ fun SearchScreen(
onClear = viewModel::clearQuery,
// A completed transcription is a submitted query, not a keystroke: the
// 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,
voiceAvailable = voiceAvailable,
voiceFocusRequester = contentFocusRequester,
@@ -899,7 +913,11 @@ private fun LoadingDot() {
internal fun searchResultsHaveFocusTarget(state: SearchUiState): Boolean =
state.results.any { it.isMovie || it.isSeries } ||
(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 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))
}
@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
fun `an error with nothing found claims a target for its retry chip`() {
val state = SearchUiState(query = "reacher", errorMessage = "Could not reach the server.")