diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0ab3a53..9c18e2d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt index 0fdf55d..5f126b9 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt index 4db1cae..8a215e7 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt @@ -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 diff --git a/app/src/main/res/drawable-nodpi/ic_service_apple_tv.png b/app/src/main/res/drawable-nodpi/ic_service_apple_tv.png new file mode 100644 index 0000000..c93da20 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_service_apple_tv.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_service_disney_plus.png b/app/src/main/res/drawable-nodpi/ic_service_disney_plus.png new file mode 100644 index 0000000..7c9a397 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_service_disney_plus.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_service_netflix.png b/app/src/main/res/drawable-nodpi/ic_service_netflix.png new file mode 100644 index 0000000..12f61c1 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_service_netflix.png differ diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/search/SearchFocusTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/search/SearchFocusTest.kt index 4798787..f858043 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/search/SearchFocusTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/search/SearchFocusTest.kt @@ -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.")