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