From a0b84dfc37f89df9bb5bc3dcbc318fa8ded727f7 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Wed, 12 Aug 2026 16:54:41 +1200 Subject: [PATCH] 0.2.60 - Improvments to Requests, placeholders --- CHANGELOG.md | 9 +- app/build.gradle.kts | 2 +- .../ponzischeme89/memby/ui/HomeGreeting.kt | 16 +- .../ponzischeme89/memby/ui/MainActivity.kt | 129 +++++++--- .../memby/ui/alerts/AlertsPage.kt | 144 +++++++++++ .../memby/ui/requests/RequestPresentation.kt | 59 +++++ .../memby/ui/requests/RequestsScreen.kt | 234 +++++++++++++----- .../memby/ui/requests/RequestsViewModel.kt | 116 +++++---- .../memby/ui/search/SearchScreen.kt | 145 +++++++++-- .../memby/ui/HomeGreetingTest.kt | 8 +- .../ui/requests/RequestPresentationTest.kt | 54 +++- .../ui/requests/RequestsScreenshotTest.kt | 39 +++ 12 files changed, 778 insertions(+), 177 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1f4261..63fcd0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ ## 0.2.59 — 2026-08-12 -- Fixed: Moving up through the on-screen keyboard on the Requests page no longer jumps straight to the tabs — only the top row of letters leaves for them. -- Improved: Requests now waits for five letters, and for a short pause in typing, before looking anything up. +- Added: Voice search on the Requests page, beside the query, the same as Search. +- Fixed: Remote navigation on the Requests search page. Up through the keyboard now moves a row at a time instead of jumping to the tabs, coming back from the results lands on the key you were last on, and Back steps results → keyboard → tabs → out, one press at a time. +- Added: A USB or phone keyboard types into the Requests search the same way it does into Search. +- Fixed: The navigation rail is now on the Requests page, so Left goes somewhere and you can move straight to Home, Search or Settings without leaving it first. +- Fixed: "Good morning" now greets you while the featured card is up, instead of appearing only once you had pressed Down past it. +- Improved: Notifications shows a bell that quietly pulses while it is listening when you have nothing waiting, and a still grey one when notifications are switched off. +- Changed: Requests no longer searches as you type. Type a name and press Search, so one title costs one look-up instead of one per letter — and two characters is now enough, so short titles work again. Dictating a title searches straight away. - Fixed: The featured cards at the top of Home no longer lead with a film you have already watched. Series are unchanged, so a new season of something you are up to date with still features. - Fixed: Saving a setting could fail repeatedly with a server error. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 880e420..1ffad86 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,7 +42,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.59" +val defaultVersionName = "0.2.60" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeGreeting.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeGreeting.kt index e11ddbd..dffcf6a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeGreeting.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeGreeting.kt @@ -12,13 +12,25 @@ internal fun homeGreetingPeriod(hourOfDay: Int): HomeGreetingPeriod = when (hour else -> HomeGreetingPeriod.EVENING } -/** The greeting lives between leaving the hero and leaving Continue Watching. */ +/** + * The greeting lives from the hero down through Continue Watching. + * + * A null [focusedRowId] is the **hero**, not "nothing": the featured card is not a row, so + * nothing in the list is focused while somebody is standing on it — which is the opening + * frame of every launch and exactly the moment "Good morning, Matt" is worth saying. It + * used to be the one state the greeting was withheld in, so in practice it appeared only + * after the viewer had already pressed Down past the thing it was meant to accompany. + * + * It still goes away further down the launcher: by then somebody is looking for something + * to watch rather than being welcomed. + */ internal fun shouldShowHomeGreeting( hasHero: Boolean, focusedRowId: String?, rowIds: List, ): Boolean { - if (!hasHero || focusedRowId == null) return false + if (!hasHero) return false + if (focusedRowId == null) return true val focusedIndex = rowIds.indexOf(focusedRowId) val continueIndex = rowIds.indexOf("continue") return focusedIndex >= 0 && continueIndex >= 0 && focusedIndex <= continueIndex diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index 22d8597..4320a87 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -2015,6 +2016,11 @@ private fun HomeScreen( } var railFocusDestination by rememberSaveable { mutableStateOf(selectedDestination) } val navigationFocusRequester = navigationFocusRequesters.getValue(railFocusDestination) + // The Requests page covers the launcher, so it mounts a rail of its own. Kept apart + // from the launcher's because both are composed at once while that page is open, and a + // requester attached to two live nodes focuses whichever Compose reaches first. + val requestsRailFocusRequester = remember { FocusRequester() } + var requestsRailExpanded by remember { mutableStateOf(false) } val contentFocusRequester = remember { FocusRequester() } val cardReturnFocusRequester = remember { FocusRequester() } val myShowReturnFocusRequester = remember { FocusRequester() } @@ -3436,36 +3442,101 @@ private fun HomeScreen( factory = RequestsViewModelFactory(repo), ) val requestsState by requestsViewModel.state.collectAsStateWithLifecycle() - RequestsScreen( - state = requestsState, - navigationFocusRequester = navigationFocusRequester, - contentFocusRequester = contentFocusRequester, - onSelectTab = requestsViewModel::selectTab, - onQueryChanged = requestsViewModel::onQueryChanged, - onAppendToQuery = requestsViewModel::appendToQuery, - onBackspace = requestsViewModel::backspace, - onClearQuery = requestsViewModel::clearQuery, - onRequest = requestsViewModel::request, - onRemove = requestsViewModel::remove, - onOpenItem = { itemId -> - // A request that has arrived opens the thing it became. The stub is - // filled in by focusItem the way a schedule card's is, so the page - // appears at once instead of waiting on an item request. - scope.launch { - runCatching { repo.getItemDetails(itemId) } - .onSuccess { item -> - closeRequests() - detailsAiringNotice = null - detailsTrail = emptyList() - detailsItem = item + // The page covers the launcher, rail and all, so it carries its own rather than + // leaving Left pointing at one nobody can see. Its own FocusRequester, because + // the launcher's is still attached to the rail underneath and one requester on + // two live nodes lands on whichever Compose reaches first. + // + // Selecting a destination from here leaves the page — this rail is how you get + // *out* of Requests, not a second way to browse behind it. + Row( + Modifier + .fillMaxSize() + .zIndex(6f) + // The rail's own surface is 95% opaque, so without this the launcher + // shows faintly through the one column of the page it does not paint. + .background(MembySurface), + ) { + TvNavigationRail( + config = remoteConfig.navigation, + selected = selectedDestination, + expanded = requestsRailExpanded, + navigationFocusRequester = requestsRailFocusRequester, + onRailFocusChanged = { requestsRailExpanded = it }, + onDestinationSelected = { destination -> + requestsRailExpanded = false + showRequests = false + genreBrowseItemType = null + when (destination) { + BrowseDestination.SETTINGS -> { + railFocusDestination = BrowseDestination.SETTINGS + restoreRailAfterSettings = true + navigationExpanded = false + userSwitcherVisible = false + showSettings = true } - } - }, - onRetry = requestsViewModel::refresh, - onExit = closeRequests, - posterUrlFor = { it.takeIf(String::isNotBlank) }, - modifier = Modifier.zIndex(6f), - ) + BrowseDestination.PROFILES -> { + railFocusDestination = BrowseDestination.PROFILES + userSwitcherVisible = true + navigationExpanded = false + showSettings = false + restoreRailAfterSettings = false + } + else -> { + selectedDestination = destination + railFocusDestination = destination + navigationExpanded = false + userSwitcherVisible = false + showSettings = false + restoreRailAfterSettings = false + scope.launch { + // Let the destination compose and attach its entry + // target before focus is transferred to it. + kotlinx.coroutines.delay(16L) + requestFirstAvailableFocus( + contentFocusRequester, + navigationFocusRequester, + ) + } + } + } + }, + alertCount = displayedNotifications.size, + activeUsername = settings.username.orEmpty(), + calendarEnabled = tvCalendarEnabled, + ) + RequestsScreen( + state = requestsState, + navigationFocusRequester = requestsRailFocusRequester, + contentFocusRequester = contentFocusRequester, + onSelectTab = requestsViewModel::selectTab, + onQueryChanged = requestsViewModel::onQueryChanged, + onAppendToQuery = requestsViewModel::appendToQuery, + onBackspace = requestsViewModel::backspace, + onClearQuery = requestsViewModel::clearQuery, + onSubmitSearch = requestsViewModel::submitSearch, + onRequest = requestsViewModel::request, + onRemove = requestsViewModel::remove, + onOpenItem = { itemId -> + // A request that has arrived opens the thing it became. The stub is + // filled in by focusItem the way a schedule card's is, so the page + // appears at once instead of waiting on an item request. + scope.launch { + runCatching { repo.getItemDetails(itemId) } + .onSuccess { item -> + closeRequests() + detailsAiringNotice = null + detailsTrail = emptyList() + detailsItem = item + } + } + }, + onRetry = requestsViewModel::refresh, + onExit = closeRequests, + posterUrlFor = { it.takeIf(String::isNotBlank) }, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) + } } if (showNotifications) { // Reached from the user picker, so leaving it goes back to the rail rather than diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt index 4c8c637..3cd7725 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt @@ -1,5 +1,13 @@ package com.ponzischeme89.memby.ui.alerts +import android.provider.Settings as AndroidSettings +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -23,6 +31,8 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.NotificationsActive +import androidx.compose.material.icons.filled.NotificationsNone +import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material.icons.filled.Tv import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -36,8 +46,13 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight @@ -244,6 +259,8 @@ private fun AlertsEmptyState(enabled: Boolean) { verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { + AlertsEmptyMark(listening = enabled) + Spacer(Modifier.height(18.dp)) Text("You’re all caught up.", color = MembyMutedText, fontSize = 20.sp, fontWeight = FontWeight.SemiBold) Spacer(Modifier.height(8.dp)) Text( @@ -258,6 +275,133 @@ private fun AlertsEmptyState(enabled: Boolean) { } } +/** The size of the mark, and the one number the drawing is measured against. */ +private val AlertsEmptyMarkSize = 132.dp + +/** + * The mark in the middle of an empty page: a bell in a disc, with a slow pulse going out + * from it while notifications are on. + * + * A page whose whole job is emptying itself spends most of its life empty, so this is the + * state it is *usually* in — two lines of grey text in the middle of a black screen read as + * a screen that failed to load rather than as good news. The pulse is what says the page is + * listening: it stops entirely when the viewer has notifications switched off, because + * nothing is arriving and an animation claiming otherwise would be decoration telling a + * small lie. + * + * Nothing here recomposes. One `Canvas`, one animated `State` never read in a + * composable body, and the bell's own breathing done inside a `graphicsLayer` lambda — + * which runs in the layer phase, not composition. The rule this page inherits from + * `SeasonalDecorations`, and for the same reason: this ships to weak television boxes. + */ +@Composable +private fun AlertsEmptyMark(listening: Boolean) { + val context = LocalContext.current + // Somebody who has turned animations off at the platform level has said something about + // every animation on the device. An accessibility choice is not a preference. + val animationsOn = remember(context) { + AndroidSettings.Global.getFloat( + context.contentResolver, + AndroidSettings.Global.ANIMATOR_DURATION_SCALE, + 1f, + ) > 0f + } + val animate = listening && animationsOn + val progress = rememberInfiniteTransition(label = "alerts-empty").animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(AlertsPulseMillis, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "pulse", + ) + + // The palette colours *are* read in composition, deliberately, so a theme change + // repaints this node. Only the animated value must stay out of the body. + // + // Switched off, the mark goes grey as well as still: accent green is the colour this + // app uses for something that is working, and a bright green bell over the sentence + // "notifications are switched off" is the picture disagreeing with the words. + val mark = if (listening) MembyAccent else MembyMutedText + Box( + modifier = Modifier.size(AlertsEmptyMarkSize), + contentAlignment = Alignment.Center, + ) { + Canvas(Modifier.fillMaxSize()) { + drawAlertsEmptyMark(if (animate) progress.value else null, mark) + } + Icon( + if (listening) Icons.Default.NotificationsNone else Icons.Default.NotificationsOff, + contentDescription = null, + tint = mark, + modifier = Modifier + .size(46.dp) + .graphicsLayer { + if (!animate) return@graphicsLayer + // A breath, not a bounce: this sits under two lines of text somebody is + // reading, and anything larger reads as a control asking to be pressed. + val breath = kotlin.math.sin(progress.value * TWO_PI).toFloat() + scaleX = 1f + breath * 0.03f + scaleY = 1f + breath * 0.03f + // One small nudge per cycle, at the moment a ring leaves the bell. + rotationZ = ringSwing(progress.value) + }, + ) + } +} + +private const val AlertsPulseMillis = 3800 +private const val TWO_PI = 2.0 * Math.PI +private const val AlertsRingCount = 3 + +/** + * One frame of the mark, at [progress] through a cycle, or the resting frame when it is + * null. + * + * Pure with respect to everything but the canvas — the same progress always draws the same + * picture — which is what lets a screenshot test capture an exact frame of an animation + * with no clock, the property `drawSeasonalField` is written for. + */ +private fun DrawScope.drawAlertsEmptyMark(progress: Float?, mark: Color) { + val centre = Offset(size.width / 2f, size.height / 2f) + val disc = size.minDimension * 0.30f + // The rings first, so the disc covers where they are born rather than a hard edge + // appearing out of the middle of it. + if (progress != null) { + repeat(AlertsRingCount) { index -> + // Evenly spaced through one cycle, so the gap between rings is constant however + // long the cycle is. The wrap is exact: at progress 1 the field is where it was + // at 0, or the whole mark would visibly jump once every few seconds. + val travel = (progress + index.toFloat() / AlertsRingCount) % 1f + drawCircle( + color = mark.copy(alpha = 0.22f * (1f - travel) * (1f - travel)), + radius = disc + travel * size.minDimension * 0.19f, + center = centre, + style = Stroke(width = 1.5f + (1f - travel) * 1.5f), + ) + } + } + drawCircle(color = mark.copy(alpha = 0.10f), radius = disc, center = centre) + drawCircle( + color = mark.copy(alpha = 0.34f), + radius = disc, + center = centre, + style = Stroke(width = 1.4f), + ) +} + +/** + * The bell's nudge: still for most of the cycle, then a quick damped wobble as a ring + * leaves it. A bell that swung continuously would be a page asking for attention it has + * nothing to give. + */ +private fun ringSwing(progress: Float): Float { + if (progress > 0.22f) return 0f + val phase = progress / 0.22f + return (kotlin.math.sin(phase * 3f * TWO_PI) * (1.0 - phase) * 5.0).toFloat() +} + @Composable private fun AlertRow( notification: UserNotification, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestPresentation.kt b/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestPresentation.kt index 666be11..8a64b1b 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestPresentation.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestPresentation.kt @@ -73,6 +73,65 @@ fun requestCandidateActionable(status: String): Boolean = when (status) { else -> false } +/** + * What the results half of the search page is saying at any moment. + * + * It is an enum with a pure rule rather than a chain of conditions inside the composable + * because the states are close enough to be confused with one another and only one of them + * may ever be on screen: "you have not typed enough", "you have typed something nobody has + * looked up yet" and "nothing matched what you searched for" are three different pieces of + * news, and the middle one only exists because this page searches when it is asked to. + */ +enum class RequestsSearchPhase { + /** Nothing typed. */ + IDLE, + + /** Not yet enough to look up. */ + TOO_SHORT, + + /** Long enough, and not yet looked up: the Search key is what happens next. */ + READY, + + /** A lookup is in flight. */ + SEARCHING, + + /** The lookup failed. */ + FAILED, + + /** It ran and nothing matched. */ + NO_MATCHES, + + /** There are cards to show. */ + RESULTS, +} + +/** + * Which of those it is. + * + * The order of the tests is the whole content: a query that has been edited since the last + * lookup is [READY] even though results are still held, because what is on screen answers + * something else — and an in-flight lookup outranks everything, since the honest thing to + * say while waiting is that we are waiting. + */ +fun requestsSearchPhase( + query: String, + searchedTerm: String?, + searching: Boolean, + failed: Boolean, + candidateCount: Int, +): RequestsSearchPhase { + val term = query.trim() + return when { + searching -> RequestsSearchPhase.SEARCHING + term.isEmpty() -> RequestsSearchPhase.IDLE + term != searchedTerm -> + if (shouldLookup(term)) RequestsSearchPhase.READY else RequestsSearchPhase.TOO_SHORT + failed -> RequestsSearchPhase.FAILED + candidateCount > 0 -> RequestsSearchPhase.RESULTS + else -> RequestsSearchPhase.NO_MATCHES + } +} + /** * What a card is grouped under on the viewer's own page. * diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestsScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestsScreen.kt index 5c07d84..79b1807 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestsScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestsScreen.kt @@ -42,6 +42,11 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.key.utf16CodePoint +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -51,9 +56,12 @@ import androidx.tv.material3.Text import com.ponzischeme89.memby.data.model.GatewayMediaRequestItem import com.ponzischeme89.memby.data.model.GatewayRequestCandidate import com.ponzischeme89.memby.ui.FocusScaleContainer +import com.ponzischeme89.memby.ui.search.BACKSPACE_CODE +import com.ponzischeme89.memby.ui.search.FIRST_PRINTABLE_CODE +import com.ponzischeme89.memby.ui.search.SearchQueryField import com.ponzischeme89.memby.ui.search.TvKeyboard +import com.ponzischeme89.memby.ui.search.voiceSearchAvailable import com.ponzischeme89.memby.ui.theme.MembyAccent -import com.ponzischeme89.memby.ui.theme.MembyCardCorner import com.ponzischeme89.memby.ui.theme.MembyChipCorner import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyPanelCorner @@ -91,6 +99,7 @@ fun RequestsScreen( onAppendToQuery: (String) -> Unit, onBackspace: () -> Unit, onClearQuery: () -> Unit, + onSubmitSearch: () -> Unit, onRequest: (GatewayRequestCandidate) -> Unit, onRemove: (GatewayMediaRequestItem) -> Unit, onOpenItem: (String) -> Unit, @@ -102,18 +111,30 @@ fun RequestsScreen( val tabsFocusRequester = remember { FocusRequester() } val paneFocusRequester = remember { FocusRequester() } val keyboardEntry = remember { FocusRequester() } + // Where Left out of the results lands: the key last used, so somebody comes back to + // where they were typing. Its own requester — see the note on DiscoverPane. + val keyboardReturn = remember { FocusRequester() } var paneFocused by remember { mutableStateOf(false) } + var resultsFocused by remember { mutableStateOf(false) } LaunchedEffect(Unit) { kotlinx.coroutines.delay(32L) runCatching { contentFocusRequester.requestFocus() } } - // Back steps out one level per press, the stance every page here takes. - BackHandler(enabled = paneFocused) { + // Back steps out one level per press, the stance every page here takes. In the search + // half that is three levels, not two: results → keyboard → tabs → leave. Landing on + // the tabs from the results skips the half of the screen somebody was working in, and + // is what made Back feel like it had thrown the search away. + BackHandler(enabled = resultsFocused) { + resultsFocused = false + runCatching { keyboardReturn.requestFocus() } + .onFailure { runCatching { keyboardEntry.requestFocus() } } + } + BackHandler(enabled = paneFocused && !resultsFocused) { paneFocused = false runCatching { tabsFocusRequester.requestFocus() } } - BackHandler(enabled = !paneFocused, onBack = onExit) + BackHandler(enabled = !paneFocused && !resultsFocused, onBack = onExit) BoxWithConstraints( modifier @@ -125,7 +146,10 @@ fun RequestsScreen( ), ) { Column( - Modifier.fillMaxSize().padding(start = 38.dp, end = 30.dp, top = 22.dp, bottom = 22.dp), + // Tighter top and bottom than the calendar's: this page has a header, a tab + // strip, a query field, a keyboard and a Search key stacked in one column, and + // the bottom of that column is the edge overscan takes first. + Modifier.fillMaxSize().padding(start = 38.dp, end = 30.dp, top = 18.dp, bottom = 16.dp), ) { RequestsHeader(state) Spacer(Modifier.height(12.dp)) @@ -137,6 +161,7 @@ fun RequestsScreen( navigationFocusRequester = navigationFocusRequester, onSelectTab = { paneFocused = false + resultsFocused = false onSelectTab(it) }, ) @@ -169,10 +194,21 @@ fun RequestsScreen( paneFocusRequester = paneFocusRequester, tabsFocusRequester = tabsFocusRequester, navigationFocusRequester = navigationFocusRequester, + keyboardReturn = keyboardReturn, onFocused = { paneFocused = true }, + onKeyboardFocused = { + paneFocused = true + resultsFocused = false + }, + onResultsFocused = { + paneFocused = true + resultsFocused = true + }, + onQueryChanged = onQueryChanged, onAppendToQuery = onAppendToQuery, onBackspace = onBackspace, onClearQuery = onClearQuery, + onSubmitSearch = onSubmitSearch, onRequest = onRequest, posterUrlFor = posterUrlFor, modifier = Modifier.weight(1f).fillMaxWidth(), @@ -430,6 +466,26 @@ private fun RequestGroupHeading(heading: String, count: Int) { } } +/** + * The search half, and the whole of the page's focus contract that is hard. + * + * Three bands sit in a column on the left — the query field with its microphone, the + * letters, the action keys — with the results to their right, and every edge between them + * is stated rather than left to Compose's own focus search. The rules: + * + * - **Up out of the top row of letters reaches the microphone, and Up from there the tabs.** + * Both are stated on the control itself. Declaring either on a parent is what broke this + * the first time: `focusProperties` is inherited by every descendant, so an `up` on the + * box around the keyboard applied to all thirty-nine keys and the letters had no vertical + * navigation of their own. + * - **Left is the rail from the first column, and the keyboard from the results.** The + * keyboard is what this pane is for, so a card never falls through to the rail. + * - **Coming back from the results lands on the key last used** — `keyboardReturn`, which + * is its own requester rather than the entry one. Sharing a single requester between the + * first key and the last-used key attaches it to two live nodes, and `requestFocus` on + * two nodes lands on whichever Compose reaches first: pressing Left out of the results + * jumped to A about as often as it returned anybody to where they were typing. + */ @Composable private fun DiscoverPane( state: RequestsUiState, @@ -437,47 +493,102 @@ private fun DiscoverPane( paneFocusRequester: FocusRequester, tabsFocusRequester: FocusRequester, navigationFocusRequester: FocusRequester, + keyboardReturn: FocusRequester, onFocused: () -> Unit, + onKeyboardFocused: () -> Unit, + onResultsFocused: () -> Unit, + onQueryChanged: (String) -> Unit, onAppendToQuery: (String) -> Unit, onBackspace: () -> Unit, onClearQuery: () -> Unit, + onSubmitSearch: () -> Unit, onRequest: (GatewayRequestCandidate) -> Unit, posterUrlFor: (String) -> String?, modifier: Modifier = Modifier, ) { val resultsEntry = remember { FocusRequester() } + val micEntry = remember { FocusRequester() } var lastKeyIndex by remember { mutableStateOf(0) } + val context = LocalContext.current + // Asked here as well as inside the field, because where Up out of the letters goes + // depends on whether there is anything above them to go to. A `focusProperties` target + // that is not attached on the current frame throws the moment somebody presses that + // direction — the rule the detail page's season chips follow. + val micPresent = remember { voiceSearchAvailable(context) } val hasResults = state.candidates.isNotEmpty() - Row(modifier) { + Row( + modifier + // A USB keyboard, or a phone remote app sending key events, types into the same + // query the on-screen keys do — the Search tab's rule, and the same limits: + // only printable characters and backspace are consumed, so D-pad and Back fall + // through untouched. + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + val code = event.utf16CodePoint + when { + code == BACKSPACE_CODE -> { + onBackspace(); true + } + code >= FIRST_PRINTABLE_CODE -> { + onAppendToQuery(code.toChar().toString()); true + } + else -> false + } + }, + ) { Column( Modifier .width(340.dp) .fillMaxHeight() .onFocusChanged { if (it.hasFocus) onFocused() }, ) { - QueryLine(state) + SearchQueryField( + query = state.query, + loading = state.searching, + // Dictating a title is already the deliberate act the Search key exists to + // be: somebody who has said "Dune Part Two" out loud is not part-way + // through typing it, and asking them to then find a button would be asking + // twice for one request. + onVoiceResult = { spoken -> + onQueryChanged(spoken) + onSubmitSearch() + }, + placeholder = "Type a film or series name", + micModifier = Modifier + .focusRequester(micEntry) + .onFocusChanged { if (it.isFocused) onKeyboardFocused() } + .focusProperties { + up = tabsFocusRequester + down = keyboardReturn + left = navigationFocusRequester + right = if (hasResults) resultsEntry else FocusRequester.Cancel + }, + ) Spacer(Modifier.height(12.dp)) Box( - // The keyboard is the pane's entry point, so Down from the tabs lands on it. - // Only the *entry* is declared here: an `up` hung off this Box would be - // inherited by every key underneath it, which is what made Up anywhere in - // the letters leave for the tab strip instead of moving one row up. The - // escape belongs to the top row alone, and the keyboard states it per key. + // The keyboard is the pane's entry point, so Down from the tabs lands on + // it. Only the entry is declared here — see the note above on inheritance. Modifier.focusRequester(paneFocusRequester), ) { TvKeyboard( navigationFocusRequester = navigationFocusRequester, resultsEntry = resultsEntry, keyboardEntry = keyboardEntry, - keyboardReturn = keyboardEntry, + keyboardReturn = keyboardReturn, lastKeyIndex = lastKeyIndex, hasResultsTarget = hasResults, - onKeyFocused = { lastKeyIndex = it }, + onKeyFocused = { + lastKeyIndex = it + onKeyboardFocused() + }, onCharacter = onAppendToQuery, onBackspace = onBackspace, onClear = onClearQuery, - upTarget = tabsFocusRequester, + upTarget = if (micPresent) micEntry else tabsFocusRequester, + onSearch = onSubmitSearch, + searchEnabled = shouldLookup(state.query) && !state.searching, + compact = true, ) } } @@ -485,9 +596,9 @@ private fun DiscoverPane( CandidatesPane( state = state, resultsEntry = resultsEntry, - keyboardEntry = keyboardEntry, + keyboardReturn = keyboardReturn, tabsFocusRequester = tabsFocusRequester, - onFocused = onFocused, + onFocused = onResultsFocused, onRequest = onRequest, posterUrlFor = posterUrlFor, modifier = Modifier.weight(1f).fillMaxHeight(), @@ -495,50 +606,38 @@ private fun DiscoverPane( } } -/** What has been typed, echoed above the keyboard the way the Search tab echoes it. */ -@Composable -private fun QueryLine(state: RequestsUiState) { - Column( - Modifier - .fillMaxWidth() - .background(MembySurfaceRaised.copy(alpha = 0.34f), RoundedCornerShape(MembyCardCorner)) - .border(1.dp, Color.White.copy(alpha = 0.06f), RoundedCornerShape(MembyCardCorner)) - .padding(horizontal = 14.dp, vertical = 10.dp), - ) { - Text("SEARCH", color = MembyAccent, fontSize = 9.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(3.dp)) - Text( - state.query.ifBlank { "Type a film or series name" }, - color = if (state.query.isBlank()) MembyQuietText else Color.White, - fontSize = 17.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} - @Composable private fun CandidatesPane( state: RequestsUiState, resultsEntry: FocusRequester, - keyboardEntry: FocusRequester, + keyboardReturn: FocusRequester, tabsFocusRequester: FocusRequester, onFocused: () -> Unit, onRequest: (GatewayRequestCandidate) -> Unit, posterUrlFor: (String) -> String?, modifier: Modifier = Modifier, ) { + val phase = requestsSearchPhase( + query = state.query, + searchedTerm = state.searchedTerm, + searching = state.searching, + failed = state.searchError != null, + candidateCount = state.candidates.size, + ) Column(modifier) { Row(verticalAlignment = Alignment.Bottom) { Column(Modifier.weight(1f)) { Text("RESULTS", color = MembyAccent, fontSize = 10.sp, fontWeight = FontWeight.Bold) Text( - when { - state.query.isBlank() -> "What are you after?" - !shouldLookup(state.query) -> "Keep typing…" - state.searching -> "Looking…" - else -> state.query.trim() + when (phase) { + RequestsSearchPhase.IDLE -> "What are you after?" + RequestsSearchPhase.SEARCHING -> "Looking…" + // The heading names what was *asked*, so a pane of cards can never + // read as the answer to a query still being typed above it. + RequestsSearchPhase.TOO_SHORT, + RequestsSearchPhase.READY, + -> state.query.trim() + else -> state.searchedTerm ?: state.query.trim() }, color = Color.White, fontSize = 20.sp, @@ -560,31 +659,35 @@ private fun CandidatesPane( } Spacer(Modifier.height(9.dp)) - if (state.searching && state.candidates.isEmpty()) { + if (phase == RequestsSearchPhase.SEARCHING) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { repeat(3) { RequestCardSkeleton() } } return@Column } - if (state.candidates.isEmpty()) { + if (phase != RequestsSearchPhase.RESULTS) { PaneMessage( - heading = when { - state.searchError != null -> "Search failed" - state.query.isBlank() -> "Search for a film or series" - !shouldLookup(state.query) -> "Type a little more" - state.hasSearched -> "Nothing found" + heading = when (phase) { + RequestsSearchPhase.FAILED -> "Search failed" + RequestsSearchPhase.TOO_SHORT -> "Type a little more" + RequestsSearchPhase.READY -> "Press Search" + RequestsSearchPhase.NO_MATCHES -> "Nothing found" else -> "Search for a film or series" }, - body = state.searchError - ?: when { - state.query.isBlank() -> - "Use the keyboard to find something the household does not have yet." - !shouldLookup(state.query) -> - "At least ${RequestsViewModel.MIN_QUERY_LENGTH} characters, so the search has something to go on." - state.hasSearched -> - "Nothing matched “${state.query.trim()}”. Check the spelling, or try the original title." - else -> "Use the keyboard to find something the household does not have yet." - }, + body = when (phase) { + RequestsSearchPhase.FAILED -> + state.searchError ?: "The search could not be run just now." + RequestsSearchPhase.TOO_SHORT -> + "At least ${RequestsViewModel.MIN_QUERY_LENGTH} characters, so the search has something to go on." + // Naming the key rather than describing it: it is the only thing on + // this pane somebody has to find, and it is directly to the left. + RequestsSearchPhase.READY -> + "Press Search on the keyboard to look “${state.query.trim()}” up." + RequestsSearchPhase.NO_MATCHES -> + "Nothing matched “${state.searchedTerm ?: state.query.trim()}”. " + + "Check the spelling, or try the original title." + else -> "Type a name and press Search to find something the household does not have yet." + }, ) return@Column } @@ -625,8 +728,9 @@ private fun CandidatesPane( ) .focusProperties { // Left always returns to the keyboard rather than falling - // through to the rail: the keyboard is what this pane is for. - left = keyboardEntry + // through to the rail: the keyboard is what this pane is for, + // and it returns to the key last used rather than to A. + left = keyboardReturn if (candidate == state.candidates.first()) up = tabsFocusRequester }, ) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestsViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestsViewModel.kt index 16907de..9a9f19f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestsViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestsViewModel.kt @@ -7,16 +7,10 @@ import com.ponzischeme89.memby.data.EmbyRepository import com.ponzischeme89.memby.data.friendlyEmbyError import com.ponzischeme89.memby.data.model.GatewayMediaRequestItem import com.ponzischeme89.memby.data.model.GatewayRequestCandidate -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -37,6 +31,14 @@ data class RequestsUiState( val searching: Boolean = false, val hasSearched: Boolean = false, val searchError: String? = null, + /** + * The term the cards on screen belong to, or null when nothing has been looked up. + * + * It is what lets the pane tell "these are the answers" from "this is what you have + * typed so far", which is the whole of the difference between a page that searches as + * you type and one that searches when you ask it to. + */ + val searchedTerm: String? = null, /** * Which candidates have a request in flight, keyed the way the wire keys them. A set * rather than a single id because a viewer can walk down the pane pressing several @@ -60,30 +62,28 @@ internal fun candidateKey(mediaType: String, foreignId: Int): String = "$mediaTy /** * Owns the two panes' state. * - * The search half reuses the Search tab's pipeline exactly — `debounce` → `trim` → - * `distinctUntilChanged` → `collectLatest` — and `collectLatest` is the load-bearing part - * for the same reason it is there: it cancels the in-flight lookup, so a slow answer for a - * prefix can never overwrite the results for what was typed after it. Radarr and Sonarr - * lookups are live provider queries measured in seconds, so that race is not hypothetical - * here; it is the ordinary case. + * **The search half does not search as you type**, which is the one way it deliberately + * differs from the Search tab. That tab queries a local index and can afford a lookup per + * keystroke; this one is a live Radarr *and* Sonarr query measured in seconds, and typing a + * title on a remote is slow enough that every letter past the threshold became its own pair + * of provider calls — eight round trips to answer a question asked once. Debouncing only + * moved the problem: a pause between letters is not a finished title. + * + * So a lookup happens when somebody asks for one ([submitSearch], the Search key on the + * keyboard) and at no other time. What that buys, besides the *arr instances: the minimum + * query can come back down to [MIN_QUERY_LENGTH] characters, because a short query now + * costs one call rather than being the first of many. "Dune" is a legitimate thing to + * search for. */ -@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() { private val _state = MutableStateFlow(RequestsUiState()) val state: StateFlow = _state.asStateFlow() - private val queryFlow = MutableStateFlow("") private var refreshJob: Job? = null + private var searchJob: Job? = null init { - viewModelScope.launch { - queryFlow - .debounce(DEBOUNCE_MS) - .map { it.trim() } - .distinctUntilChanged() - .collectLatest { term -> runLookup(term) } - } refresh() } @@ -123,19 +123,20 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() { } fun onQueryChanged(query: String) { - // The field updates immediately; only the lookup is debounced. + // Editing the query retires the answers to the previous one. Leaving them up under + // a term nobody has looked up yet is how somebody requests the wrong film — and it + // is worse here than on a page that searches as you type, where the results catch + // up a moment later on their own. _state.update { it.copy( query = query, notice = null, - // Results for the previous term are cleared as soon as the term changes. - // Leaving them up under a new query is how somebody requests the wrong film. - candidates = if (shouldLookup(query)) it.candidates else emptyList(), - hasSearched = if (shouldLookup(query)) it.hasSearched else false, + candidates = emptyList(), + hasSearched = false, + searchedTerm = null, searchError = null, ) } - queryFlow.value = query } fun appendToQuery(text: String) = onQueryChanged(_state.value.query + text) @@ -143,29 +144,43 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() { fun backspace() = onQueryChanged(_state.value.query.dropLast(1)) fun clearQuery() { + searchJob?.cancel() _state.update { it.copy( query = "", candidates = emptyList(), searching = false, - hasSearched = false, searchError = null, notice = null, + hasSearched = false, searchedTerm = null, searchError = null, notice = null, ) } - queryFlow.value = "" + } + + /** + * Looks up what has been typed. This is the only thing that reaches Radarr or Sonarr. + * + * A press while one is already in flight is ignored rather than queued: the viewer is + * asking for the same thing again, and a remote's repeat rate would otherwise turn one + * held key into a burst of provider queries — the exact cost this design exists to + * avoid. + */ + fun submitSearch() { + val term = _state.value.query.trim() + if (!shouldLookup(term) || _state.value.searching) return + searchJob?.cancel() + searchJob = viewModelScope.launch { runLookup(term) } } private suspend fun runLookup(term: String) { - if (!shouldLookup(term)) { - _state.update { it.copy(searching = false, candidates = emptyList(), hasSearched = false) } - return - } - _state.update { it.copy(searching = true, searchError = null) } + _state.update { it.copy(searching = true, searchError = null, notice = null) } runCatching { repository.lookupMediaRequests(term) } .onSuccess { candidates -> - // Guard against a response for a term the viewer has already typed past. - // collectLatest cancels the coroutine, but a call already past its last - // suspension point still completes — the calendar's requestedMonth rule. + // Guard against an answer for a term the viewer has since edited past. The + // job is cancelled, but a call already past its last suspension point still + // completes — the calendar's requestedMonth rule. if (_state.value.query.trim() != term) return@onSuccess _state.update { - it.copy(candidates = candidates, searching = false, hasSearched = true) + it.copy( + candidates = candidates, searching = false, + hasSearched = true, searchedTerm = term, + ) } } .onFailure { error -> @@ -173,7 +188,7 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() { if (_state.value.query.trim() != term) return@onFailure _state.update { it.copy( - searching = false, hasSearched = true, + searching = false, hasSearched = true, searchedTerm = term, searchError = friendlyEmbyError(error), ) } @@ -268,26 +283,19 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() { companion object { /** - * Longer than the Search tab's, because the thing at the other end is different. - * Typing on a remote is around half a second a letter, so a 300 ms window let every - * keystroke past the threshold through as its own live Radarr and Sonarr query. - */ - const val DEBOUNCE_MS = 700L - - /** - * Five characters, not the Search tab's two. + * Two characters, the same floor the gateway independently enforces. * - * Every keystroke here reaches Radarr and Sonarr rather than a local index, so a - * viewer typing a title on a remote spends a live provider query per letter — and - * the early ones are worthless: a three-letter prefix returns a hundred films - * nobody meant. Five is roughly where a prefix starts naming something, so the - * lookups begin when they can answer rather than as soon as they are legal. The - * gateway independently refuses under two. + * It was raised to five while this page searched as you type, because every letter + * past the threshold cost a pair of live provider queries and the early ones + * answered nothing worth having. With the lookup behind a press that reasoning is + * gone: a short query is one deliberate call, and "Up" is a film somebody may + * genuinely be looking for. */ - const val MIN_QUERY_LENGTH = 5 + const val MIN_QUERY_LENGTH = 2 } } +/** Whether there is enough typed to be worth asking the providers about. */ fun shouldLookup(query: String): Boolean = query.trim().length >= RequestsViewModel.MIN_QUERY_LENGTH 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 cf321f3..a4b3cc6 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 @@ -109,6 +109,7 @@ import com.ponzischeme89.memby.ui.theme.MembyAccentMuted import com.ponzischeme89.memby.ui.theme.MembyControlSurface import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyOnSurface +import com.ponzischeme89.memby.ui.theme.MembyQuietText import com.ponzischeme89.memby.ui.theme.MembySurface import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised import kotlinx.coroutines.flow.distinctUntilChanged @@ -354,7 +355,7 @@ private fun SearchPane( ) { Text("Search", color = Heading, fontSize = 26.sp, fontWeight = FontWeight.SemiBold) Spacer(Modifier.height(10.dp)) - QueryField( + SearchQueryField( query = state.query, loading = state.isLoading, onVoiceResult = onVoiceResult, @@ -378,15 +379,40 @@ private fun SearchPane( /** * The query as typed, plus the two things that belong beside it: a quiet progress dot * while a search is in flight, and voice input when the device offers it. + * + * Internal for the same reason [TvKeyboard] is: the Requests page types into a live Radarr + * and Sonarr lookup rather than the library, but it is the same act on the same remote, and + * a second copy of the microphone would be a second set of permission handling, a second + * failure mode and a second thing to keep in step. + * + * [micModifier] is how a host wires the button into its own focus contract — Requests hangs + * the escape to its tab strip and the drop into its keyboard off it. It is deliberately the + * caller's business: this composable knows what the button *is*, and only the screen around + * it knows what is above and below it. */ +/** + * Whether this television can listen at all. + * + * Shared so a host can ask the same question the field answers by drawing the button — + * Requests needs it a step earlier, because where Up out of its keyboard goes depends on + * whether there is a microphone above it. Note that this returns false on Android 11+ + * without the `android.speech.RecognitionService` entry in the manifest's ``, + * which is why that entry is there. + */ +internal fun voiceSearchAvailable(context: android.content.Context): Boolean = + SpeechRecognizer.isRecognitionAvailable(context) + @Composable -private fun QueryField( +internal fun SearchQueryField( query: String, loading: Boolean, onVoiceResult: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String = "Search movies, shows and episodes", + micModifier: Modifier = Modifier, ) { val context = LocalContext.current - val voiceAvailable = remember { SpeechRecognizer.isRecognitionAvailable(context) } + val voiceAvailable = remember { voiceSearchAvailable(context) } val voiceLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult(), ) { result -> @@ -426,7 +452,7 @@ private fun QueryField( ) { startVoiceSearch() } Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(10.dp)) .background(MembySurfaceRaised) @@ -442,7 +468,7 @@ private fun QueryField( ) Spacer(Modifier.width(10.dp)) Text( - text = query.ifEmpty { "Search movies, shows and episodes" }, + text = query.ifEmpty { placeholder }, color = if (query.isEmpty()) Muted else Heading, // Fixed size rather than shrinking as the query grows: readable at three // metres matters more than fitting a long query on one line. @@ -476,7 +502,7 @@ private fun QueryField( } }, contentDescription = "Search by voice", - modifier = Modifier.clip(RoundedCornerShape(8.dp)), + modifier = micModifier.clip(RoundedCornerShape(8.dp)), ) { focused -> Icon( Icons.Default.Mic, @@ -519,8 +545,29 @@ internal fun TvKeyboard( * leaves for the tabs instead of moving one row up. */ upTarget: FocusRequester? = null, + /** + * What the Search key does, and whether there is one at all. + * + * Null means there is none, which is the Search tab: it queries a local index as you + * type, and a submit key there would be a control that does nothing the page has not + * already done. Requests passes one because its lookup is a live Radarr and Sonarr + * query — see [RequestsViewModel][com.ponzischeme89.memby.ui.requests.RequestsViewModel]. + */ + onSearch: (() -> Unit)? = null, + /** Whether enough has been typed for that key to do anything. */ + searchEnabled: Boolean = false, + /** + * Lays the same keys out tighter, for a host with less height to give. + * + * Requests is that host: it carries a page header and a tab strip above its keyboard + * and a Search key below it, which is about 90dp the Search tab does not spend, and the + * bottom of the column is where overscan bites. The keys stay well over the size a + * remote can hit at three metres — this trims the space between them, not the targets. + */ + compact: Boolean = false, ) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + val keyHeight = if (compact) 32.dp else 36.dp + Column(verticalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 8.dp)) { KeyboardRows.forEachIndexed { rowIndex, row -> Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { row.forEachIndexed { columnIndex, character -> @@ -530,6 +577,7 @@ internal fun TvKeyboard( contentDescription = "Type ${character}", onClick = { onCharacter(character.toString()) }, onFocused = { onKeyFocused(index) }, + height = keyHeight, modifier = Modifier .weight(1f) // Explicit edges. Left of the first column is the navigation @@ -565,6 +613,7 @@ internal fun TvKeyboard( contentDescription = "Insert a space", onClick = { onCharacter(" ") }, onFocused = { onKeyFocused(ACTION_ROW_INDEX) }, + height = keyHeight, modifier = Modifier .weight(2f) .focusProperties { left = navigationFocusRequester } @@ -582,6 +631,7 @@ internal fun TvKeyboard( contentDescription = "Delete the last character", onClick = onBackspace, onFocused = { onKeyFocused(ACTION_ROW_INDEX + 1) }, + height = keyHeight, modifier = Modifier .weight(2f) .then( @@ -598,6 +648,7 @@ internal fun TvKeyboard( contentDescription = "Clear the whole query", onClick = onClear, onFocused = { onKeyFocused(ACTION_ROW_INDEX + 2) }, + height = keyHeight, modifier = Modifier .weight(2f) .focusProperties { @@ -612,6 +663,40 @@ internal fun TvKeyboard( ), ) } + + // The submit key, for a host whose lookup costs something. It is deliberately full + // width and the last thing in the column, so it reads as the end of typing rather + // than as a fourth action beside Space and Delete — and so Down out of that row + // arrives at it without anybody looking for it. + if (onSearch != null) { + Spacer(Modifier.height(if (compact) 4.dp else 6.dp)) + ActionKey( + icon = Icons.Default.Search, + label = "Search", + contentDescription = "Search for what you have typed", + onClick = onSearch, + onFocused = { onKeyFocused(SEARCH_KEY_INDEX) }, + height = keyHeight + 4.dp, + // Dimmed rather than absent below the minimum: a key that appears only + // once enough has been typed moves the row underneath somebody's thumb, + // and it is the one control on this pane that explains what to do next. + enabled = searchEnabled, + emphasised = true, + modifier = Modifier + .fillMaxWidth() + .focusProperties { + left = navigationFocusRequester + right = if (hasResultsTarget) resultsEntry else FocusRequester.Cancel + } + .then( + if (lastKeyIndex == SEARCH_KEY_INDEX) { + Modifier.focusRequester(keyboardReturn) + } else { + Modifier + }, + ), + ) + } } } @@ -622,6 +707,7 @@ private fun KeyboardKey( onClick: () -> Unit, onFocused: () -> Unit, modifier: Modifier = Modifier, + height: Dp = 36.dp, ) { FocusScaleContainer( onFocused = onFocused, @@ -632,9 +718,9 @@ private fun KeyboardKey( Box( Modifier .fillMaxWidth() - // 44dp of target at six columns across a third of a 1080p screen: large - // enough to hit reliably while glancing at the results, not the keyboard. - .height(36.dp) + // Large enough to hit reliably while glancing at the results rather than + // at the keyboard. See `compact` on TvKeyboard for why it is a parameter. + .height(height) .background(if (focused) KeyFocused else KeyIdle), contentAlignment = Alignment.Center, ) { @@ -656,31 +742,51 @@ private fun ActionKey( onClick: () -> Unit, onFocused: () -> Unit, modifier: Modifier = Modifier, + /** + * A key that is still focusable but does nothing yet. It stays reachable on purpose: + * skipping it would move the row under the remote as the query grows, and the whole + * point of the disabled state is that somebody presses it and reads why nothing + * happened from the pane beside it. + */ + enabled: Boolean = true, + /** Drawn as the pane's primary action rather than as one key among several. */ + emphasised: Boolean = false, + height: Dp = 36.dp, ) { FocusScaleContainer( onFocused = onFocused, - onClick = onClick, + onClick = { if (enabled) onClick() }, contentDescription = contentDescription, modifier = modifier.clip(RoundedCornerShape(8.dp)), ) { focused -> + val background = when { + focused -> KeyFocused + emphasised && enabled -> MembyAccentMuted + else -> KeyIdle + } + val foreground = when { + focused -> KeyLabelFocused + !enabled -> MembyQuietText + else -> KeyLabel + } Row( Modifier .fillMaxWidth() - .height(36.dp) - .background(if (focused) KeyFocused else KeyIdle), + .height(height) + .background(background), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { Icon( icon, contentDescription = null, - tint = if (focused) KeyLabelFocused else KeyLabel, + tint = foreground, modifier = Modifier.size(17.dp), ) Spacer(Modifier.width(7.dp)) Text( label, - color = if (focused) KeyLabelFocused else KeyLabel, + color = foreground, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, @@ -1432,5 +1538,10 @@ private fun SearchError( private val CARD_SPACING = 16.dp private const val KEYBOARD_COLUMNS = 6 private const val ACTION_ROW_INDEX = 36 -private const val BACKSPACE_CODE = 8 -private const val FIRST_PRINTABLE_CODE = 32 + +/** The submit key's place in the same numbering, so returning focus can land on it. */ +private const val SEARCH_KEY_INDEX = ACTION_ROW_INDEX + 3 +// Internal so the Requests page's keyboard passthrough agrees with this one about what a +// typed character is; two definitions of "printable" is two behaviours on one remote. +internal const val BACKSPACE_CODE = 8 +internal const val FIRST_PRINTABLE_CODE = 32 diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/HomeGreetingTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/HomeGreetingTest.kt index b3911a7..489233f 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/HomeGreetingTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/HomeGreetingTest.kt @@ -17,13 +17,17 @@ class HomeGreetingTest { } @Test - fun `greeting is shown after hero through continue watching`() { + fun `greeting is shown from the hero through continue watching`() { val rows = listOf("featured", "continue", "latest") + // Null is the hero: the featured card is not a row, so nothing in the list holds + // focus while somebody is on it — and that is the frame every launch opens with. + assertTrue(shouldShowHomeGreeting(true, null, rows)) assertTrue(shouldShowHomeGreeting(true, "featured", rows)) assertTrue(shouldShowHomeGreeting(true, "continue", rows)) assertFalse(shouldShowHomeGreeting(true, "latest", rows)) - assertFalse(shouldShowHomeGreeting(true, null, rows)) assertFalse(shouldShowHomeGreeting(false, "continue", rows)) + // No hero means no greeting even on the opening frame: the header is a row title. + assertFalse(shouldShowHomeGreeting(false, null, rows)) } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/requests/RequestPresentationTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/requests/RequestPresentationTest.kt index c795ee9..0f88dad 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/requests/RequestPresentationTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/requests/RequestPresentationTest.kt @@ -91,16 +91,60 @@ class RequestPresentationTest { assertEquals("2 of 5 ready to watch", requestSummaryLabel(5, 2)) } + /** + * A short query is affordable again now that a lookup happens on a press rather than on + * a keystroke: it is one deliberate call to Radarr and Sonarr, not the first of eight. + */ @Test - fun `the lookup threshold is stricter than the library search's`() { - // Every keystroke here reaches Radarr and Sonarr rather than a local index. - assertFalse(shouldLookup("du")) - assertFalse(shouldLookup("dune")) - assertTrue(shouldLookup("dunes")) + fun `two characters is enough to look something up`() { + assertFalse(shouldLookup("d")) + assertTrue(shouldLookup("up")) + assertTrue(shouldLookup("dune")) assertFalse(shouldLookup(" a ")) assertTrue(shouldLookup(" dune part two ")) } + /** + * The three quiet states are the ones worth pinning: "not enough yet", "enough, and + * waiting for you to ask" and "asked, and nothing matched" read almost the same on + * screen and mean quite different things. + */ + @Test + fun `the results pane says which of the quiet states it is in`() { + fun phase( + query: String, + searched: String? = null, + searching: Boolean = false, + failed: Boolean = false, + count: Int = 0, + ) = requestsSearchPhase(query, searched, searching, failed, count) + + assertEquals(RequestsSearchPhase.IDLE, phase("")) + assertEquals(RequestsSearchPhase.TOO_SHORT, phase("d")) + assertEquals(RequestsSearchPhase.READY, phase("dune")) + assertEquals(RequestsSearchPhase.SEARCHING, phase("dune", searching = true)) + assertEquals(RequestsSearchPhase.RESULTS, phase("dune", searched = "dune", count = 3)) + assertEquals(RequestsSearchPhase.NO_MATCHES, phase("dune", searched = "dune")) + assertEquals(RequestsSearchPhase.FAILED, phase("dune", searched = "dune", failed = true)) + } + + /** + * Typing after a search means the cards on screen answer something else, so the pane + * asks to be run again rather than presenting them as the answer. + */ + @Test + fun `editing after a search returns the pane to press-search`() { + assertEquals( + RequestsSearchPhase.READY, + requestsSearchPhase("dune p", searchedTerm = "dune", searching = false, failed = false, candidateCount = 3), + ) + // And the whitespace either side of a query is not an edit. + assertEquals( + RequestsSearchPhase.RESULTS, + requestsSearchPhase(" dune ", searchedTerm = "dune", searching = false, failed = false, candidateCount = 3), + ) + } + @Test fun `the switcher's action count matches the rows actually drawn`() { assertEquals(2, userSwitcherActionCount(showRequests = false)) diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/requests/RequestsScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/requests/RequestsScreenshotTest.kt index 0a8830d..cabe764 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/requests/RequestsScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/requests/RequestsScreenshotTest.kt @@ -77,7 +77,10 @@ class RequestsScreenshotTest { RequestsUiState( tab = RequestsTab.DISCOVER, loadingRequests = false, + // searchedTerm matching the query is what makes these cards *the answer* + // rather than results the viewer has since typed past. query = "dune", + searchedTerm = "dune", hasSearched = true, candidates = sampleCandidates, ), @@ -92,7 +95,10 @@ class RequestsScreenshotTest { RequestsUiState( tab = RequestsTab.DISCOVER, loadingRequests = false, + // searchedTerm matching the query is what makes these cards *the answer* + // rather than results the viewer has since typed past. query = "dune", + searchedTerm = "dune", hasSearched = true, candidates = sampleCandidates, submitting = setOf(candidateKey("movie", 693134)), @@ -109,6 +115,37 @@ class RequestsScreenshotTest { ) } + /** + * Typed, and not looked up yet — the state this page spends most of its time in now + * that nothing reaches Radarr or Sonarr until somebody asks. The capture is here to + * check the one thing a unit test cannot: that the Search key reads as the next step + * from across a room, and that the pane beside it says so in the same breath. + */ + @Test + fun `typed but not yet searched`() { + capture( + "requests-discover-ready", + RequestsUiState( + tab = RequestsTab.DISCOVER, + loadingRequests = false, + query = "dune", + ), + ) + } + + /** Below the minimum: the Search key is drawn, reachable, and visibly not yet usable. */ + @Test + fun `too little typed to search`() { + capture( + "requests-discover-too-short", + RequestsUiState( + tab = RequestsTab.DISCOVER, + loadingRequests = false, + query = "d", + ), + ) + } + @Test fun `a search that found nothing`() { capture( @@ -117,6 +154,7 @@ class RequestsScreenshotTest { tab = RequestsTab.DISCOVER, loadingRequests = false, query = "qqqqq", + searchedTerm = "qqqqq", hasSearched = true, ), ) @@ -183,6 +221,7 @@ class RequestsScreenshotTest { onAppendToQuery = {}, onBackspace = {}, onClearQuery = {}, + onSubmitSearch = {}, onRequest = {}, onRemove = {}, onOpenItem = {},