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
+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
// 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()
@@ -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<String>,
): 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
@@ -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
@@ -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("Youre 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<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
private fun AlertRow(
notification: UserNotification,
@@ -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.
*
@@ -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
},
)
@@ -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<RequestsUiState> = _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
@@ -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 `<queries>`,
* 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
@@ -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))
}
}
@@ -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))
@@ -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 = {},