0.2.60 - Improvments to Requests, placeholders
This commit is contained in:
@@ -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("You’re all caught up.", color = MembyMutedText, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
@@ -258,6 +275,133 @@ private fun AlertsEmptyState(enabled: Boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The size of the mark, and the one number the drawing is measured against. */
|
||||
private val AlertsEmptyMarkSize = 132.dp
|
||||
|
||||
/**
|
||||
* The mark in the middle of an empty page: a bell in a disc, with a slow pulse going out
|
||||
* from it while notifications are on.
|
||||
*
|
||||
* A page whose whole job is emptying itself spends most of its life empty, so this is the
|
||||
* state it is *usually* in — two lines of grey text in the middle of a black screen read as
|
||||
* a screen that failed to load rather than as good news. The pulse is what says the page is
|
||||
* listening: it stops entirely when the viewer has notifications switched off, because
|
||||
* nothing is arriving and an animation claiming otherwise would be decoration telling a
|
||||
* small lie.
|
||||
*
|
||||
* Nothing here recomposes. One `Canvas`, one animated `State<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 = {},
|
||||
|
||||
Reference in New Issue
Block a user