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