0.3.29 - More search fixes
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.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.")
|
||||
|
||||
Reference in New Issue
Block a user