0.2.60 - Improvments to Requests, placeholders

This commit is contained in:
ponzischeme89
2026-08-12 16:54:41 +12:00
parent 4fb68f4bbc
commit a0b84dfc37
12 changed files with 778 additions and 177 deletions
+7 -2
View File
@@ -1,6 +1,11 @@
## 0.2.59 — 2026-08-12 ## 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. - Added: Voice search on the Requests page, beside the query, the same as Search.
- Improved: Requests now waits for five letters, and for a short pause in typing, before looking anything up. - 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: 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. - Fixed: Saving a setting could fail repeatedly with a server error.
+1 -1
View File
@@ -42,7 +42,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the // 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. // source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.59" val defaultVersionName = "0.2.60"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -12,13 +12,25 @@ internal fun homeGreetingPeriod(hourOfDay: Int): HomeGreetingPeriod = when (hour
else -> HomeGreetingPeriod.EVENING 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( internal fun shouldShowHomeGreeting(
hasHero: Boolean, hasHero: Boolean,
focusedRowId: String?, focusedRowId: String?,
rowIds: List<String>, rowIds: List<String>,
): Boolean { ): Boolean {
if (!hasHero || focusedRowId == null) return false if (!hasHero) return false
if (focusedRowId == null) return true
val focusedIndex = rowIds.indexOf(focusedRowId) val focusedIndex = rowIds.indexOf(focusedRowId)
val continueIndex = rowIds.indexOf("continue") val continueIndex = rowIds.indexOf("continue")
return focusedIndex >= 0 && continueIndex >= 0 && focusedIndex <= continueIndex return focusedIndex >= 0 && continueIndex >= 0 && focusedIndex <= continueIndex
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -2015,6 +2016,11 @@ private fun HomeScreen(
} }
var railFocusDestination by rememberSaveable { mutableStateOf(selectedDestination) } var railFocusDestination by rememberSaveable { mutableStateOf(selectedDestination) }
val navigationFocusRequester = navigationFocusRequesters.getValue(railFocusDestination) 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 contentFocusRequester = remember { FocusRequester() }
val cardReturnFocusRequester = remember { FocusRequester() } val cardReturnFocusRequester = remember { FocusRequester() }
val myShowReturnFocusRequester = remember { FocusRequester() } val myShowReturnFocusRequester = remember { FocusRequester() }
@@ -3436,36 +3442,101 @@ private fun HomeScreen(
factory = RequestsViewModelFactory(repo), factory = RequestsViewModelFactory(repo),
) )
val requestsState by requestsViewModel.state.collectAsStateWithLifecycle() val requestsState by requestsViewModel.state.collectAsStateWithLifecycle()
RequestsScreen( // The page covers the launcher, rail and all, so it carries its own rather than
state = requestsState, // leaving Left pointing at one nobody can see. Its own FocusRequester, because
navigationFocusRequester = navigationFocusRequester, // the launcher's is still attached to the rail underneath and one requester on
contentFocusRequester = contentFocusRequester, // two live nodes lands on whichever Compose reaches first.
onSelectTab = requestsViewModel::selectTab, //
onQueryChanged = requestsViewModel::onQueryChanged, // Selecting a destination from here leaves the page — this rail is how you get
onAppendToQuery = requestsViewModel::appendToQuery, // *out* of Requests, not a second way to browse behind it.
onBackspace = requestsViewModel::backspace, Row(
onClearQuery = requestsViewModel::clearQuery, Modifier
onRequest = requestsViewModel::request, .fillMaxSize()
onRemove = requestsViewModel::remove, .zIndex(6f)
onOpenItem = { itemId -> // The rail's own surface is 95% opaque, so without this the launcher
// A request that has arrived opens the thing it became. The stub is // shows faintly through the one column of the page it does not paint.
// filled in by focusItem the way a schedule card's is, so the page .background(MembySurface),
// appears at once instead of waiting on an item request. ) {
scope.launch { TvNavigationRail(
runCatching { repo.getItemDetails(itemId) } config = remoteConfig.navigation,
.onSuccess { item -> selected = selectedDestination,
closeRequests() expanded = requestsRailExpanded,
detailsAiringNotice = null navigationFocusRequester = requestsRailFocusRequester,
detailsTrail = emptyList() onRailFocusChanged = { requestsRailExpanded = it },
detailsItem = item onDestinationSelected = { destination ->
requestsRailExpanded = false
showRequests = false
genreBrowseItemType = null
when (destination) {
BrowseDestination.SETTINGS -> {
railFocusDestination = BrowseDestination.SETTINGS
restoreRailAfterSettings = true
navigationExpanded = false
userSwitcherVisible = false
showSettings = true
} }
} BrowseDestination.PROFILES -> {
}, railFocusDestination = BrowseDestination.PROFILES
onRetry = requestsViewModel::refresh, userSwitcherVisible = true
onExit = closeRequests, navigationExpanded = false
posterUrlFor = { it.takeIf(String::isNotBlank) }, showSettings = false
modifier = Modifier.zIndex(6f), 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) { if (showNotifications) {
// Reached from the user picker, so leaving it goes back to the rail rather than // Reached from the user picker, so leaving it goes back to the rail rather than
@@ -1,5 +1,13 @@
package com.ponzischeme89.memby.ui.alerts 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.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -23,6 +31,8 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NotificationsActive 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.material.icons.filled.Tv
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color 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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
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
@@ -244,6 +259,8 @@ private fun AlertsEmptyState(enabled: Boolean) {
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
AlertsEmptyMark(listening = enabled)
Spacer(Modifier.height(18.dp))
Text("Youre all caught up.", color = MembyMutedText, fontSize = 20.sp, fontWeight = FontWeight.SemiBold) Text("Youre all caught up.", color = MembyMutedText, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( 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<Float>` 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 @Composable
private fun AlertRow( private fun AlertRow(
notification: UserNotification, notification: UserNotification,
@@ -73,6 +73,65 @@ fun requestCandidateActionable(status: String): Boolean = when (status) {
else -> false 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. * What a card is grouped under on the viewer's own page.
* *
@@ -42,6 +42,11 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.GatewayMediaRequestItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.ui.FocusScaleContainer 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.TvKeyboard
import com.ponzischeme89.memby.ui.search.voiceSearchAvailable
import com.ponzischeme89.memby.ui.theme.MembyAccent 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.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
@@ -91,6 +99,7 @@ fun RequestsScreen(
onAppendToQuery: (String) -> Unit, onAppendToQuery: (String) -> Unit,
onBackspace: () -> Unit, onBackspace: () -> Unit,
onClearQuery: () -> Unit, onClearQuery: () -> Unit,
onSubmitSearch: () -> Unit,
onRequest: (GatewayRequestCandidate) -> Unit, onRequest: (GatewayRequestCandidate) -> Unit,
onRemove: (GatewayMediaRequestItem) -> Unit, onRemove: (GatewayMediaRequestItem) -> Unit,
onOpenItem: (String) -> Unit, onOpenItem: (String) -> Unit,
@@ -102,18 +111,30 @@ fun RequestsScreen(
val tabsFocusRequester = remember { FocusRequester() } val tabsFocusRequester = remember { FocusRequester() }
val paneFocusRequester = remember { FocusRequester() } val paneFocusRequester = remember { FocusRequester() }
val keyboardEntry = 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 paneFocused by remember { mutableStateOf(false) }
var resultsFocused by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
kotlinx.coroutines.delay(32L) kotlinx.coroutines.delay(32L)
runCatching { contentFocusRequester.requestFocus() } runCatching { contentFocusRequester.requestFocus() }
} }
// Back steps out one level per press, the stance every page here takes. // Back steps out one level per press, the stance every page here takes. In the search
BackHandler(enabled = paneFocused) { // 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 paneFocused = false
runCatching { tabsFocusRequester.requestFocus() } runCatching { tabsFocusRequester.requestFocus() }
} }
BackHandler(enabled = !paneFocused, onBack = onExit) BackHandler(enabled = !paneFocused && !resultsFocused, onBack = onExit)
BoxWithConstraints( BoxWithConstraints(
modifier modifier
@@ -125,7 +146,10 @@ fun RequestsScreen(
), ),
) { ) {
Column( 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) RequestsHeader(state)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
@@ -137,6 +161,7 @@ fun RequestsScreen(
navigationFocusRequester = navigationFocusRequester, navigationFocusRequester = navigationFocusRequester,
onSelectTab = { onSelectTab = {
paneFocused = false paneFocused = false
resultsFocused = false
onSelectTab(it) onSelectTab(it)
}, },
) )
@@ -169,10 +194,21 @@ fun RequestsScreen(
paneFocusRequester = paneFocusRequester, paneFocusRequester = paneFocusRequester,
tabsFocusRequester = tabsFocusRequester, tabsFocusRequester = tabsFocusRequester,
navigationFocusRequester = navigationFocusRequester, navigationFocusRequester = navigationFocusRequester,
keyboardReturn = keyboardReturn,
onFocused = { paneFocused = true }, onFocused = { paneFocused = true },
onKeyboardFocused = {
paneFocused = true
resultsFocused = false
},
onResultsFocused = {
paneFocused = true
resultsFocused = true
},
onQueryChanged = onQueryChanged,
onAppendToQuery = onAppendToQuery, onAppendToQuery = onAppendToQuery,
onBackspace = onBackspace, onBackspace = onBackspace,
onClearQuery = onClearQuery, onClearQuery = onClearQuery,
onSubmitSearch = onSubmitSearch,
onRequest = onRequest, onRequest = onRequest,
posterUrlFor = posterUrlFor, posterUrlFor = posterUrlFor,
modifier = Modifier.weight(1f).fillMaxWidth(), 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 @Composable
private fun DiscoverPane( private fun DiscoverPane(
state: RequestsUiState, state: RequestsUiState,
@@ -437,47 +493,102 @@ private fun DiscoverPane(
paneFocusRequester: FocusRequester, paneFocusRequester: FocusRequester,
tabsFocusRequester: FocusRequester, tabsFocusRequester: FocusRequester,
navigationFocusRequester: FocusRequester, navigationFocusRequester: FocusRequester,
keyboardReturn: FocusRequester,
onFocused: () -> Unit, onFocused: () -> Unit,
onKeyboardFocused: () -> Unit,
onResultsFocused: () -> Unit,
onQueryChanged: (String) -> Unit,
onAppendToQuery: (String) -> Unit, onAppendToQuery: (String) -> Unit,
onBackspace: () -> Unit, onBackspace: () -> Unit,
onClearQuery: () -> Unit, onClearQuery: () -> Unit,
onSubmitSearch: () -> Unit,
onRequest: (GatewayRequestCandidate) -> Unit, onRequest: (GatewayRequestCandidate) -> Unit,
posterUrlFor: (String) -> String?, posterUrlFor: (String) -> String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val resultsEntry = remember { FocusRequester() } val resultsEntry = remember { FocusRequester() }
val micEntry = remember { FocusRequester() }
var lastKeyIndex by remember { mutableStateOf(0) } 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() 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( Column(
Modifier Modifier
.width(340.dp) .width(340.dp)
.fillMaxHeight() .fillMaxHeight()
.onFocusChanged { if (it.hasFocus) onFocused() }, .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)) Spacer(Modifier.height(12.dp))
Box( Box(
// The keyboard is the pane's entry point, so Down from the tabs lands on it. // The keyboard is the pane's entry point, so Down from the tabs lands on
// Only the *entry* is declared here: an `up` hung off this Box would be // it. Only the entry is declared here — see the note above on inheritance.
// 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.
Modifier.focusRequester(paneFocusRequester), Modifier.focusRequester(paneFocusRequester),
) { ) {
TvKeyboard( TvKeyboard(
navigationFocusRequester = navigationFocusRequester, navigationFocusRequester = navigationFocusRequester,
resultsEntry = resultsEntry, resultsEntry = resultsEntry,
keyboardEntry = keyboardEntry, keyboardEntry = keyboardEntry,
keyboardReturn = keyboardEntry, keyboardReturn = keyboardReturn,
lastKeyIndex = lastKeyIndex, lastKeyIndex = lastKeyIndex,
hasResultsTarget = hasResults, hasResultsTarget = hasResults,
onKeyFocused = { lastKeyIndex = it }, onKeyFocused = {
lastKeyIndex = it
onKeyboardFocused()
},
onCharacter = onAppendToQuery, onCharacter = onAppendToQuery,
onBackspace = onBackspace, onBackspace = onBackspace,
onClear = onClearQuery, 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( CandidatesPane(
state = state, state = state,
resultsEntry = resultsEntry, resultsEntry = resultsEntry,
keyboardEntry = keyboardEntry, keyboardReturn = keyboardReturn,
tabsFocusRequester = tabsFocusRequester, tabsFocusRequester = tabsFocusRequester,
onFocused = onFocused, onFocused = onResultsFocused,
onRequest = onRequest, onRequest = onRequest,
posterUrlFor = posterUrlFor, posterUrlFor = posterUrlFor,
modifier = Modifier.weight(1f).fillMaxHeight(), 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 @Composable
private fun CandidatesPane( private fun CandidatesPane(
state: RequestsUiState, state: RequestsUiState,
resultsEntry: FocusRequester, resultsEntry: FocusRequester,
keyboardEntry: FocusRequester, keyboardReturn: FocusRequester,
tabsFocusRequester: FocusRequester, tabsFocusRequester: FocusRequester,
onFocused: () -> Unit, onFocused: () -> Unit,
onRequest: (GatewayRequestCandidate) -> Unit, onRequest: (GatewayRequestCandidate) -> Unit,
posterUrlFor: (String) -> String?, posterUrlFor: (String) -> String?,
modifier: Modifier = Modifier, 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) { Column(modifier) {
Row(verticalAlignment = Alignment.Bottom) { Row(verticalAlignment = Alignment.Bottom) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text("RESULTS", color = MembyAccent, fontSize = 10.sp, fontWeight = FontWeight.Bold) Text("RESULTS", color = MembyAccent, fontSize = 10.sp, fontWeight = FontWeight.Bold)
Text( Text(
when { when (phase) {
state.query.isBlank() -> "What are you after?" RequestsSearchPhase.IDLE -> "What are you after?"
!shouldLookup(state.query) -> "Keep typing…" RequestsSearchPhase.SEARCHING -> "Looking…"
state.searching -> "Looking…" // The heading names what was *asked*, so a pane of cards can never
else -> state.query.trim() // 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, color = Color.White,
fontSize = 20.sp, fontSize = 20.sp,
@@ -560,31 +659,35 @@ private fun CandidatesPane(
} }
Spacer(Modifier.height(9.dp)) Spacer(Modifier.height(9.dp))
if (state.searching && state.candidates.isEmpty()) { if (phase == RequestsSearchPhase.SEARCHING) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
repeat(3) { RequestCardSkeleton() } repeat(3) { RequestCardSkeleton() }
} }
return@Column return@Column
} }
if (state.candidates.isEmpty()) { if (phase != RequestsSearchPhase.RESULTS) {
PaneMessage( PaneMessage(
heading = when { heading = when (phase) {
state.searchError != null -> "Search failed" RequestsSearchPhase.FAILED -> "Search failed"
state.query.isBlank() -> "Search for a film or series" RequestsSearchPhase.TOO_SHORT -> "Type a little more"
!shouldLookup(state.query) -> "Type a little more" RequestsSearchPhase.READY -> "Press Search"
state.hasSearched -> "Nothing found" RequestsSearchPhase.NO_MATCHES -> "Nothing found"
else -> "Search for a film or series" else -> "Search for a film or series"
}, },
body = state.searchError body = when (phase) {
?: when { RequestsSearchPhase.FAILED ->
state.query.isBlank() -> state.searchError ?: "The search could not be run just now."
"Use the keyboard to find something the household does not have yet." RequestsSearchPhase.TOO_SHORT ->
!shouldLookup(state.query) -> "At least ${RequestsViewModel.MIN_QUERY_LENGTH} characters, so the search has something to go on."
"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
state.hasSearched -> // this pane somebody has to find, and it is directly to the left.
"Nothing matched “${state.query.trim()}”. Check the spelling, or try the original title." RequestsSearchPhase.READY ->
else -> "Use the keyboard to find something the household does not have yet." "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 return@Column
} }
@@ -625,8 +728,9 @@ private fun CandidatesPane(
) )
.focusProperties { .focusProperties {
// Left always returns to the keyboard rather than falling // Left always returns to the keyboard rather than falling
// through to the rail: the keyboard is what this pane is for. // through to the rail: the keyboard is what this pane is for,
left = keyboardEntry // and it returns to the key last used rather than to A.
left = keyboardReturn
if (candidate == state.candidates.first()) up = tabsFocusRequester if (candidate == state.candidates.first()) up = tabsFocusRequester
}, },
) )
@@ -7,16 +7,10 @@ import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.friendlyEmbyError import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.model.GatewayMediaRequestItem import com.ponzischeme89.memby.data.model.GatewayMediaRequestItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow 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.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -37,6 +31,14 @@ data class RequestsUiState(
val searching: Boolean = false, val searching: Boolean = false,
val hasSearched: Boolean = false, val hasSearched: Boolean = false,
val searchError: String? = null, 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 * 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 * 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. * Owns the two panes' state.
* *
* The search half reuses the Search tab's pipeline exactly — `debounce` → `trim` → * **The search half does not search as you type**, which is the one way it deliberately
* `distinctUntilChanged` → `collectLatest` — and `collectLatest` is the load-bearing part * differs from the Search tab. That tab queries a local index and can afford a lookup per
* for the same reason it is there: it cancels the in-flight lookup, so a slow answer for a * keystroke; this one is a live Radarr *and* Sonarr query measured in seconds, and typing a
* prefix can never overwrite the results for what was typed after it. Radarr and Sonarr * title on a remote is slow enough that every letter past the threshold became its own pair
* lookups are live provider queries measured in seconds, so that race is not hypothetical * of provider calls — eight round trips to answer a question asked once. Debouncing only
* here; it is the ordinary case. * 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() { class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() {
private val _state = MutableStateFlow(RequestsUiState()) private val _state = MutableStateFlow(RequestsUiState())
val state: StateFlow<RequestsUiState> = _state.asStateFlow() val state: StateFlow<RequestsUiState> = _state.asStateFlow()
private val queryFlow = MutableStateFlow("")
private var refreshJob: Job? = null private var refreshJob: Job? = null
private var searchJob: Job? = null
init { init {
viewModelScope.launch {
queryFlow
.debounce(DEBOUNCE_MS)
.map { it.trim() }
.distinctUntilChanged()
.collectLatest { term -> runLookup(term) }
}
refresh() refresh()
} }
@@ -123,19 +123,20 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() {
} }
fun onQueryChanged(query: String) { 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 { _state.update {
it.copy( it.copy(
query = query, query = query,
notice = null, notice = null,
// Results for the previous term are cleared as soon as the term changes. candidates = emptyList(),
// Leaving them up under a new query is how somebody requests the wrong film. hasSearched = false,
candidates = if (shouldLookup(query)) it.candidates else emptyList(), searchedTerm = null,
hasSearched = if (shouldLookup(query)) it.hasSearched else false,
searchError = null, searchError = null,
) )
} }
queryFlow.value = query
} }
fun appendToQuery(text: String) = onQueryChanged(_state.value.query + text) 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 backspace() = onQueryChanged(_state.value.query.dropLast(1))
fun clearQuery() { fun clearQuery() {
searchJob?.cancel()
_state.update { _state.update {
it.copy( it.copy(
query = "", candidates = emptyList(), searching = false, 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) { private suspend fun runLookup(term: String) {
if (!shouldLookup(term)) { _state.update { it.copy(searching = true, searchError = null, notice = null) }
_state.update { it.copy(searching = false, candidates = emptyList(), hasSearched = false) }
return
}
_state.update { it.copy(searching = true, searchError = null) }
runCatching { repository.lookupMediaRequests(term) } runCatching { repository.lookupMediaRequests(term) }
.onSuccess { candidates -> .onSuccess { candidates ->
// Guard against a response for a term the viewer has already typed past. // Guard against an answer for a term the viewer has since edited past. The
// collectLatest cancels the coroutine, but a call already past its last // job is cancelled, but a call already past its last suspension point still
// suspension point still completes — the calendar's requestedMonth rule. // completes — the calendar's requestedMonth rule.
if (_state.value.query.trim() != term) return@onSuccess if (_state.value.query.trim() != term) return@onSuccess
_state.update { _state.update {
it.copy(candidates = candidates, searching = false, hasSearched = true) it.copy(
candidates = candidates, searching = false,
hasSearched = true, searchedTerm = term,
)
} }
} }
.onFailure { error -> .onFailure { error ->
@@ -173,7 +188,7 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() {
if (_state.value.query.trim() != term) return@onFailure if (_state.value.query.trim() != term) return@onFailure
_state.update { _state.update {
it.copy( it.copy(
searching = false, hasSearched = true, searching = false, hasSearched = true, searchedTerm = term,
searchError = friendlyEmbyError(error), searchError = friendlyEmbyError(error),
) )
} }
@@ -268,26 +283,19 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() {
companion object { companion object {
/** /**
* Longer than the Search tab's, because the thing at the other end is different. * Two characters, the same floor the gateway independently enforces.
* 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.
* *
* Every keystroke here reaches Radarr and Sonarr rather than a local index, so a * It was raised to five while this page searched as you type, because every letter
* viewer typing a title on a remote spends a live provider query per letter — and * past the threshold cost a pair of live provider queries and the early ones
* the early ones are worthless: a three-letter prefix returns a hundred films * answered nothing worth having. With the lookup behind a press that reasoning is
* nobody meant. Five is roughly where a prefix starts naming something, so the * gone: a short query is one deliberate call, and "Up" is a film somebody may
* lookups begin when they can answer rather than as soon as they are legal. The * genuinely be looking for.
* gateway independently refuses under two.
*/ */
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 = fun shouldLookup(query: String): Boolean =
query.trim().length >= RequestsViewModel.MIN_QUERY_LENGTH query.trim().length >= RequestsViewModel.MIN_QUERY_LENGTH
@@ -109,6 +109,7 @@ import com.ponzischeme89.memby.ui.theme.MembyAccentMuted
import com.ponzischeme89.memby.ui.theme.MembyControlSurface import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface 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.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@@ -354,7 +355,7 @@ private fun SearchPane(
) { ) {
Text("Search", color = Heading, fontSize = 26.sp, fontWeight = FontWeight.SemiBold) Text("Search", color = Heading, fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
QueryField( SearchQueryField(
query = state.query, query = state.query,
loading = state.isLoading, loading = state.isLoading,
onVoiceResult = onVoiceResult, 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 * 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. * 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 `<queries>`,
* which is why that entry is there.
*/
internal fun voiceSearchAvailable(context: android.content.Context): Boolean =
SpeechRecognizer.isRecognitionAvailable(context)
@Composable @Composable
private fun QueryField( internal fun SearchQueryField(
query: String, query: String,
loading: Boolean, loading: Boolean,
onVoiceResult: (String) -> Unit, onVoiceResult: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String = "Search movies, shows and episodes",
micModifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val voiceAvailable = remember { SpeechRecognizer.isRecognitionAvailable(context) } val voiceAvailable = remember { voiceSearchAvailable(context) }
val voiceLauncher = rememberLauncherForActivityResult( val voiceLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(), ActivityResultContracts.StartActivityForResult(),
) { result -> ) { result ->
@@ -426,7 +452,7 @@ private fun QueryField(
) { startVoiceSearch() } ) { startVoiceSearch() }
Row( Row(
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
.background(MembySurfaceRaised) .background(MembySurfaceRaised)
@@ -442,7 +468,7 @@ private fun QueryField(
) )
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Text( Text(
text = query.ifEmpty { "Search movies, shows and episodes" }, text = query.ifEmpty { placeholder },
color = if (query.isEmpty()) Muted else Heading, color = if (query.isEmpty()) Muted else Heading,
// Fixed size rather than shrinking as the query grows: readable at three // Fixed size rather than shrinking as the query grows: readable at three
// metres matters more than fitting a long query on one line. // metres matters more than fitting a long query on one line.
@@ -476,7 +502,7 @@ private fun QueryField(
} }
}, },
contentDescription = "Search by voice", contentDescription = "Search by voice",
modifier = Modifier.clip(RoundedCornerShape(8.dp)), modifier = micModifier.clip(RoundedCornerShape(8.dp)),
) { focused -> ) { focused ->
Icon( Icon(
Icons.Default.Mic, Icons.Default.Mic,
@@ -519,8 +545,29 @@ internal fun TvKeyboard(
* leaves for the tabs instead of moving one row up. * leaves for the tabs instead of moving one row up.
*/ */
upTarget: FocusRequester? = null, 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 -> KeyboardRows.forEachIndexed { rowIndex, row ->
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
row.forEachIndexed { columnIndex, character -> row.forEachIndexed { columnIndex, character ->
@@ -530,6 +577,7 @@ internal fun TvKeyboard(
contentDescription = "Type ${character}", contentDescription = "Type ${character}",
onClick = { onCharacter(character.toString()) }, onClick = { onCharacter(character.toString()) },
onFocused = { onKeyFocused(index) }, onFocused = { onKeyFocused(index) },
height = keyHeight,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
// Explicit edges. Left of the first column is the navigation // Explicit edges. Left of the first column is the navigation
@@ -565,6 +613,7 @@ internal fun TvKeyboard(
contentDescription = "Insert a space", contentDescription = "Insert a space",
onClick = { onCharacter(" ") }, onClick = { onCharacter(" ") },
onFocused = { onKeyFocused(ACTION_ROW_INDEX) }, onFocused = { onKeyFocused(ACTION_ROW_INDEX) },
height = keyHeight,
modifier = Modifier modifier = Modifier
.weight(2f) .weight(2f)
.focusProperties { left = navigationFocusRequester } .focusProperties { left = navigationFocusRequester }
@@ -582,6 +631,7 @@ internal fun TvKeyboard(
contentDescription = "Delete the last character", contentDescription = "Delete the last character",
onClick = onBackspace, onClick = onBackspace,
onFocused = { onKeyFocused(ACTION_ROW_INDEX + 1) }, onFocused = { onKeyFocused(ACTION_ROW_INDEX + 1) },
height = keyHeight,
modifier = Modifier modifier = Modifier
.weight(2f) .weight(2f)
.then( .then(
@@ -598,6 +648,7 @@ internal fun TvKeyboard(
contentDescription = "Clear the whole query", contentDescription = "Clear the whole query",
onClick = onClear, onClick = onClear,
onFocused = { onKeyFocused(ACTION_ROW_INDEX + 2) }, onFocused = { onKeyFocused(ACTION_ROW_INDEX + 2) },
height = keyHeight,
modifier = Modifier modifier = Modifier
.weight(2f) .weight(2f)
.focusProperties { .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, onClick: () -> Unit,
onFocused: () -> Unit, onFocused: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
height: Dp = 36.dp,
) { ) {
FocusScaleContainer( FocusScaleContainer(
onFocused = onFocused, onFocused = onFocused,
@@ -632,9 +718,9 @@ private fun KeyboardKey(
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
// 44dp of target at six columns across a third of a 1080p screen: large // Large enough to hit reliably while glancing at the results rather than
// enough to hit reliably while glancing at the results, not the keyboard. // at the keyboard. See `compact` on TvKeyboard for why it is a parameter.
.height(36.dp) .height(height)
.background(if (focused) KeyFocused else KeyIdle), .background(if (focused) KeyFocused else KeyIdle),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@@ -656,31 +742,51 @@ private fun ActionKey(
onClick: () -> Unit, onClick: () -> Unit,
onFocused: () -> Unit, onFocused: () -> Unit,
modifier: Modifier = Modifier, 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( FocusScaleContainer(
onFocused = onFocused, onFocused = onFocused,
onClick = onClick, onClick = { if (enabled) onClick() },
contentDescription = contentDescription, contentDescription = contentDescription,
modifier = modifier.clip(RoundedCornerShape(8.dp)), modifier = modifier.clip(RoundedCornerShape(8.dp)),
) { focused -> ) { focused ->
val background = when {
focused -> KeyFocused
emphasised && enabled -> MembyAccentMuted
else -> KeyIdle
}
val foreground = when {
focused -> KeyLabelFocused
!enabled -> MembyQuietText
else -> KeyLabel
}
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(36.dp) .height(height)
.background(if (focused) KeyFocused else KeyIdle), .background(background),
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Icon(
icon, icon,
contentDescription = null, contentDescription = null,
tint = if (focused) KeyLabelFocused else KeyLabel, tint = foreground,
modifier = Modifier.size(17.dp), modifier = Modifier.size(17.dp),
) )
Spacer(Modifier.width(7.dp)) Spacer(Modifier.width(7.dp))
Text( Text(
label, label,
color = if (focused) KeyLabelFocused else KeyLabel, color = foreground,
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
@@ -1432,5 +1538,10 @@ private fun SearchError(
private val CARD_SPACING = 16.dp private val CARD_SPACING = 16.dp
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
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
@@ -17,13 +17,17 @@ class HomeGreetingTest {
} }
@Test @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") 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, "featured", rows))
assertTrue(shouldShowHomeGreeting(true, "continue", rows)) assertTrue(shouldShowHomeGreeting(true, "continue", rows))
assertFalse(shouldShowHomeGreeting(true, "latest", rows)) assertFalse(shouldShowHomeGreeting(true, "latest", rows))
assertFalse(shouldShowHomeGreeting(true, null, rows))
assertFalse(shouldShowHomeGreeting(false, "continue", 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))
} }
} }
@@ -91,16 +91,60 @@ class RequestPresentationTest {
assertEquals("2 of 5 ready to watch", requestSummaryLabel(5, 2)) 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 @Test
fun `the lookup threshold is stricter than the library search's`() { fun `two characters is enough to look something up`() {
// Every keystroke here reaches Radarr and Sonarr rather than a local index. assertFalse(shouldLookup("d"))
assertFalse(shouldLookup("du")) assertTrue(shouldLookup("up"))
assertFalse(shouldLookup("dune")) assertTrue(shouldLookup("dune"))
assertTrue(shouldLookup("dunes"))
assertFalse(shouldLookup(" a ")) assertFalse(shouldLookup(" a "))
assertTrue(shouldLookup(" dune part two ")) 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 @Test
fun `the switcher's action count matches the rows actually drawn`() { fun `the switcher's action count matches the rows actually drawn`() {
assertEquals(2, userSwitcherActionCount(showRequests = false)) assertEquals(2, userSwitcherActionCount(showRequests = false))
@@ -77,7 +77,10 @@ class RequestsScreenshotTest {
RequestsUiState( RequestsUiState(
tab = RequestsTab.DISCOVER, tab = RequestsTab.DISCOVER,
loadingRequests = false, loadingRequests = false,
// searchedTerm matching the query is what makes these cards *the answer*
// rather than results the viewer has since typed past.
query = "dune", query = "dune",
searchedTerm = "dune",
hasSearched = true, hasSearched = true,
candidates = sampleCandidates, candidates = sampleCandidates,
), ),
@@ -92,7 +95,10 @@ class RequestsScreenshotTest {
RequestsUiState( RequestsUiState(
tab = RequestsTab.DISCOVER, tab = RequestsTab.DISCOVER,
loadingRequests = false, loadingRequests = false,
// searchedTerm matching the query is what makes these cards *the answer*
// rather than results the viewer has since typed past.
query = "dune", query = "dune",
searchedTerm = "dune",
hasSearched = true, hasSearched = true,
candidates = sampleCandidates, candidates = sampleCandidates,
submitting = setOf(candidateKey("movie", 693134)), 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 @Test
fun `a search that found nothing`() { fun `a search that found nothing`() {
capture( capture(
@@ -117,6 +154,7 @@ class RequestsScreenshotTest {
tab = RequestsTab.DISCOVER, tab = RequestsTab.DISCOVER,
loadingRequests = false, loadingRequests = false,
query = "qqqqq", query = "qqqqq",
searchedTerm = "qqqqq",
hasSearched = true, hasSearched = true,
), ),
) )
@@ -183,6 +221,7 @@ class RequestsScreenshotTest {
onAppendToQuery = {}, onAppendToQuery = {},
onBackspace = {}, onBackspace = {},
onClearQuery = {}, onClearQuery = {},
onSubmitSearch = {},
onRequest = {}, onRequest = {},
onRemove = {}, onRemove = {},
onOpenItem = {}, onOpenItem = {},