0.2.80
This commit is contained in:
@@ -722,10 +722,10 @@ caches results per query for the session, so backspacing is instant.
|
|||||||
**Every search the tab performs is recorded, and the gateway is what records it.** It used
|
**Every search the tab performs is recorded, and the gateway is what records it.** It used
|
||||||
to depend entirely on the television posting to `/v1/search/history` after a result landed,
|
to depend entirely on the television posting to `/v1/search/history` after a result landed,
|
||||||
which meant a query answered from the client's own cache, one whose post failed, or one
|
which meant a query answered from the client's own cache, one whose post failed, or one
|
||||||
from a build that predates the call was never written down at all — and the table feeding
|
from a build that predates the call was never written down at all — and the table the
|
||||||
the recent-searches row and future per-user ranking was a partial record of what the
|
console reads back was a partial record of what the household looks for. `handleSearch`
|
||||||
household looks for. `handleSearch` now calls `recordSearchQuery` itself, before the Redis
|
now calls `recordSearchQuery` itself, before the Redis lookup, so a cached answer counts
|
||||||
lookup, so a cached answer counts the same as one that reached Emby. Things to preserve:
|
the same as one that reached Emby. Things to preserve:
|
||||||
|
|
||||||
- **The write is detached from the request context.** Instant search cancels the in-flight
|
- **The write is detached from the request context.** Instant search cancels the in-flight
|
||||||
request on every keystroke, so a write hung off `r.Context()` would be abandoned for
|
request on every keystroke, so a write hung off `r.Context()` would be abandoned for
|
||||||
|
|||||||
@@ -63,7 +63,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.79"
|
val defaultVersionName = "0.2.80"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -982,13 +982,6 @@ class EmbyRepository internal constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Memby's database has no search history; this is a persisted Memby gateway feature. */
|
|
||||||
suspend fun getRecentSearches(): List<String> {
|
|
||||||
if (!ServerConfig.isGateway) return emptyList()
|
|
||||||
return runCatching { requireGateway().recentSearches().queries }
|
|
||||||
.getOrDefault(emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One month of the TV calendar, or an unavailable month when there is nobody to ask.
|
* One month of the TV calendar, or an unavailable month when there is nobody to ask.
|
||||||
*
|
*
|
||||||
@@ -1179,6 +1172,17 @@ class EmbyRepository internal constructor(
|
|||||||
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "dismiss")
|
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "dismiss")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears this viewer's notifications in one request and answers how many were taken.
|
||||||
|
*
|
||||||
|
* The shortcut in the user picker calls this rather than looping [dismissNotification]:
|
||||||
|
* one request keeps the count honest and leaves the gateway one line in the log naming
|
||||||
|
* who cleared what. With no gateway there is nothing to clear — the direct path has no
|
||||||
|
* notifications at all — so it answers zero rather than pretending to have done work.
|
||||||
|
*/
|
||||||
|
suspend fun clearNotifications(): Int =
|
||||||
|
if (ServerConfig.isGateway) requireGateway().clearNotifications().cleared else 0
|
||||||
|
|
||||||
fun myShowImageUrl(itemId: String, imageTag: String, maxWidth: Int = 320): String? =
|
fun myShowImageUrl(itemId: String, imageTag: String, maxWidth: Int = 320): String? =
|
||||||
imageTag.takeIf(String::isNotBlank)?.let {
|
imageTag.takeIf(String::isNotBlank)?.let {
|
||||||
imageUrl(itemId, "Primary", it, maxWidth)
|
imageUrl(itemId, "Primary", it, maxWidth)
|
||||||
|
|||||||
@@ -574,11 +574,6 @@ data class GatewayMagicRequest(
|
|||||||
val availableMinutes: Int = 0,
|
val availableMinutes: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class GatewaySearchHistory(
|
|
||||||
val queries: List<String> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GatewayRequestCandidate(
|
data class GatewayRequestCandidate(
|
||||||
val mediaType: String,
|
val mediaType: String,
|
||||||
@@ -927,6 +922,17 @@ data class NotificationsResponse(
|
|||||||
val preferences: NotificationPreferences = NotificationPreferences(),
|
val preferences: NotificationPreferences = NotificationPreferences(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many notifications a clear-all press actually took.
|
||||||
|
*
|
||||||
|
* The count is the gateway's, not the television's: a row somebody dismissed on another set
|
||||||
|
* a moment earlier is one this must not claim, and the confirmation the viewer reads is
|
||||||
|
* built from this number. Defaults to zero so a gateway that predates the route — which
|
||||||
|
* answers 404 rather than a body — can never produce a confirmation claiming otherwise.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ClearNotificationsResponse(val cleared: Int = 0)
|
||||||
|
|
||||||
/** One row-engagement event. See `data/analytics/RowAnalytics.kt`. */
|
/** One row-engagement event. See `data/analytics/RowAnalytics.kt`. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GatewayRowEvent(
|
data class GatewayRowEvent(
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
|
|||||||
import com.ponzischeme89.memby.data.model.GatewayRowEvents
|
import com.ponzischeme89.memby.data.model.GatewayRowEvents
|
||||||
import com.ponzischeme89.memby.data.model.GatewayJourneyEvents
|
import com.ponzischeme89.memby.data.model.GatewayJourneyEvents
|
||||||
import com.ponzischeme89.memby.data.model.GatewayRows
|
import com.ponzischeme89.memby.data.model.GatewayRows
|
||||||
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
|
|
||||||
import com.ponzischeme89.memby.data.model.GatewayRequestLookup
|
import com.ponzischeme89.memby.data.model.GatewayRequestLookup
|
||||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||||
@@ -111,9 +110,6 @@ interface GatewayApi {
|
|||||||
@POST("v1/search/history")
|
@POST("v1/search/history")
|
||||||
suspend fun recordSearch(@Body body: Map<String, String>)
|
suspend fun recordSearch(@Body body: Map<String, String>)
|
||||||
|
|
||||||
@GET("v1/search/history")
|
|
||||||
suspend fun recentSearches(): GatewaySearchHistory
|
|
||||||
|
|
||||||
@GET("v1/requests/lookup")
|
@GET("v1/requests/lookup")
|
||||||
suspend fun requestLookup(@Query("q") term: String): GatewayRequestLookup
|
suspend fun requestLookup(@Query("q") term: String): GatewayRequestLookup
|
||||||
|
|
||||||
@@ -177,6 +173,16 @@ interface GatewayApi {
|
|||||||
@Body body: com.ponzischeme89.memby.data.model.NotificationPreferences,
|
@Body body: com.ponzischeme89.memby.data.model.NotificationPreferences,
|
||||||
): com.ponzischeme89.memby.data.model.NotificationsResponse
|
): com.ponzischeme89.memby.data.model.NotificationsResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears this viewer's list in one request and answers how many rows it took.
|
||||||
|
*
|
||||||
|
* Deliberately not a loop over the route below: the shortcut in the user picker has to
|
||||||
|
* confirm what it did, and a count of the requests a television made is a count of what
|
||||||
|
* it asked for rather than of what happened.
|
||||||
|
*/
|
||||||
|
@POST("v1/notifications/clear")
|
||||||
|
suspend fun clearNotifications(): com.ponzischeme89.memby.data.model.ClearNotificationsResponse
|
||||||
|
|
||||||
@POST("v1/notifications/{id}/{action}")
|
@POST("v1/notifications/{id}/{action}")
|
||||||
suspend fun updateNotification(
|
suspend fun updateNotification(
|
||||||
@Path("id") id: Long,
|
@Path("id") id: Long,
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
|
|||||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
||||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -253,6 +252,12 @@ fun TvNavigationRail(
|
|||||||
activeProfileInitials: String = "",
|
activeProfileInitials: String = "",
|
||||||
calendarEnabled: Boolean = false,
|
calendarEnabled: Boolean = false,
|
||||||
genresEnabled: Boolean = true,
|
genresEnabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* A hold on the user item, which is what raises the shortcut menu over the launcher.
|
||||||
|
* Null leaves every item with the ordinary press it has always had — the rail is drawn
|
||||||
|
* beside screens that have nowhere to put a menu.
|
||||||
|
*/
|
||||||
|
onUserLongPressed: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
var railHasFocus by remember { mutableStateOf(false) }
|
var railHasFocus by remember { mutableStateOf(false) }
|
||||||
val logoScale = remember { Animatable(0.72f) }
|
val logoScale = remember { Animatable(0.72f) }
|
||||||
@@ -417,6 +422,12 @@ fun TvNavigationRail(
|
|||||||
avatarInitials = activeUsername.takeIf {
|
avatarInitials = activeUsername.takeIf {
|
||||||
destination == BrowseDestination.PROFILES
|
destination == BrowseDestination.PROFILES
|
||||||
}?.let { profileInitials(it, activeProfileInitials) },
|
}?.let { profileInitials(it, activeProfileInitials) },
|
||||||
|
// Only the user item. A hold anywhere else on the rail still means the
|
||||||
|
// press it always meant, so nothing a viewer already knows changes.
|
||||||
|
onLongClick = onUserLongPressed.takeIf {
|
||||||
|
destination == BrowseDestination.PROFILES
|
||||||
|
},
|
||||||
|
longPressLabel = "User shortcuts",
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
@@ -788,6 +799,13 @@ fun ExpandableNavigationItem(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
badge: String? = null,
|
badge: String? = null,
|
||||||
avatarInitials: String? = null,
|
avatarInitials: String? = null,
|
||||||
|
/**
|
||||||
|
* A hold rather than a press. Only the user item offers one today — it is where the
|
||||||
|
* shortcut that clears this viewer's notifications lives — so a rail item with nothing
|
||||||
|
* behind a hold keeps the ordinary press it always had, and this stays null.
|
||||||
|
*/
|
||||||
|
onLongClick: (() -> Unit)? = null,
|
||||||
|
longPressLabel: String = "Quick actions",
|
||||||
) {
|
) {
|
||||||
var focused by remember { mutableStateOf(false) }
|
var focused by remember { mutableStateOf(false) }
|
||||||
// Not `by`: both colours are read in the draw phase / at the point of use, so the
|
// Not `by`: both colours are read in the draw phase / at the point of use, so the
|
||||||
@@ -820,9 +838,15 @@ fun ExpandableNavigationItem(
|
|||||||
}
|
}
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.drawBehind { drawRect(background.value) }
|
.drawBehind { drawRect(background.value) }
|
||||||
.clickable(onClick = onClick)
|
.remoteLongPress(onClick = onClick, onLongClick = onLongClick)
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = badge?.let { "$label, $it waiting" } ?: label
|
contentDescription = badge?.let { "$label, $it waiting" } ?: label
|
||||||
|
if (onLongClick != null) {
|
||||||
|
onLongClick(longPressLabel) {
|
||||||
|
onLongClick.invoke()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(horizontal = 8.dp),
|
.padding(horizontal = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -1663,12 +1687,6 @@ internal fun MediaRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
row.items.isEmpty() && row.id == "favourite-shows" -> {
|
|
||||||
FavoriteShowsEmptyState(
|
|
||||||
navigationFocusRequester = navigationFocusRequester,
|
|
||||||
onContentFocused = onContentFocused,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
row.items.isEmpty() -> {
|
row.items.isEmpty() -> {
|
||||||
Text(
|
Text(
|
||||||
row.emptyMessage,
|
row.emptyMessage,
|
||||||
@@ -1756,64 +1774,6 @@ internal fun MediaRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun FavoriteShowsEmptyState(
|
|
||||||
navigationFocusRequester: FocusRequester,
|
|
||||||
onContentFocused: () -> Unit,
|
|
||||||
) {
|
|
||||||
var focused by remember { mutableStateOf(false) }
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 36.dp, vertical = 10.dp)
|
|
||||||
.clip(RoundedCornerShape(MembyPanelCorner))
|
|
||||||
.background(Color.White.copy(alpha = if (focused) 0.11f else 0.055f))
|
|
||||||
.border(
|
|
||||||
2.dp,
|
|
||||||
if (focused) Color.White else Color.White.copy(alpha = 0.14f),
|
|
||||||
RoundedCornerShape(MembyPanelCorner),
|
|
||||||
)
|
|
||||||
.focusProperties { left = navigationFocusRequester }
|
|
||||||
.onFocusChanged {
|
|
||||||
focused = it.isFocused
|
|
||||||
if (it.isFocused) onContentFocused()
|
|
||||||
}
|
|
||||||
.focusable()
|
|
||||||
.padding(horizontal = 22.dp, vertical = 20.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(46.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color.White.copy(alpha = 0.09f)),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = MembyIcon.FavouriteOutline.mark,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(25.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
Text(
|
|
||||||
"No favourite shows yet",
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 17.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Mark a series as a favourite and it’ll be waiting here.",
|
|
||||||
color = MutedText,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun GalleryJumpButton(
|
private fun GalleryJumpButton(
|
||||||
forward: Boolean,
|
forward: Boolean,
|
||||||
@@ -2312,10 +2272,6 @@ fun FocusScaleContainer(
|
|||||||
content: @Composable BoxScope.(focused: Boolean) -> Unit,
|
content: @Composable BoxScope.(focused: Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
var focused by remember { mutableStateOf(false) }
|
var focused by remember { mutableStateOf(false) }
|
||||||
val pressScope = rememberCoroutineScope()
|
|
||||||
var holdJob by remember { mutableStateOf<Job?>(null) }
|
|
||||||
var remoteLongPressHandled by remember { mutableStateOf(false) }
|
|
||||||
val currentOnLongClick by rememberUpdatedState(onLongClick)
|
|
||||||
val scale = animateFloatAsState(
|
val scale = animateFloatAsState(
|
||||||
targetValue = if (focused) 1.025f else 1f,
|
targetValue = if (focused) 1.025f else 1f,
|
||||||
animationSpec = tween(95),
|
animationSpec = tween(95),
|
||||||
@@ -2333,55 +2289,12 @@ fun FocusScaleContainer(
|
|||||||
}
|
}
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
focused = it.isFocused
|
focused = it.isFocused
|
||||||
if (it.isFocused) {
|
if (it.isFocused) onFocused()
|
||||||
onFocused()
|
|
||||||
} else {
|
|
||||||
holdJob?.cancel()
|
|
||||||
holdJob = null
|
|
||||||
remoteLongPressHandled = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.then(
|
// The hold, the swallowed repeats and the cancel-on-focus-loss are
|
||||||
if (onLongClick != null) {
|
// [Modifier.remoteLongPress]'s — shared with the rail's user item, so the length
|
||||||
Modifier
|
// of press that opens a menu is the same wherever a menu can be opened.
|
||||||
.onPreviewKeyEvent { event ->
|
.remoteLongPress(onClick = onClick, onLongClick = onLongClick)
|
||||||
val native = event.nativeKeyEvent
|
|
||||||
val activationKey = native.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER ||
|
|
||||||
native.keyCode == android.view.KeyEvent.KEYCODE_ENTER ||
|
|
||||||
native.keyCode == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER
|
|
||||||
when {
|
|
||||||
activationKey &&
|
|
||||||
native.action == android.view.KeyEvent.ACTION_DOWN &&
|
|
||||||
native.repeatCount == 0 &&
|
|
||||||
holdJob == null &&
|
|
||||||
!remoteLongPressHandled -> {
|
|
||||||
holdJob = pressScope.launch {
|
|
||||||
delay(QuickActionsHoldDurationMillis)
|
|
||||||
remoteLongPressHandled = true
|
|
||||||
holdJob = null
|
|
||||||
currentOnLongClick?.invoke()
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
activationKey &&
|
|
||||||
native.action == android.view.KeyEvent.ACTION_DOWN -> true
|
|
||||||
activationKey &&
|
|
||||||
native.action == android.view.KeyEvent.ACTION_UP -> {
|
|
||||||
val wasLongPress = remoteLongPressHandled
|
|
||||||
holdJob?.cancel()
|
|
||||||
holdJob = null
|
|
||||||
remoteLongPressHandled = false
|
|
||||||
if (!wasLongPress) onClick()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
} else {
|
|
||||||
Modifier.clickable(onClick = onClick)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.semantics(mergeDescendants = true) {
|
.semantics(mergeDescendants = true) {
|
||||||
this.contentDescription = contentDescription
|
this.contentDescription = contentDescription
|
||||||
if (onLongClick != null) {
|
if (onLongClick != null) {
|
||||||
|
|||||||
@@ -1913,6 +1913,10 @@ private fun HomeScreen(
|
|||||||
// standing, and it is nowhere near the launcher.
|
// standing, and it is nowhere near the launcher.
|
||||||
var addingProfile by remember { mutableStateOf(false) }
|
var addingProfile by remember { mutableStateOf(false) }
|
||||||
var userSwitcherVisible by remember { mutableStateOf(false) }
|
var userSwitcherVisible by remember { mutableStateOf(false) }
|
||||||
|
// The shortcut menu a hold on the rail's user item raises. Kept beside the picker rather
|
||||||
|
// than inside it: the two are alternative answers to the same control, and only one of
|
||||||
|
// them may ever be on screen.
|
||||||
|
var userQuickActionsVisible by remember { mutableStateOf(false) }
|
||||||
var switchingProfileId by remember { mutableStateOf<String?>(null) }
|
var switchingProfileId by remember { mutableStateOf<String?>(null) }
|
||||||
var removingProfileId by remember { mutableStateOf<String?>(null) }
|
var removingProfileId by remember { mutableStateOf<String?>(null) }
|
||||||
var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
|
var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
|
||||||
@@ -1983,8 +1987,6 @@ private fun HomeScreen(
|
|||||||
// the row list is out of scope. Saved with the rest so a recreate mid-browse does not
|
// the row list is out of scope. Saved with the rest so a recreate mid-browse does not
|
||||||
// file the next resume under nothing.
|
// file the next resume under nothing.
|
||||||
var returnRowKind by rememberSaveable { mutableStateOf<String?>(null) }
|
var returnRowKind by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var recentSearches by remember { mutableStateOf<List<String>>(emptyList()) }
|
|
||||||
var initialSearchQuery by rememberSaveable { mutableStateOf<String?>(null) }
|
|
||||||
// Destination and row list states live above the conditional content branches.
|
// Destination and row list states live above the conditional content branches.
|
||||||
// Opening Search/Settings/details therefore never creates a new list at position 0.
|
// Opening Search/Settings/details therefore never creates a new list at position 0.
|
||||||
val verticalStates = rememberSaveable(saver = LazyListStateMapSaver) {
|
val verticalStates = rememberSaveable(saver = LazyListStateMapSaver) {
|
||||||
@@ -2022,6 +2024,11 @@ private fun HomeScreen(
|
|||||||
// than the first, and the search obligingly skipped past the thing somebody had just
|
// than the first, and the search obligingly skipped past the thing somebody had just
|
||||||
// been reading about in Continue Watching.
|
// been reading about in Continue Watching.
|
||||||
val heroRowEntryFocusRequester = remember { FocusRequester() }
|
val heroRowEntryFocusRequester = remember { FocusRequester() }
|
||||||
|
// My Shows sits above the shelves on the television page, so it is the first stop
|
||||||
|
// below the hero and the thing Up out of the topmost shelf must return to. It is its
|
||||||
|
// own requester rather than the rail's entry one for the same reason the shelves' hero
|
||||||
|
// entry is: the entry requester belongs to whichever band the rail hands focus to.
|
||||||
|
val myShowsEntryFocusRequester = remember { FocusRequester() }
|
||||||
// Feature flags can remove the node that currently owns focus. Move the route first,
|
// Feature flags can remove the node that currently owns focus. Move the route first,
|
||||||
// wait for its replacement to compose, then establish an explicit remote target.
|
// wait for its replacement to compose, then establish an explicit remote target.
|
||||||
// A viewer standing on the Requests page when an operator withdraws access is moved
|
// A viewer standing on the Requests page when an operator withdraws access is moved
|
||||||
@@ -2145,6 +2152,7 @@ private fun HomeScreen(
|
|||||||
showSettings = false
|
showSettings = false
|
||||||
showProfiles = false
|
showProfiles = false
|
||||||
userSwitcherVisible = false
|
userSwitcherVisible = false
|
||||||
|
userQuickActionsVisible = false
|
||||||
showNotifications = false
|
showNotifications = false
|
||||||
showRequests = false
|
showRequests = false
|
||||||
detailsItem = null
|
detailsItem = null
|
||||||
@@ -2438,9 +2446,6 @@ private fun HomeScreen(
|
|||||||
)
|
)
|
||||||
LaunchedEffect(selectedDestination) {
|
LaunchedEffect(selectedDestination) {
|
||||||
focusedHomeRowId = null
|
focusedHomeRowId = null
|
||||||
if (selectedDestination == BrowseDestination.FAVORITES) {
|
|
||||||
recentSearches = repo.getRecentSearches()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
LaunchedEffect(selectedDestination, settings.forYouMinutes) {
|
LaunchedEffect(selectedDestination, settings.forYouMinutes) {
|
||||||
if (
|
if (
|
||||||
@@ -2549,6 +2554,9 @@ private fun HomeScreen(
|
|||||||
BrowseDestination.PROFILES -> {
|
BrowseDestination.PROFILES -> {
|
||||||
railFocusDestination = BrowseDestination.PROFILES
|
railFocusDestination = BrowseDestination.PROFILES
|
||||||
userSwitcherVisible = true
|
userSwitcherVisible = true
|
||||||
|
// The picker and the shortcut menu are two answers to one control; only
|
||||||
|
// one of them may ever be on screen.
|
||||||
|
userQuickActionsVisible = false
|
||||||
navigationExpanded = false
|
navigationExpanded = false
|
||||||
// The rail stays live beside Settings, so a destination
|
// The rail stays live beside Settings, so a destination
|
||||||
// chosen from there has to close it or it would sit over
|
// chosen from there has to close it or it would sit over
|
||||||
@@ -2606,6 +2614,21 @@ private fun HomeScreen(
|
|||||||
activeProfileInitials = settings.profileInitials,
|
activeProfileInitials = settings.profileInitials,
|
||||||
calendarEnabled = tvCalendarEnabled,
|
calendarEnabled = tvCalendarEnabled,
|
||||||
genresEnabled = genreBrowserEnabled,
|
genresEnabled = genreBrowserEnabled,
|
||||||
|
// A hold opens the shortcut menu; the ordinary press still opens the user
|
||||||
|
// picker, so nothing a viewer already knows how to do has moved.
|
||||||
|
onUserLongPressed = {
|
||||||
|
homeViewModel.trackJourney(
|
||||||
|
category = "navigation", action = "open", screen = journeyScreen,
|
||||||
|
feature = "user_switcher_shortcuts", source = journeyScreen,
|
||||||
|
target = "user_shortcuts",
|
||||||
|
)
|
||||||
|
userSwitcherVisible = false
|
||||||
|
navigationExpanded = false
|
||||||
|
// So closing the menu hands focus back to the item that was held, rather
|
||||||
|
// than to whichever destination the rail was last aimed at.
|
||||||
|
railFocusDestination = BrowseDestination.PROFILES
|
||||||
|
userQuickActionsVisible = true
|
||||||
|
},
|
||||||
)
|
)
|
||||||
androidx.compose.foundation.layout.BoxWithConstraints(
|
androidx.compose.foundation.layout.BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -2649,8 +2672,6 @@ private fun HomeScreen(
|
|||||||
discoveryItems = discovery,
|
discoveryItems = discovery,
|
||||||
returnFocusItemId = returnItemId.takeIf { returnRowId == SEARCH_ROW_ID },
|
returnFocusItemId = returnItemId.takeIf { returnRowId == SEARCH_ROW_ID },
|
||||||
returnFocusRequester = cardReturnFocusRequester,
|
returnFocusRequester = cardReturnFocusRequester,
|
||||||
initialQuery = initialSearchQuery,
|
|
||||||
onInitialQueryConsumed = { initialSearchQuery = null },
|
|
||||||
onSearchStarted = {
|
onSearchStarted = {
|
||||||
homeViewModel.trackJourney(
|
homeViewModel.trackJourney(
|
||||||
category = "search", action = "start", screen = "search",
|
category = "search", action = "start", screen = "search",
|
||||||
@@ -2823,6 +2844,13 @@ private fun HomeScreen(
|
|||||||
val firstPopulatedRowId = remember(rows) {
|
val firstPopulatedRowId = remember(rows) {
|
||||||
rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id
|
rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id
|
||||||
}
|
}
|
||||||
|
// My Shows is a band of its own above the shelves, so on this page it is the
|
||||||
|
// first stop below the hero and the thing Up out of the topmost shelf has to
|
||||||
|
// return to. Both moves used to jump over it: the hero pointed Down straight
|
||||||
|
// at the first shelf, and Up out of that shelf found no shelf above it and
|
||||||
|
// went to the hero.
|
||||||
|
val myShowsStripFocusable =
|
||||||
|
selectedDestination == BrowseDestination.SHOWS && myShows.isNotEmpty()
|
||||||
// Must agree exactly with the items placed above the rows in the LazyColumn
|
// Must agree exactly with the items placed above the rows in the LazyColumn
|
||||||
// below, because it is what turns a row index into a scroll target.
|
// below, because it is what turns a row index into a scroll target.
|
||||||
// Counting one that is not placed scrolls a row short of the destination,
|
// Counting one that is not placed scrolls a row short of the destination,
|
||||||
@@ -2830,10 +2858,7 @@ private fun HomeScreen(
|
|||||||
// the move with nothing to complete it.
|
// the move with nothing to complete it.
|
||||||
val leadingItemCount =
|
val leadingItemCount =
|
||||||
(if (selectedDestination == BrowseDestination.SHOWS) 1 else 0) +
|
(if (selectedDestination == BrowseDestination.SHOWS) 1 else 0) +
|
||||||
if (
|
(if (selectedDestination == BrowseDestination.FOR_YOU) 1 else 0)
|
||||||
selectedDestination == BrowseDestination.FAVORITES &&
|
|
||||||
recentSearches.isNotEmpty()
|
|
||||||
) 1 else if (selectedDestination == BrowseDestination.FOR_YOU) 1 else 0
|
|
||||||
LaunchedEffect(rowListFocusRestoreRequest, selectedDestination) {
|
LaunchedEffect(rowListFocusRestoreRequest, selectedDestination) {
|
||||||
if (rowListFocusRestoreRequest == 0) return@LaunchedEffect
|
if (rowListFocusRestoreRequest == 0) return@LaunchedEffect
|
||||||
val rowId = returnRowId ?: run {
|
val rowId = returnRowId ?: run {
|
||||||
@@ -2860,6 +2885,55 @@ private fun HomeScreen(
|
|||||||
}
|
}
|
||||||
rowListFocusRestoreRequest = 0
|
rowListFocusRestoreRequest = 0
|
||||||
}
|
}
|
||||||
|
// Leaving My Shows vertically. Neither direction can be a plain
|
||||||
|
// focusProperties target: the shelf below is a lazy child composed by the
|
||||||
|
// scroll rather than something already there to receive the press, and the
|
||||||
|
// hero is not composed at all while a shelf holds focus.
|
||||||
|
val enterFirstPopulatedRow: () -> Boolean = enter@{
|
||||||
|
if (rowFocusMoving) return@enter true
|
||||||
|
val destinationRowIndex = rows.indexOfFirst { it.items.isNotEmpty() }
|
||||||
|
if (destinationRowIndex < 0) return@enter false
|
||||||
|
val destinationRow = rows[destinationRowIndex]
|
||||||
|
val destinationItemIndex = rowEntryItemIndex(
|
||||||
|
sourceIndex = 0,
|
||||||
|
destinationItemCount = destinationRow.items.size,
|
||||||
|
rememberedDestinationIndex = rowFocusPositions[destinationRow.id],
|
||||||
|
)
|
||||||
|
rowFocusPositions[destinationRow.id] = destinationItemIndex
|
||||||
|
rowFocusRequestId += 1
|
||||||
|
val request = RowFocusRequest(
|
||||||
|
rowId = destinationRow.id,
|
||||||
|
itemIndex = destinationItemIndex,
|
||||||
|
requestId = rowFocusRequestId,
|
||||||
|
)
|
||||||
|
rowFocusMoving = true
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
verticalState.animateScrollToItem(
|
||||||
|
leadingItemCount + destinationRowIndex,
|
||||||
|
)
|
||||||
|
} catch (cancelled: kotlinx.coroutines.CancellationException) {
|
||||||
|
// Same bargain the shelves' own moves strike: ask for the card
|
||||||
|
// anyway, and let the request expire if nothing consumes it.
|
||||||
|
pendingRowFocus = request
|
||||||
|
throw cancelled
|
||||||
|
}
|
||||||
|
pendingRowFocus = request
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
val returnToMyShows: () -> Boolean = ret@{
|
||||||
|
if (!myShowsStripFocusable) return@ret false
|
||||||
|
// The strip sits above the shelves with the hero, so coming back up to
|
||||||
|
// it brings the hero back with it.
|
||||||
|
rowFocusedBelowHero = false
|
||||||
|
scope.launch {
|
||||||
|
verticalState.animateScrollToItem(0)
|
||||||
|
kotlinx.coroutines.delay(16L)
|
||||||
|
runCatching { myShowsEntryFocusRequester.requestFocus() }
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
if (showHomeHero) {
|
if (showHomeHero) {
|
||||||
HomeMovieHero(
|
HomeMovieHero(
|
||||||
@@ -2870,8 +2944,13 @@ private fun HomeScreen(
|
|||||||
returnFocusRequester = cardReturnFocusRequester,
|
returnFocusRequester = cardReturnFocusRequester,
|
||||||
// Only when there is a card down there to attach it to: a
|
// Only when there is a card down there to attach it to: a
|
||||||
// requester naming nothing throws the moment Down is pressed.
|
// requester naming nothing throws the moment Down is pressed.
|
||||||
downFocusRequester = heroRowEntryFocusRequester.takeIf {
|
// My Shows first where there is one, then the first shelf with
|
||||||
firstPopulatedRowId != null
|
// cards in it. Only ever a requester with a card behind it: one
|
||||||
|
// naming nothing throws the moment Down is pressed.
|
||||||
|
downFocusRequester = if (myShowsStripFocusable) {
|
||||||
|
myShowsEntryFocusRequester
|
||||||
|
} else {
|
||||||
|
heroRowEntryFocusRequester.takeIf { firstPopulatedRowId != null }
|
||||||
},
|
},
|
||||||
onItemFocused = { item ->
|
onItemFocused = { item ->
|
||||||
navigationExpanded = false
|
navigationExpanded = false
|
||||||
@@ -2949,10 +3028,40 @@ private fun HomeScreen(
|
|||||||
availableWidth = contentWidth,
|
availableWidth = contentWidth,
|
||||||
density = settings.homeCardDensity,
|
density = settings.homeCardDensity,
|
||||||
navigationFocusRequester = navigationFocusRequester,
|
navigationFocusRequester = navigationFocusRequester,
|
||||||
// My Shows is the first reachable content on this page.
|
// My Shows is the first reachable content on this page —
|
||||||
contentFocusRequester = contentFocusRequester,
|
// unless the hero is up, which owns the rail's entry
|
||||||
|
// target the way it does on every other page. Keyed on
|
||||||
|
// the hero actually being drawn rather than on there
|
||||||
|
// being one: a requester attached to two live nodes is
|
||||||
|
// ambiguous, and one attached to a hero that is not
|
||||||
|
// composed would leave the rail with nowhere to hand
|
||||||
|
// focus to.
|
||||||
|
contentFocusRequester = contentFocusRequester.takeIf {
|
||||||
|
!showHomeHero
|
||||||
|
},
|
||||||
|
entryFocusRequester = myShowsEntryFocusRequester,
|
||||||
returnFocusItemId = myShowReturnItemId,
|
returnFocusItemId = myShowReturnItemId,
|
||||||
returnFocusRequester = myShowReturnFocusRequester,
|
returnFocusRequester = myShowReturnFocusRequester,
|
||||||
|
onMoveVertical = { direction ->
|
||||||
|
when (direction) {
|
||||||
|
RowFocusDirection.DOWN -> enterFirstPopulatedRow()
|
||||||
|
RowFocusDirection.UP -> if (hasContextualHero) {
|
||||||
|
rowFocusedBelowHero = false
|
||||||
|
scope.launch {
|
||||||
|
verticalState.animateScrollToItem(0)
|
||||||
|
// Let the hero compose and attach its
|
||||||
|
// entry target before focus is handed up.
|
||||||
|
kotlinx.coroutines.delay(16L)
|
||||||
|
runCatching {
|
||||||
|
contentFocusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onShowSelected = {
|
onShowSelected = {
|
||||||
homeViewModel.trackJourney(
|
homeViewModel.trackJourney(
|
||||||
category = "content", action = "open", screen = "shows",
|
category = "content", action = "open", screen = "shows",
|
||||||
@@ -2966,26 +3075,6 @@ private fun HomeScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
selectedDestination == BrowseDestination.FAVORITES &&
|
|
||||||
recentSearches.isNotEmpty()
|
|
||||||
) {
|
|
||||||
item(key = "recent-searches", contentType = "recent-searches") {
|
|
||||||
RecentSearchesRow(
|
|
||||||
queries = recentSearches,
|
|
||||||
navigationFocusRequester = navigationFocusRequester,
|
|
||||||
onContentFocused = { navigationExpanded = false },
|
|
||||||
onQuerySelected = { query ->
|
|
||||||
homeViewModel.trackJourney(
|
|
||||||
category = "search", action = "select", screen = "favourites",
|
|
||||||
feature = "recent_searches", source = "recent_searches", target = "search",
|
|
||||||
)
|
|
||||||
initialSearchQuery = query
|
|
||||||
selectedDestination = BrowseDestination.SEARCH
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (selectedDestination == BrowseDestination.FOR_YOU) {
|
if (selectedDestination == BrowseDestination.FOR_YOU) {
|
||||||
item(key = "for-you-time", contentType = "for-you-time") {
|
item(key = "for-you-time", contentType = "for-you-time") {
|
||||||
ForYouTimeBudget(
|
ForYouTimeBudget(
|
||||||
@@ -3055,6 +3144,11 @@ private fun HomeScreen(
|
|||||||
// not composed while a row holds focus, so there is
|
// not composed while a row holds focus, so there is
|
||||||
// nothing above for Compose's own focus search to
|
// nothing above for Compose's own focus search to
|
||||||
// find and the press would otherwise be dead.
|
// find and the press would otherwise be dead.
|
||||||
|
// My Shows is the band directly above the shelves,
|
||||||
|
// so it answers this press before the hero does.
|
||||||
|
if (direction == RowFocusDirection.UP && returnToMyShows()) {
|
||||||
|
return@moveVertical true
|
||||||
|
}
|
||||||
if (direction == RowFocusDirection.UP && hasContextualHero) {
|
if (direction == RowFocusDirection.UP && hasContextualHero) {
|
||||||
rowFocusedBelowHero = false
|
rowFocusedBelowHero = false
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -3290,6 +3384,83 @@ private fun HomeScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (userQuickActionsVisible) {
|
||||||
|
val closeUserQuickActions: () -> Unit = {
|
||||||
|
userQuickActionsVisible = false
|
||||||
|
scope.launch {
|
||||||
|
kotlinx.coroutines.delay(16L)
|
||||||
|
runCatching { navigationFocusRequester.requestFocus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BackHandler(onBack = closeUserQuickActions)
|
||||||
|
UserQuickActionsOverlay(
|
||||||
|
username = settings.username.orEmpty(),
|
||||||
|
alertCount = displayedNotifications.size,
|
||||||
|
busy = notificationsMutationBusy,
|
||||||
|
// Clearing from here never takes the viewer anywhere: the menu closes, the
|
||||||
|
// badge on the rail behind it empties, and a toast says what happened. The
|
||||||
|
// list is emptied optimistically for the reason the alerts page already is —
|
||||||
|
// a badge that lingered while the request was in flight is one somebody
|
||||||
|
// clears twice — and put back untouched if the gateway refuses.
|
||||||
|
onClearNotifications = {
|
||||||
|
if (!notificationsMutationBusy) {
|
||||||
|
notificationsMutationBusy = true
|
||||||
|
val previous = notificationState
|
||||||
|
val offered = displayedNotifications.size
|
||||||
|
val hadLocalUpdate = settings.updateAlertVersion != null
|
||||||
|
notificationState = notificationState.copy(notifications = emptyList())
|
||||||
|
closeUserQuickActions()
|
||||||
|
scope.launch {
|
||||||
|
// The update notice is this television's own — there is no server
|
||||||
|
// row behind it, so it is dismissed locally and counted here.
|
||||||
|
if (hadLocalUpdate) ServiceLocator.settings.dismissUpdateAlert()
|
||||||
|
runCatching { repo.clearNotifications() }
|
||||||
|
.onSuccess { cleared ->
|
||||||
|
val total = cleared + if (hadLocalUpdate) 1 else 0
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
clearedNotificationsMessage(total),
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
homeViewModel.trackJourney(
|
||||||
|
category = "notifications", action = "dismiss",
|
||||||
|
screen = journeyScreen,
|
||||||
|
feature = "user_switcher_clear_notifications",
|
||||||
|
source = "user_switcher", target = "notifications",
|
||||||
|
// The one free-text field, and the count is the only
|
||||||
|
// thing that separates one use of this shortcut from
|
||||||
|
// the next.
|
||||||
|
itemName = "$total cleared",
|
||||||
|
outcome = "success",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
// Put the list back and say so here rather than writing
|
||||||
|
// notificationsError: this menu never opens the page, and
|
||||||
|
// an error banner waiting on a page nobody has opened is
|
||||||
|
// one they meet later with no idea what it is about.
|
||||||
|
notificationState = previous
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
"Couldn’t clear notifications",
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
homeViewModel.trackJourney(
|
||||||
|
category = "notifications", action = "dismiss",
|
||||||
|
screen = journeyScreen,
|
||||||
|
feature = "user_switcher_clear_notifications",
|
||||||
|
source = "user_switcher", target = "notifications",
|
||||||
|
itemName = "$offered offered",
|
||||||
|
outcome = "failure",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
notificationsMutationBusy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = closeUserQuickActions,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showSettings) {
|
if (showSettings) {
|
||||||
val closeSettings: () -> Unit = {
|
val closeSettings: () -> Unit = {
|
||||||
showSettings = false
|
showSettings = false
|
||||||
@@ -3648,6 +3819,9 @@ private fun HomeScreen(
|
|||||||
BrowseDestination.PROFILES -> {
|
BrowseDestination.PROFILES -> {
|
||||||
railFocusDestination = BrowseDestination.PROFILES
|
railFocusDestination = BrowseDestination.PROFILES
|
||||||
userSwitcherVisible = true
|
userSwitcherVisible = true
|
||||||
|
// The picker and the shortcut menu are two answers to one control; only
|
||||||
|
// one of them may ever be on screen.
|
||||||
|
userQuickActionsVisible = false
|
||||||
navigationExpanded = false
|
navigationExpanded = false
|
||||||
showSettings = false
|
showSettings = false
|
||||||
restoreRailAfterSettings = false
|
restoreRailAfterSettings = false
|
||||||
@@ -4068,76 +4242,6 @@ private fun PlaybackLaunchOverlay(item: BaseItem, modifier: Modifier = Modifier)
|
|||||||
private const val SEARCH_ROW_ID = "search-results"
|
private const val SEARCH_ROW_ID = "search-results"
|
||||||
private const val GENRE_BROWSER_ROW_ID = "genre-browser-results"
|
private const val GENRE_BROWSER_ROW_ID = "genre-browser-results"
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RecentSearchesRow(
|
|
||||||
queries: List<String>,
|
|
||||||
navigationFocusRequester: FocusRequester,
|
|
||||||
onContentFocused: () -> Unit,
|
|
||||||
onQuerySelected: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
HomeRowHeaderIcon(MembyIcon.Search.mark)
|
|
||||||
Spacer(Modifier.width(HomeRowHeaderIconGap))
|
|
||||||
Text(
|
|
||||||
"Recent searches",
|
|
||||||
color = MembyOnSurface,
|
|
||||||
fontSize = 20.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(10.dp))
|
|
||||||
Text(
|
|
||||||
"LAST 30 DAYS",
|
|
||||||
color = MembyQuietText,
|
|
||||||
fontSize = 10.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
LazyRow(
|
|
||||||
// Vertical padding so a focus-scaled chip has room to grow, like every other row.
|
|
||||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
|
||||||
horizontal = 36.dp,
|
|
||||||
vertical = 9.dp,
|
|
||||||
),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
|
||||||
) {
|
|
||||||
items(queries, key = { it.lowercase() }) { query ->
|
|
||||||
val first = query == queries.first()
|
|
||||||
FocusScaleContainer(
|
|
||||||
onFocused = onContentFocused,
|
|
||||||
onClick = { onQuerySelected(query) },
|
|
||||||
contentDescription = "Search again for $query",
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(MembyChipCorner))
|
|
||||||
.then(
|
|
||||||
if (first) {
|
|
||||||
Modifier.focusProperties { left = navigationFocusRequester }
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
},
|
|
||||||
),
|
|
||||||
) { focused ->
|
|
||||||
Text(
|
|
||||||
query,
|
|
||||||
color = if (focused) MembyAccentInk else MembyOnSurface,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
modifier = Modifier
|
|
||||||
.background(
|
|
||||||
if (focused) MembyAccent else MembyControlSurface,
|
|
||||||
)
|
|
||||||
.padding(horizontal = 17.dp, vertical = 10.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HomeClock(
|
private fun HomeClock(
|
||||||
showGreeting: Boolean,
|
showGreeting: Boolean,
|
||||||
@@ -4651,11 +4755,21 @@ internal fun homeRowsFor(
|
|||||||
BrowseDestination.SETTINGS -> emptyList()
|
BrowseDestination.SETTINGS -> emptyList()
|
||||||
}
|
}
|
||||||
val deduplicated = deduplicateBrowseRows(destinationRows)
|
val deduplicated = deduplicateBrowseRows(destinationRows)
|
||||||
return if (destination == BrowseDestination.HOME && state.rows.isEmpty()) {
|
// The television page carries two fixed shelves it may have nothing for — a household
|
||||||
applyHomeRowPreferences(deduplicated, settings)
|
// part-way through no episodes, or with no series favourited — and a heading with an
|
||||||
|
// apology under it is a row the D-pad already refuses to enter, so it is a stop on the
|
||||||
|
// way to the shelves that do have something. A row still *loading* is kept: it has
|
||||||
|
// something arriving, and withdrawing it would move every shelf below it a moment later.
|
||||||
|
val populated = if (destination == BrowseDestination.SHOWS) {
|
||||||
|
deduplicated.filter { it.items.isNotEmpty() || it.loading }
|
||||||
} else {
|
} else {
|
||||||
deduplicated
|
deduplicated
|
||||||
}
|
}
|
||||||
|
return if (destination == BrowseDestination.HOME && state.rows.isEmpty()) {
|
||||||
|
applyHomeRowPreferences(populated, settings)
|
||||||
|
} else {
|
||||||
|
populated
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ import androidx.compose.ui.focus.FocusRequester
|
|||||||
import androidx.compose.ui.focus.focusProperties
|
import androidx.compose.ui.focus.focusProperties
|
||||||
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.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -64,8 +69,15 @@ internal fun MyShowsStrip(
|
|||||||
density: String,
|
density: String,
|
||||||
navigationFocusRequester: FocusRequester,
|
navigationFocusRequester: FocusRequester,
|
||||||
contentFocusRequester: FocusRequester?,
|
contentFocusRequester: FocusRequester?,
|
||||||
|
// Where Down out of the hero and Up out of the shelf below land. A second requester
|
||||||
|
// rather than the entry one because while the hero is up, that one belongs to the hero.
|
||||||
|
entryFocusRequester: FocusRequester? = null,
|
||||||
returnFocusItemId: String? = null,
|
returnFocusItemId: String? = null,
|
||||||
returnFocusRequester: FocusRequester? = null,
|
returnFocusRequester: FocusRequester? = null,
|
||||||
|
// Leaving the strip vertically. Handled by the caller for the reason the shelves' own
|
||||||
|
// moves are: the destination is a lazy child that has to be composed before its focus
|
||||||
|
// node can be asked for. Returning false lets the press fall through to Compose.
|
||||||
|
onMoveVertical: (RowFocusDirection) -> Boolean = { false },
|
||||||
onShowSelected: (MyShow) -> Unit,
|
onShowSelected: (MyShow) -> Unit,
|
||||||
onContentFocused: () -> Unit,
|
onContentFocused: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -155,6 +167,13 @@ internal fun MyShowsStrip(
|
|||||||
Modifier
|
Modifier
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.then(
|
||||||
|
if (show.itemId == shows.first().itemId && entryFocusRequester != null) {
|
||||||
|
Modifier.focusRequester(entryFocusRequester)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
.then(
|
.then(
|
||||||
if (show.itemId == returnFocusItemId && returnFocusRequester != null) {
|
if (show.itemId == returnFocusItemId && returnFocusRequester != null) {
|
||||||
Modifier.focusRequester(returnFocusRequester)
|
Modifier.focusRequester(returnFocusRequester)
|
||||||
@@ -163,6 +182,17 @@ internal fun MyShowsStrip(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.focusProperties { left = navigationFocusRequester }
|
.focusProperties { left = navigationFocusRequester }
|
||||||
|
.onPreviewKeyEvent { event ->
|
||||||
|
if (event.type != KeyEventType.KeyDown) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
when (event.key) {
|
||||||
|
Key.DirectionUp -> onMoveVertical(RowFocusDirection.UP)
|
||||||
|
Key.DirectionDown -> onMoveVertical(RowFocusDirection.DOWN)
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.onFocusChanged { if (it.hasFocus) onContentFocused() },
|
.onFocusChanged { if (it.hasFocus) onContentFocused() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.ponzischeme89.memby.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A short press and a hold, told apart on a remote that has neither.
|
||||||
|
*
|
||||||
|
* A television reports the confirm key as an ordinary key event, so a long press has to be
|
||||||
|
* measured: the DOWN is swallowed, [QuickActionsHoldDurationMillis] is waited out, and the
|
||||||
|
* UP decides which of the two happened. This is one modifier rather than a copy per surface
|
||||||
|
* because two of them are already live — a media card and the rail's user item — and the
|
||||||
|
* hold duration is the one thing that must not differ between them: a shortcut that opens
|
||||||
|
* after a different length of press on one control than another reads as an unreliable
|
||||||
|
* remote rather than as two features.
|
||||||
|
*
|
||||||
|
* Three details are load-bearing:
|
||||||
|
*
|
||||||
|
* - **Every repeat of the held key is consumed**, not acted on. Some remotes report a held
|
||||||
|
* key as a stream of DOWNs, and acting on those would open the menu again underneath the
|
||||||
|
* one already up.
|
||||||
|
* - **The short press fires on UP**, never on DOWN, because until the key is released
|
||||||
|
* there is no way to know which press this was.
|
||||||
|
* - **Losing focus cancels the hold.** Focus can move out from under a held key — the
|
||||||
|
* menu that just opened takes it — and a timer that survived that would fire the long
|
||||||
|
* press against a control the viewer had already left.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.remoteLongPress(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: (() -> Unit)?,
|
||||||
|
): Modifier = composed {
|
||||||
|
if (onLongClick == null) return@composed clickable(onClick = onClick)
|
||||||
|
val pressScope = rememberCoroutineScope()
|
||||||
|
var holdJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
var handled by remember { mutableStateOf(false) }
|
||||||
|
val currentOnLongClick by rememberUpdatedState(onLongClick)
|
||||||
|
val currentOnClick by rememberUpdatedState(onClick)
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged {
|
||||||
|
if (!it.isFocused) {
|
||||||
|
holdJob?.cancel()
|
||||||
|
holdJob = null
|
||||||
|
handled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onPreviewKeyEvent { event ->
|
||||||
|
val native = event.nativeKeyEvent
|
||||||
|
val activationKey = native.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER ||
|
||||||
|
native.keyCode == android.view.KeyEvent.KEYCODE_ENTER ||
|
||||||
|
native.keyCode == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER
|
||||||
|
when {
|
||||||
|
activationKey &&
|
||||||
|
native.action == android.view.KeyEvent.ACTION_DOWN &&
|
||||||
|
native.repeatCount == 0 &&
|
||||||
|
holdJob == null &&
|
||||||
|
!handled -> {
|
||||||
|
holdJob = pressScope.launch {
|
||||||
|
delay(QuickActionsHoldDurationMillis)
|
||||||
|
handled = true
|
||||||
|
holdJob = null
|
||||||
|
currentOnLongClick?.invoke()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
activationKey && native.action == android.view.KeyEvent.ACTION_DOWN -> true
|
||||||
|
activationKey && native.action == android.view.KeyEvent.ACTION_UP -> {
|
||||||
|
val wasLongPress = handled
|
||||||
|
holdJob?.cancel()
|
||||||
|
holdJob = null
|
||||||
|
handled = false
|
||||||
|
if (!wasLongPress) currentOnClick()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package com.ponzischeme89.memby.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import androidx.tv.material3.Icon
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyDisabledText
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||||
|
import com.ponzischeme89.memby.ui.theme.mark
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shortcut menu behind a hold on the rail's user item.
|
||||||
|
*
|
||||||
|
* It exists so that clearing a stack of notifications is one hold and one press, rather than
|
||||||
|
* a trip into the notifications page to empty a list somebody has already read from the
|
||||||
|
* badge. Everything about *what* the menu offers is decided here, pure, because the one rule
|
||||||
|
* worth pinning is the empty case: a household with nothing waiting must be told so rather
|
||||||
|
* than offered a button that makes a request the gateway can only answer "nothing".
|
||||||
|
*/
|
||||||
|
internal enum class UserQuickActionKind { CLEAR_NOTIFICATIONS, CANCEL }
|
||||||
|
|
||||||
|
internal data class UserQuickActionEntry(
|
||||||
|
val kind: UserQuickActionKind,
|
||||||
|
val label: String,
|
||||||
|
/**
|
||||||
|
* False draws the row quietly and refuses the press. Deliberately shown rather than
|
||||||
|
* removed: a menu whose contents change shape depending on whether there happens to be
|
||||||
|
* news is one a viewer cannot learn, and "Nothing to clear" is the answer they opened it
|
||||||
|
* for anyway.
|
||||||
|
*/
|
||||||
|
val enabled: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The menu for a viewer with [alertCount] notifications waiting.
|
||||||
|
*
|
||||||
|
* The count is named on the row because it is the whole of what the press is about, and
|
||||||
|
* because it is the number the viewer is about to see disappear from the badge — a
|
||||||
|
* confirmation that agreed with nothing on screen would read as having cleared something
|
||||||
|
* else. [AlertBadgeMax] is not applied: the badge caps at "9+" to fit its pill, where this
|
||||||
|
* row has the width to be exact and a viewer deciding whether to clear wants the real
|
||||||
|
* figure.
|
||||||
|
*/
|
||||||
|
internal fun userQuickActions(alertCount: Int, busy: Boolean = false): List<UserQuickActionEntry> {
|
||||||
|
val count = alertCount.coerceAtLeast(0)
|
||||||
|
val label = when {
|
||||||
|
count <= 0 -> "No notifications to clear"
|
||||||
|
count == 1 -> "Clear 1 notification"
|
||||||
|
else -> "Clear $count notifications"
|
||||||
|
}
|
||||||
|
return listOf(
|
||||||
|
UserQuickActionEntry(
|
||||||
|
kind = UserQuickActionKind.CLEAR_NOTIFICATIONS,
|
||||||
|
label = label,
|
||||||
|
enabled = count > 0 && !busy,
|
||||||
|
),
|
||||||
|
UserQuickActionEntry(UserQuickActionKind.CANCEL, "Cancel", enabled = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the television says once the gateway has answered.
|
||||||
|
*
|
||||||
|
* The figure is the server's rather than the count the menu offered: another set in the
|
||||||
|
* house may have emptied the list a moment earlier, and a confirmation claiming seven when
|
||||||
|
* the gateway took none would be the shortcut reporting its intent instead of its result.
|
||||||
|
*/
|
||||||
|
internal fun clearedNotificationsMessage(cleared: Int): String = when {
|
||||||
|
cleared <= 0 -> "Nothing left to clear"
|
||||||
|
cleared == 1 -> "1 notification cleared"
|
||||||
|
else -> "$cleared notifications cleared"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The menu itself: a small panel beside the rail, not a full-screen dialog.
|
||||||
|
*
|
||||||
|
* It is the user switcher's shape deliberately — same inset, same width, same corner — so a
|
||||||
|
* hold and a press on the same rail item read as two doors on one control rather than as two
|
||||||
|
* different features. It is short enough that D-pad travel is one press, which is the whole
|
||||||
|
* point of a shortcut.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun UserQuickActionsOverlay(
|
||||||
|
username: String,
|
||||||
|
alertCount: Int,
|
||||||
|
busy: Boolean,
|
||||||
|
onClearNotifications: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val actions = userQuickActions(alertCount, busy)
|
||||||
|
val focusRequesters = remember(actions.size) { List(actions.size) { FocusRequester() } }
|
||||||
|
var focusedIndex by remember { mutableStateOf(0) }
|
||||||
|
// The menu appears while the confirm key is still physically held — that hold is what
|
||||||
|
// opened it. Until it is released, swallow every activation so the press that raised the
|
||||||
|
// menu cannot also run the row it landed on.
|
||||||
|
var openingPressReleased by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(16)
|
||||||
|
runCatching { focusRequesters.first().requestFocus() }
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.zIndex(21f)
|
||||||
|
.background(Color.Black.copy(alpha = 0.56f)),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
.padding(start = 52.dp)
|
||||||
|
.width(292.dp)
|
||||||
|
.shadow(12.dp, RoundedCornerShape(MembyPanelCorner))
|
||||||
|
.clip(RoundedCornerShape(MembyPanelCorner))
|
||||||
|
.background(MembySurface)
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.07f), RoundedCornerShape(MembyPanelCorner))
|
||||||
|
.onPreviewKeyEvent { event ->
|
||||||
|
val native = event.nativeKeyEvent
|
||||||
|
val activationKey =
|
||||||
|
native.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER ||
|
||||||
|
native.keyCode == android.view.KeyEvent.KEYCODE_ENTER ||
|
||||||
|
native.keyCode == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER
|
||||||
|
if (activationKey && !openingPressReleased) {
|
||||||
|
if (native.action == android.view.KeyEvent.ACTION_UP) {
|
||||||
|
openingPressReleased = true
|
||||||
|
}
|
||||||
|
return@onPreviewKeyEvent true
|
||||||
|
}
|
||||||
|
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||||
|
when (event.key) {
|
||||||
|
Key.DirectionUp, Key.DirectionDown -> {
|
||||||
|
focusedIndex = quickActionNextIndex(
|
||||||
|
currentIndex = focusedIndex,
|
||||||
|
actionCount = actions.size,
|
||||||
|
direction = if (event.key == Key.DirectionUp) {
|
||||||
|
QuickActionDirection.UP
|
||||||
|
} else {
|
||||||
|
QuickActionDirection.DOWN
|
||||||
|
},
|
||||||
|
)
|
||||||
|
runCatching { focusRequesters[focusedIndex].requestFocus() }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Key.DirectionLeft, Key.Back -> {
|
||||||
|
onDismiss()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Keep focus inside the menu rather than letting spatial search find
|
||||||
|
// a dimmed card behind it.
|
||||||
|
Key.DirectionRight -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"User shortcuts",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 5.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
username.takeIf(String::isNotBlank) ?: "Signed in",
|
||||||
|
color = MembyQuietText,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
actions.forEachIndexed { index, action ->
|
||||||
|
if (index > 0) Spacer(Modifier.height(2.dp))
|
||||||
|
UserQuickActionItem(
|
||||||
|
label = action.label,
|
||||||
|
icon = when (action.kind) {
|
||||||
|
UserQuickActionKind.CLEAR_NOTIFICATIONS -> MembyIcon.Notification.mark
|
||||||
|
UserQuickActionKind.CANCEL -> MembyIcon.ChevronLeft.mark
|
||||||
|
},
|
||||||
|
enabled = action.enabled,
|
||||||
|
modifier = Modifier
|
||||||
|
.focusRequester(focusRequesters[index])
|
||||||
|
.onFocusChanged { if (it.isFocused) focusedIndex = index },
|
||||||
|
onClick = {
|
||||||
|
when (action.kind) {
|
||||||
|
UserQuickActionKind.CLEAR_NOTIFICATIONS ->
|
||||||
|
if (action.enabled) onClearNotifications()
|
||||||
|
UserQuickActionKind.CANCEL -> onDismiss()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UserQuickActionItem(
|
||||||
|
label: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
enabled: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
// A row nothing can be done with is still reachable and still says what it is; it simply
|
||||||
|
// never lights up, so the focus ring is the one thing on the panel that can promise a
|
||||||
|
// press will do something.
|
||||||
|
val foreground = when {
|
||||||
|
!enabled -> MembyDisabledText
|
||||||
|
focused -> Color.White
|
||||||
|
else -> MembyMutedText
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(42.dp)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
.clip(RoundedCornerShape(MembyChipCorner))
|
||||||
|
.background(
|
||||||
|
if (focused && enabled) Color.White.copy(alpha = 0.11f) else Color.Transparent,
|
||||||
|
)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = if (enabled) label else "$label, unavailable"
|
||||||
|
}
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (enabled && focused) Color.White else foreground,
|
||||||
|
modifier = Modifier.size(17.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
color = foreground,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
@@ -76,6 +77,7 @@ import com.ponzischeme89.memby.ui.theme.MembyAccentDeep
|
|||||||
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
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.MembyIcon
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||||
@@ -151,11 +153,25 @@ fun GenreBrowseScreen(
|
|||||||
// The genre the remote is on, kept apart from the view model's selection so the rail's
|
// The genre the remote is on, kept apart from the view model's selection so the rail's
|
||||||
// own marker and the entry FocusRequester are never waiting on a request. The pane
|
// own marker and the entry FocusRequester are never waiting on a request. The pane
|
||||||
// follows the selection instead, because it labels the grid rather than the remote.
|
// follows the selection instead, because it labels the grid rather than the remote.
|
||||||
var activeCategoryId by remember(initialCategoryId) {
|
//
|
||||||
mutableStateOf(state.selectedCategoryId ?: initialCategoryId)
|
// It opens on the destination's own default rather than on whatever was left selected.
|
||||||
}
|
// The view model outlives this screen — it is scoped to the activity, so its pages
|
||||||
|
// survive a trip to Home and back, which is what makes returning to a genre free — but
|
||||||
|
// arriving at Genres is arriving at the top of a catalogue, not resuming a place in
|
||||||
|
// one, and a viewer who went Home a day ago has no reason to be put back in Crime.
|
||||||
|
var activeCategoryId by remember(initialCategoryId) { mutableStateOf(initialCategoryId) }
|
||||||
var contentHasFocus by remember { mutableStateOf(false) }
|
var contentHasFocus by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// One requester per genre, owned here rather than by the rail, because every way back
|
||||||
|
// into the rail has to name *which* genre it is going back to. Handing the rail a
|
||||||
|
// single "the active one" requester that moved between items as the selection changed
|
||||||
|
// was what put a returning viewer on the genre they had passed through rather than on
|
||||||
|
// the one they had chosen: a requester that changes which node it is attached to has,
|
||||||
|
// for a moment, two, and the one it answers with is not the one under the marker.
|
||||||
|
val railFocusRequesters = remember(itemType) {
|
||||||
|
genreCategoryTabs(itemType).associate { it.id to FocusRequester() }
|
||||||
|
}
|
||||||
|
|
||||||
// One grid state per genre, so returning to a genre returns to where it was left
|
// One grid state per genre, so returning to a genre returns to where it was left
|
||||||
// rather than to the top of it. Bounded by the catalogue, which is a fixed list.
|
// rather than to the top of it. Bounded by the catalogue, which is a fixed list.
|
||||||
val gridStates = remember(itemType) { mutableMapOf<String, LazyGridState>() }
|
val gridStates = remember(itemType) { mutableMapOf<String, LazyGridState>() }
|
||||||
@@ -174,24 +190,44 @@ fun GenreBrowseScreen(
|
|||||||
}
|
}
|
||||||
val gridState = gridStates.getOrPut(shownCategoryId ?: initialCategoryId) { LazyGridState() }
|
val gridState = gridStates.getOrPut(shownCategoryId ?: initialCategoryId) { LazyGridState() }
|
||||||
|
|
||||||
|
// Whether the rail has been travelled *on this visit*. The debounce below is about a
|
||||||
|
// held D-pad, so it must not delay the genre the screen opens on — and the view model
|
||||||
|
// remembering a previous visit's selection is exactly what would otherwise make the
|
||||||
|
// opening genre look like a change.
|
||||||
|
var railTravelled by remember(itemType) { mutableStateOf(false) }
|
||||||
LaunchedEffect(activeCategoryId) {
|
LaunchedEffect(activeCategoryId) {
|
||||||
// See [GENRE_SELECT_DEBOUNCE_MS]: travelling past a genre must not ask for it.
|
// See [GENRE_SELECT_DEBOUNCE_MS]: travelling past a genre must not ask for it.
|
||||||
// Only a *change* is a candidate for that — the genre the page opens on is the one
|
// Only a *change* is a candidate for that — the genre the page opens on is the one
|
||||||
// thing somebody is definitely waiting for, so it is asked for at once.
|
// thing somebody is definitely waiting for, so it is asked for at once.
|
||||||
if (state.selectedCategoryId != null) kotlinx.coroutines.delay(GENRE_SELECT_DEBOUNCE_MS)
|
if (railTravelled) kotlinx.coroutines.delay(GENRE_SELECT_DEBOUNCE_MS)
|
||||||
browseViewModel.selectCategory(activeCategoryId)
|
browseViewModel.selectCategory(activeCategoryId)
|
||||||
|
railTravelled = true
|
||||||
}
|
}
|
||||||
LaunchedEffect(favouriteStates) { browseViewModel.applyFavouriteStates(favouriteStates) }
|
LaunchedEffect(favouriteStates) { browseViewModel.applyFavouriteStates(favouriteStates) }
|
||||||
LaunchedEffect(playedStates) { browseViewModel.applyPlayedStates(playedStates) }
|
LaunchedEffect(playedStates) { browseViewModel.applyPlayedStates(playedStates) }
|
||||||
|
/**
|
||||||
|
* Puts the remote back on the genre the grid belongs to.
|
||||||
|
*
|
||||||
|
* Named rather than left to the shared entry requester: this is the press that has to
|
||||||
|
* remember where the viewer was, and the genre they chose is the only honest answer.
|
||||||
|
*/
|
||||||
|
fun focusRail(): Boolean =
|
||||||
|
railFocusRequesters[activeCategoryId]?.let { runCatching { it.requestFocus() }.isSuccess }
|
||||||
|
?: runCatching { contentFocusRequester.requestFocus() }.isSuccess
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
kotlinx.coroutines.delay(32L)
|
// The rail's items are one composition away, so the first attempt can legitimately
|
||||||
runCatching { contentFocusRequester.requestFocus() }
|
// find nothing attached — hence the retries rather than a single delayed request.
|
||||||
|
repeat(4) {
|
||||||
|
kotlinx.coroutines.delay(32L)
|
||||||
|
if (focusRail()) return@LaunchedEffect
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Back steps out of the grid before it steps off the page — one press per level, the
|
// Back steps out of the grid before it steps off the page — one press per level, the
|
||||||
// rule the search pane and the calendar already follow.
|
// rule the search pane and the calendar already follow.
|
||||||
BackHandler {
|
BackHandler {
|
||||||
if (contentHasFocus) {
|
if (contentHasFocus) {
|
||||||
runCatching { contentFocusRequester.requestFocus() }
|
focusRail()
|
||||||
} else {
|
} else {
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -229,6 +265,7 @@ fun GenreBrowseScreen(
|
|||||||
activeCategoryId = activeCategoryId,
|
activeCategoryId = activeCategoryId,
|
||||||
navigationFocusRequester = navigationFocusRequester,
|
navigationFocusRequester = navigationFocusRequester,
|
||||||
activeFocusRequester = contentFocusRequester,
|
activeFocusRequester = contentFocusRequester,
|
||||||
|
itemFocusRequesters = railFocusRequesters,
|
||||||
onCategoryFocused = { id ->
|
onCategoryFocused = { id ->
|
||||||
onContentFocused()
|
onContentFocused()
|
||||||
activeCategoryId = id
|
activeCategoryId = id
|
||||||
@@ -372,8 +409,12 @@ fun GenreBrowseScreen(
|
|||||||
.focusProperties {
|
.focusProperties {
|
||||||
// Left out of the first column is the way
|
// Left out of the first column is the way
|
||||||
// back to the genre this grid belongs to,
|
// back to the genre this grid belongs to,
|
||||||
// which is where that requester is attached.
|
// named directly so the press cannot land
|
||||||
if (index % columns == 0) left = contentFocusRequester
|
// on a genre merely travelled through.
|
||||||
|
if (index % columns == 0) {
|
||||||
|
left = railFocusRequesters[activeCategoryId]
|
||||||
|
?: contentFocusRequester
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -415,6 +456,11 @@ internal fun GenreRail(
|
|||||||
activeFocusRequester: FocusRequester,
|
activeFocusRequester: FocusRequester,
|
||||||
onCategoryFocused: (String) -> Unit,
|
onCategoryFocused: (String) -> Unit,
|
||||||
onEnterContent: () -> Boolean,
|
onEnterContent: () -> Boolean,
|
||||||
|
/**
|
||||||
|
* One requester per genre, owned by the screen: every way back into the rail names the
|
||||||
|
* genre it is going back to, and a requester shared between them cannot.
|
||||||
|
*/
|
||||||
|
itemFocusRequesters: Map<String, FocusRequester> = emptyMap(),
|
||||||
/**
|
/**
|
||||||
* Draws one genre as though the remote were on it.
|
* Draws one genre as though the remote were on it.
|
||||||
*
|
*
|
||||||
@@ -431,9 +477,14 @@ internal fun GenreRail(
|
|||||||
val index = categories.indexOfFirst { it.id == activeCategoryId }
|
val index = categories.indexOfFirst { it.id == activeCategoryId }
|
||||||
if (index > 0) runCatching { railState.scrollToItem(index) }
|
if (index > 0) runCatching { railState.scrollToItem(index) }
|
||||||
}
|
}
|
||||||
val itemFocusRequesters = remember(categories) {
|
// Falls back to a private set only for a caller that has none of its own, which today
|
||||||
|
// is the screenshot test: the rail must be renderable without the screen around it.
|
||||||
|
val ownedFocusRequesters = remember(categories) {
|
||||||
categories.associate { it.id to FocusRequester() }
|
categories.associate { it.id to FocusRequester() }
|
||||||
}
|
}
|
||||||
|
val requesterFor: (String) -> FocusRequester = { id ->
|
||||||
|
itemFocusRequesters[id] ?: ownedFocusRequesters.getValue(id)
|
||||||
|
}
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.width(GenreRailWidth)
|
.width(GenreRailWidth)
|
||||||
@@ -468,15 +519,18 @@ internal fun GenreRail(
|
|||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
letterSpacing = 1.6.sp,
|
letterSpacing = 1.6.sp,
|
||||||
modifier = Modifier.padding(start = 22.dp, top = 46.dp, bottom = 14.dp),
|
modifier = Modifier.padding(start = 22.dp, top = 46.dp, bottom = 18.dp),
|
||||||
)
|
)
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = railState,
|
state = railState,
|
||||||
contentPadding = PaddingValues(start = 12.dp, end = 14.dp, bottom = 48.dp),
|
// The top inset is not decoration: the list clips at its own edge, so the first
|
||||||
|
// genre sat directly under the heading and read as cut off by it — and the
|
||||||
|
// focused item grows, which took what little clearance there was.
|
||||||
|
contentPadding = PaddingValues(start = 12.dp, end = 14.dp, top = 6.dp, bottom = 48.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
rowItemsIndexed(categories, key = { _, category -> category.id }) { index, category ->
|
rowItemsIndexed(categories, key = { _, category -> category.id }) { index, category ->
|
||||||
val requester = itemFocusRequesters.getValue(category.id)
|
val requester = requesterFor(category.id)
|
||||||
GenreRailItem(
|
GenreRailItem(
|
||||||
category = category,
|
category = category,
|
||||||
active = category.id == activeCategoryId,
|
active = category.id == activeCategoryId,
|
||||||
@@ -503,12 +557,12 @@ internal fun GenreRail(
|
|||||||
up = if (index == 0) {
|
up = if (index == 0) {
|
||||||
FocusRequester.Cancel
|
FocusRequester.Cancel
|
||||||
} else {
|
} else {
|
||||||
itemFocusRequesters.getValue(categories[index - 1].id)
|
requesterFor(categories[index - 1].id)
|
||||||
}
|
}
|
||||||
down = if (index == categories.lastIndex) {
|
down = if (index == categories.lastIndex) {
|
||||||
FocusRequester.Cancel
|
FocusRequester.Cancel
|
||||||
} else {
|
} else {
|
||||||
itemFocusRequesters.getValue(categories[index + 1].id)
|
requesterFor(categories[index + 1].id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -521,10 +575,11 @@ internal fun GenreRail(
|
|||||||
* One genre.
|
* One genre.
|
||||||
*
|
*
|
||||||
* Focus and the genre in force are marked separately, the stance the player's subtitle
|
* Focus and the genre in force are marked separately, the stance the player's subtitle
|
||||||
* menu takes: the option under the thumb is the only fill on the rail (accent), and the
|
* menu takes: the option under the thumb is the only solid fill on the rail (white, the
|
||||||
* genre the grid beside it is showing wears a quiet plate with a bar in the accent. One
|
* brightest thing on a near-black column), and the genre the grid beside it is showing
|
||||||
* causes the other while the viewer is in the rail, and they come apart the moment the
|
* wears the same white held back to a wash, with a bar in the accent. One causes the
|
||||||
* viewer presses Right — which is the case the distinction exists for.
|
* other while the viewer is in the rail, and they come apart the moment the viewer
|
||||||
|
* presses Right — which is the case the distinction exists for.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun GenreRailItem(
|
private fun GenreRailItem(
|
||||||
@@ -551,14 +606,18 @@ private fun GenreRailItem(
|
|||||||
label = "genre-rail-emphasis",
|
label = "genre-rail-emphasis",
|
||||||
)
|
)
|
||||||
val plate = when {
|
val plate = when {
|
||||||
focused -> MembyAccent
|
// White, not the accent. The rail sits on near-black beside a grid of artwork,
|
||||||
// Deep green rather than a neutral: the genre in force is what the grid beside
|
// and a coloured plate with dark ink on it read as a filled-in shape rather
|
||||||
// the rail is showing, and a grey plate read as "this row is slightly raised"
|
// than as a highlight — the thing under the thumb should be the brightest
|
||||||
// rather than as "this is the one you are looking at".
|
// thing on the screen, which on this surface means white.
|
||||||
active -> MembyAccentDeep
|
focused -> Color.White
|
||||||
|
// The genre in force keeps the same white, held back to a wash. One vocabulary
|
||||||
|
// for both states: a viewer down in the grid is looking for the same mark they
|
||||||
|
// left behind in the rail, only quieter.
|
||||||
|
active -> Color.White.copy(alpha = 0.14f)
|
||||||
else -> Color.Transparent
|
else -> Color.Transparent
|
||||||
}
|
}
|
||||||
val bar = if (focused) MembyAccentInk.copy(alpha = 0.45f) else MembyAccent
|
val bar = if (focused) MembyAccentDeep else MembyAccent
|
||||||
val barVisible = focused || active
|
val barVisible = focused || active
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -579,20 +638,38 @@ private fun GenreRailItem(
|
|||||||
}
|
}
|
||||||
.padding(start = 15.dp, end = 12.dp, top = 11.dp, bottom = 11.dp),
|
.padding(start = 15.dp, end = 12.dp, top = 11.dp, bottom = 11.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
val ink = when {
|
||||||
category.label,
|
focused -> MembyAccentInk
|
||||||
color = when {
|
active -> Color.White
|
||||||
focused -> MembyAccentInk
|
else -> MembyMutedText
|
||||||
active -> Color.White
|
}
|
||||||
else -> MembyMutedText
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
},
|
Text(
|
||||||
fontSize = 15.sp,
|
category.label,
|
||||||
fontWeight = if (focused || active) FontWeight.Bold else FontWeight.Medium,
|
color = ink,
|
||||||
// Wrapped rather than ellipsised: "Family & Animation" is a real entry and
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
// a rail that hid half of its own labels would be unreadable at distance.
|
fontSize = 15.sp,
|
||||||
maxLines = 2,
|
fontWeight = if (focused || active) FontWeight.Bold else FontWeight.Medium,
|
||||||
lineHeight = 18.sp,
|
// Wrapped rather than ellipsised: "Family & Animation" is a real entry
|
||||||
)
|
// and a rail that hid half of its own labels would be unreadable at
|
||||||
|
// distance.
|
||||||
|
maxLines = 2,
|
||||||
|
lineHeight = 18.sp,
|
||||||
|
)
|
||||||
|
// The whole-catalogue entry is the one row a viewer has no genre name to
|
||||||
|
// recognise, so it is the one that has to say it leads somewhere. It is a
|
||||||
|
// mark on the row rather than a second style of row: everything else about
|
||||||
|
// it — the plate, the marker, the ink — is what every other genre wears.
|
||||||
|
if (category.id == ALL_MEDIA_CATEGORY_ID) {
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Icon(
|
||||||
|
MembyIcon.ChevronRight.mark,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = ink.copy(alpha = if (focused || active) 0.85f else 0.6f),
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,8 +153,6 @@ fun SearchScreen(
|
|||||||
discoveryItems: List<BaseItem>,
|
discoveryItems: List<BaseItem>,
|
||||||
returnFocusItemId: String?,
|
returnFocusItemId: String?,
|
||||||
returnFocusRequester: FocusRequester,
|
returnFocusRequester: FocusRequester,
|
||||||
initialQuery: String? = null,
|
|
||||||
onInitialQueryConsumed: () -> Unit = {},
|
|
||||||
onSearchStarted: () -> Unit = {},
|
onSearchStarted: () -> Unit = {},
|
||||||
onItemFocused: (BaseItem) -> Unit,
|
onItemFocused: (BaseItem) -> Unit,
|
||||||
onItemSelected: (BaseItem) -> Unit,
|
onItemSelected: (BaseItem) -> Unit,
|
||||||
@@ -169,12 +167,6 @@ fun SearchScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
LaunchedEffect(discoveryItems) { viewModel.setDiscoveryItems(discoveryItems) }
|
LaunchedEffect(discoveryItems) { viewModel.setDiscoveryItems(discoveryItems) }
|
||||||
LaunchedEffect(initialQuery) {
|
|
||||||
initialQuery?.takeIf { it.isNotBlank() }?.let {
|
|
||||||
viewModel.onQueryChanged(it)
|
|
||||||
onInitialQueryConsumed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val resultsEntry = remember { FocusRequester() }
|
val resultsEntry = remember { FocusRequester() }
|
||||||
// The rail and the screen agree on one entry target: Search opens on the keyboard,
|
// The rail and the screen agree on one entry target: Search opens on the keyboard,
|
||||||
@@ -199,8 +191,7 @@ fun SearchScreen(
|
|||||||
}
|
}
|
||||||
val hasResultsTarget = when {
|
val hasResultsTarget = when {
|
||||||
state.errorMessage != null && state.results.isEmpty() -> true
|
state.errorMessage != null && state.results.isEmpty() -> true
|
||||||
state.isDiscovery -> discoveryItems.isNotEmpty() ||
|
state.isDiscovery -> discoveryItems.isNotEmpty() || state.genreSuggestions.isNotEmpty()
|
||||||
state.suggestions.any { it.kind == SearchSuggestion.Kind.GENRE }
|
|
||||||
else -> state.results.isNotEmpty() || state.requestCandidates.isNotEmpty() ||
|
else -> state.results.isNotEmpty() || state.requestCandidates.isNotEmpty() ||
|
||||||
(state.requestsAvailable && shouldSearch(state.query))
|
(state.requestsAvailable && shouldSearch(state.query))
|
||||||
}
|
}
|
||||||
@@ -310,7 +301,6 @@ fun SearchScreen(
|
|||||||
onRetry = viewModel::retry,
|
onRetry = viewModel::retry,
|
||||||
onRequest = viewModel::request,
|
onRequest = viewModel::request,
|
||||||
onShowRequests = viewModel::showRequests,
|
onShowRequests = viewModel::showRequests,
|
||||||
onSuggestionSelected = viewModel::onQueryChanged,
|
|
||||||
onGenreSelected = viewModel::onGenreSelected,
|
onGenreSelected = viewModel::onGenreSelected,
|
||||||
onBackFromGenre = closeGenre,
|
onBackFromGenre = closeGenre,
|
||||||
onLoadMore = viewModel::loadMore,
|
onLoadMore = viewModel::loadMore,
|
||||||
@@ -814,7 +804,6 @@ private fun ResultsPane(
|
|||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onRequest: (GatewayRequestCandidate) -> Unit,
|
onRequest: (GatewayRequestCandidate) -> Unit,
|
||||||
onShowRequests: () -> Unit,
|
onShowRequests: () -> Unit,
|
||||||
onSuggestionSelected: (String) -> Unit,
|
|
||||||
onGenreSelected: (String) -> Unit,
|
onGenreSelected: (String) -> Unit,
|
||||||
onBackFromGenre: () -> Unit,
|
onBackFromGenre: () -> Unit,
|
||||||
onLoadMore: () -> Unit,
|
onLoadMore: () -> Unit,
|
||||||
@@ -843,37 +832,19 @@ private fun ResultsPane(
|
|||||||
requestFocusRequester = resultsEntry,
|
requestFocusRequester = resultsEntry,
|
||||||
keyboardReturn = keyboardReturn,
|
keyboardReturn = keyboardReturn,
|
||||||
)
|
)
|
||||||
val genres = state.suggestions.filter { it.kind == SearchSuggestion.Kind.GENRE }
|
val genres = state.genreSuggestions
|
||||||
val recent = state.suggestions.filter { it.kind == SearchSuggestion.Kind.RECENT }
|
|
||||||
if (showingDiscovery && genres.isNotEmpty()) {
|
if (showingDiscovery && genres.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
GenreTiles(
|
GenreTiles(
|
||||||
genres = genres,
|
genres = genres,
|
||||||
resultsEntry = resultsEntry,
|
resultsEntry = resultsEntry,
|
||||||
keyboardReturn = keyboardReturn,
|
keyboardReturn = keyboardReturn,
|
||||||
// Not onSuggestionSelected: a genre opens the shelf of titles that are
|
// A genre opens the shelf of titles that are in it: running its name
|
||||||
// in it, where running its name through search matched a film called
|
// through search matched a film called Drama and missed most of the
|
||||||
// Drama and missed most of the drama.
|
// drama.
|
||||||
onSelected = onGenreSelected,
|
onSelected = onGenreSelected,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showingDiscovery && recent.isNotEmpty()) {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
"RECENT SEARCHES",
|
|
||||||
color = Muted,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
letterSpacing = 0.8.sp,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
SuggestionChips(
|
|
||||||
suggestions = recent,
|
|
||||||
resultsEntry = null,
|
|
||||||
keyboardReturn = keyboardReturn,
|
|
||||||
onSelected = onSuggestionSelected,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
when {
|
when {
|
||||||
@@ -1391,18 +1362,18 @@ private fun genreIcon(label: String): ImageVector = when {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun GenreTiles(
|
private fun GenreTiles(
|
||||||
genres: List<SearchSuggestion>,
|
genres: List<String>,
|
||||||
resultsEntry: FocusRequester,
|
resultsEntry: FocusRequester,
|
||||||
keyboardReturn: FocusRequester,
|
keyboardReturn: FocusRequester,
|
||||||
onSelected: (String) -> Unit,
|
onSelected: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
rowItemsIndexed(genres.take(6), key = { _, item -> item.label }) { index, genre ->
|
rowItemsIndexed(genres.take(6), key = { _, item -> item }) { index, genre ->
|
||||||
val color = GenreColors[index % GenreColors.size]
|
val color = GenreColors[index % GenreColors.size]
|
||||||
FocusScaleContainer(
|
FocusScaleContainer(
|
||||||
onFocused = {},
|
onFocused = {},
|
||||||
onClick = { onSelected(genre.label) },
|
onClick = { onSelected(genre) },
|
||||||
contentDescription = "Search the ${genre.label} genre",
|
contentDescription = "Search the $genre genre",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(126.dp)
|
.width(126.dp)
|
||||||
.height(72.dp)
|
.height(72.dp)
|
||||||
@@ -1416,7 +1387,7 @@ private fun GenreTiles(
|
|||||||
.background(if (focused) color.copy(alpha = 0.95f) else color),
|
.background(if (focused) color.copy(alpha = 0.95f) else color),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
genreIcon(genre.label),
|
genreIcon(genre),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color.White.copy(alpha = 0.9f),
|
tint = Color.White.copy(alpha = 0.9f),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -1425,7 +1396,7 @@ private fun GenreTiles(
|
|||||||
.size(30.dp),
|
.size(30.dp),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
genre.label,
|
genre,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
@@ -1439,42 +1410,6 @@ private fun GenreTiles(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SuggestionChips(
|
|
||||||
suggestions: List<SearchSuggestion>,
|
|
||||||
resultsEntry: FocusRequester?,
|
|
||||||
keyboardReturn: FocusRequester,
|
|
||||||
onSelected: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
suggestions.take(6).forEachIndexed { index, suggestion ->
|
|
||||||
FocusScaleContainer(
|
|
||||||
onFocused = {},
|
|
||||||
onClick = { onSelected(suggestion.label) },
|
|
||||||
contentDescription = when (suggestion.kind) {
|
|
||||||
SearchSuggestion.Kind.RECENT -> "Search again for ${suggestion.label}"
|
|
||||||
SearchSuggestion.Kind.GENRE -> "Search the ${suggestion.label} genre"
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(999.dp))
|
|
||||||
.then(if (index == 0 && resultsEntry != null) Modifier.focusRequester(resultsEntry) else Modifier)
|
|
||||||
.focusProperties { if (index == 0) left = keyboardReturn },
|
|
||||||
) { focused ->
|
|
||||||
Text(
|
|
||||||
suggestion.label.uppercase(),
|
|
||||||
color = if (focused) KeyLabelFocused else KeyLabel,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
maxLines = 1,
|
|
||||||
modifier = Modifier
|
|
||||||
.background(if (focused) KeyFocused else KeyIdle)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 7.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchEmptyMessage(state: SearchUiState, showingDiscovery: Boolean) {
|
private fun SearchEmptyMessage(state: SearchUiState, showingDiscovery: Boolean) {
|
||||||
val message = when {
|
val message = when {
|
||||||
|
|||||||
@@ -23,15 +23,6 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
|
||||||
* Something to offer when there is nothing to search for yet. Kept deliberately cheap:
|
|
||||||
* both kinds are built from data the app already has in memory, because an empty search
|
|
||||||
* box is not worth a server request.
|
|
||||||
*/
|
|
||||||
data class SearchSuggestion(val label: String, val kind: Kind) {
|
|
||||||
enum class Kind { RECENT, GENRE }
|
|
||||||
}
|
|
||||||
|
|
||||||
data class SearchUiState(
|
data class SearchUiState(
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
val results: List<BaseItem> = emptyList(),
|
val results: List<BaseItem> = emptyList(),
|
||||||
@@ -59,7 +50,12 @@ data class SearchUiState(
|
|||||||
val genreOffset: Int = 0,
|
val genreOffset: Int = 0,
|
||||||
/** There is more of this genre to ask for. See [hasMoreGenreItems]. */
|
/** There is more of this genre to ask for. See [hasMoreGenreItems]. */
|
||||||
val canLoadMore: Boolean = false,
|
val canLoadMore: Boolean = false,
|
||||||
val suggestions: List<SearchSuggestion> = emptyList(),
|
/**
|
||||||
|
* Genres to offer when there is nothing to search for yet. Kept deliberately cheap:
|
||||||
|
* they are built from data the app already has in memory, because an empty search box
|
||||||
|
* is not worth a server request.
|
||||||
|
*/
|
||||||
|
val genreSuggestions: List<String> = emptyList(),
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
/** True once a query has run to completion, so "no matches" is distinguishable from "not yet". */
|
/** True once a query has run to completion, so "no matches" is distinguishable from "not yet". */
|
||||||
val hasSearched: Boolean = false,
|
val hasSearched: Boolean = false,
|
||||||
@@ -110,7 +106,6 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
): Boolean = size > CACHE_ENTRIES
|
): Boolean = size > CACHE_ENTRIES
|
||||||
}
|
}
|
||||||
|
|
||||||
private val recentQueries = ArrayDeque<String>()
|
|
||||||
private var genreSuggestions: List<String> = emptyList()
|
private var genreSuggestions: List<String> = emptyList()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,15 +127,6 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
// same terms while keeping the repository's plain suspend signature.
|
// same terms while keeping the repository's plain suspend signature.
|
||||||
.collectLatest { term -> runSearch(term) }
|
.collectLatest { term -> runSearch(term) }
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
|
||||||
repository.getRecentSearches().forEach { term ->
|
|
||||||
if (recentQueries.none { it.equals(term, ignoreCase = true) }) {
|
|
||||||
recentQueries.addLast(term)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while (recentQueries.size > MAX_RECENT_QUERIES) recentQueries.removeLast()
|
|
||||||
refreshSuggestions()
|
|
||||||
}
|
|
||||||
refreshSuggestions()
|
refreshSuggestions()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,7 +446,6 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
rankSearchResults(term, items)
|
rankSearchResults(term, items)
|
||||||
}
|
}
|
||||||
cache[term] = ranked
|
cache[term] = ranked
|
||||||
rememberQuery(term)
|
|
||||||
viewModelScope.launch { repository.recordSearch(term) }
|
viewModelScope.launch { repository.recordSearch(term) }
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
|
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
|
||||||
@@ -502,24 +487,14 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rememberQuery(term: String) {
|
|
||||||
recentQueries.removeAll { it.equals(term, ignoreCase = true) }
|
|
||||||
recentQueries.addFirst(term)
|
|
||||||
while (recentQueries.size > MAX_RECENT_QUERIES) recentQueries.removeLast()
|
|
||||||
refreshSuggestions()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshSuggestions() {
|
private fun refreshSuggestions() {
|
||||||
val suggestions = recentQueries.map { SearchSuggestion(it, SearchSuggestion.Kind.RECENT) } +
|
_state.update { it.copy(genreSuggestions = genreSuggestions) }
|
||||||
genreSuggestions.map { SearchSuggestion(it, SearchSuggestion.Kind.GENRE) }
|
|
||||||
_state.update { it.copy(suggestions = suggestions) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val DEBOUNCE_MS = 250L
|
const val DEBOUNCE_MS = 250L
|
||||||
const val MIN_QUERY_LENGTH = 2
|
const val MIN_QUERY_LENGTH = 2
|
||||||
private const val CACHE_ENTRIES = 24
|
private const val CACHE_ENTRIES = 24
|
||||||
private const val MAX_RECENT_QUERIES = 6
|
|
||||||
private const val MAX_GENRE_SUGGESTIONS = 6
|
private const val MAX_GENRE_SUGGESTIONS = 6
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import com.ponzischeme89.memby.data.model.GatewayNextEpisode
|
|||||||
import com.ponzischeme89.memby.data.model.GatewayMovieRatings
|
import com.ponzischeme89.memby.data.model.GatewayMovieRatings
|
||||||
import com.ponzischeme89.memby.data.model.GatewayPlayback
|
import com.ponzischeme89.memby.data.model.GatewayPlayback
|
||||||
import com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse
|
import com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse
|
||||||
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
|
|
||||||
import com.ponzischeme89.memby.data.model.GatewayPreferences
|
import com.ponzischeme89.memby.data.model.GatewayPreferences
|
||||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||||
import com.ponzischeme89.memby.data.model.GatewayIntro
|
import com.ponzischeme89.memby.data.model.GatewayIntro
|
||||||
@@ -497,15 +496,6 @@ class GatewayPayloadTest {
|
|||||||
assertTrue(home.partial)
|
assertTrue(home.partial)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `decodes persisted recent searches`() {
|
|
||||||
val history = json.decodeFromString<GatewaySearchHistory>(
|
|
||||||
"""{"queries":["severance","slow horses","arrival"]}""",
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals(listOf("severance", "slow horses", "arrival"), history.queries)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `decodes a playback response`() {
|
fun `decodes a playback response`() {
|
||||||
val playback = json.decodeFromString<GatewayPlayback>(
|
val playback = json.decodeFromString<GatewayPlayback>(
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ class ServerHomeRowsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `shows destination includes empty favorites and ranked server shelves`() {
|
fun `shows destination drops empty shelves and keeps ranked server ones`() {
|
||||||
val rankedRows = listOf(
|
val rankedRows = listOf(
|
||||||
row("curated:comedy-shows", "shows", "c1"),
|
row("curated:comedy-shows", "shows", "c1"),
|
||||||
row("curated:horror-shows", "shows", "h1"),
|
row("curated:horror-shows", "shows", "h1"),
|
||||||
@@ -210,17 +210,45 @@ class ServerHomeRowsTest {
|
|||||||
|
|
||||||
val rows = homeRowsFor(BrowseDestination.SHOWS, state, Settings())
|
val rows = homeRowsFor(BrowseDestination.SHOWS, state, Settings())
|
||||||
|
|
||||||
|
// Nothing part-way through and nothing favourited, so neither fixed shelf is a
|
||||||
|
// heading worth printing: the D-pad already refuses to enter an empty one.
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf(
|
listOf(
|
||||||
"continue-shows",
|
|
||||||
"favourite-shows",
|
|
||||||
"curated:comedy-shows",
|
"curated:comedy-shows",
|
||||||
"curated:horror-shows",
|
"curated:horror-shows",
|
||||||
"curated:drama-shows",
|
"curated:drama-shows",
|
||||||
),
|
),
|
||||||
rows.map { it.id },
|
rows.map { it.id },
|
||||||
)
|
)
|
||||||
assertTrue(rows.first { it.id == "favourite-shows" }.items.isEmpty())
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `shows destination keeps its fixed shelves once they have something in them`() {
|
||||||
|
val state = HomeUiState(
|
||||||
|
rows = serverRows,
|
||||||
|
continueWatching = listOf(BaseItem(id = "ep", type = "Episode")),
|
||||||
|
favorites = listOf(BaseItem(id = "series", type = "Series")),
|
||||||
|
loading = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val rows = homeRowsFor(BrowseDestination.SHOWS, state, Settings())
|
||||||
|
|
||||||
|
assertEquals(listOf("continue-shows", "favourite-shows"), rows.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a shows shelf that is still loading is kept`() {
|
||||||
|
val state = HomeUiState(
|
||||||
|
rows = serverRows,
|
||||||
|
favorites = emptyList(),
|
||||||
|
loading = setOf(HomeSection.FAVORITES),
|
||||||
|
)
|
||||||
|
|
||||||
|
val rows = homeRowsFor(BrowseDestination.SHOWS, state, Settings())
|
||||||
|
|
||||||
|
// Withdrawing it would move every shelf below it a moment later, when the
|
||||||
|
// favourites land.
|
||||||
|
assertTrue(rows.any { it.id == "favourite-shows" })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.ponzischeme89.memby.ui
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shortcut menu a hold on the rail's user item raises, into
|
||||||
|
* `build/screenshots/user-shortcuts/`.
|
||||||
|
*
|
||||||
|
* ```powershell
|
||||||
|
* .\gradlew.bat :app:testDebugUnitTest --tests "*UserQuickActionsScreenshotTest"
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* The claim this feature makes is that the menu is *small and immediate* — the alternative
|
||||||
|
* to walking into a page — and that the unavailable row reads as deliberately unavailable
|
||||||
|
* rather than as one that failed to load. Neither is a thing a unit test can check, so the
|
||||||
|
* empty case is the capture worth keeping.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||||
|
class UserQuickActionsScreenshotTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val compose = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `alerts waiting`() {
|
||||||
|
capture("user-shortcuts-populated", alertCount = 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One, which is the row's singular wording. */
|
||||||
|
@Test
|
||||||
|
fun `a single alert`() {
|
||||||
|
capture("user-shortcuts-single", alertCount = 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nothing waiting. The row is drawn quiet and unpressable — the capture is the check
|
||||||
|
* that it reads that way beside a Cancel that is not.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `nothing to clear`() {
|
||||||
|
capture("user-shortcuts-empty", alertCount = 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A clear already in flight: both rows drawn, neither of them offering a second press. */
|
||||||
|
@Test
|
||||||
|
fun `already clearing`() {
|
||||||
|
capture("user-shortcuts-busy", alertCount = 7, busy = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun capture(name: String, alertCount: Int, busy: Boolean = false) {
|
||||||
|
compose.setContent {
|
||||||
|
MembyTheme {
|
||||||
|
UserQuickActionsOverlay(
|
||||||
|
username = "Matt",
|
||||||
|
alertCount = alertCount,
|
||||||
|
busy = busy,
|
||||||
|
onClearNotifications = {},
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onRoot().captureRoboImage("build/screenshots/user-shortcuts/$name.png")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.ponzischeme89.memby.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shortcut menu's wording and its one real rule: nothing waiting means nothing to press.
|
||||||
|
*
|
||||||
|
* A menu offering "Clear notifications" over an empty list would send a request the gateway
|
||||||
|
* can only answer with zero, and would tell somebody it had done something it had not.
|
||||||
|
*/
|
||||||
|
class UserQuickActionsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nothing waiting disables the clear action and says so`() {
|
||||||
|
val actions = userQuickActions(alertCount = 0)
|
||||||
|
val clear = actions.first { it.kind == UserQuickActionKind.CLEAR_NOTIFICATIONS }
|
||||||
|
assertFalse(clear.enabled)
|
||||||
|
assertEquals("No notifications to clear", clear.label)
|
||||||
|
// Still offered rather than removed: a menu that changes shape with the news is one
|
||||||
|
// nobody can learn, and Cancel must always be there to leave by.
|
||||||
|
assertEquals(2, actions.size)
|
||||||
|
assertTrue(actions.last().enabled)
|
||||||
|
assertEquals(UserQuickActionKind.CANCEL, actions.last().kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the count is named exactly, past the badge's cap`() {
|
||||||
|
assertEquals("Clear 1 notification", labelFor(1))
|
||||||
|
assertEquals("Clear 7 notifications", labelFor(7))
|
||||||
|
// The badge says "9+" because its pill is narrow; this row has the width to be exact,
|
||||||
|
// and a viewer deciding whether to clear wants the real figure.
|
||||||
|
assertEquals("Clear 42 notifications", labelFor(42))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a clear already in flight cannot be started twice`() {
|
||||||
|
val clear = userQuickActions(alertCount = 5, busy = true)
|
||||||
|
.first { it.kind == UserQuickActionKind.CLEAR_NOTIFICATIONS }
|
||||||
|
assertFalse(clear.enabled)
|
||||||
|
// The wording still names what is waiting: the row is unavailable for a moment, not
|
||||||
|
// a different row.
|
||||||
|
assertEquals("Clear 5 notifications", clear.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The confirmation reports the gateway's count, so zero is a real answer — another set
|
||||||
|
* in the house emptying the list first — and reads as such rather than as a failure.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the confirmation reports what actually happened`() {
|
||||||
|
assertEquals("Nothing left to clear", clearedNotificationsMessage(0))
|
||||||
|
assertEquals("1 notification cleared", clearedNotificationsMessage(1))
|
||||||
|
assertEquals("6 notifications cleared", clearedNotificationsMessage(6))
|
||||||
|
assertEquals("Nothing left to clear", clearedNotificationsMessage(-3))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun labelFor(count: Int) = userQuickActions(count)
|
||||||
|
.first { it.kind == UserQuickActionKind.CLEAR_NOTIFICATIONS }.label
|
||||||
|
}
|
||||||
@@ -272,7 +272,6 @@ func (s *Server) Routes() http.Handler {
|
|||||||
// treats it as one. Paged, because a household's Drama shelf is not a screenful.
|
// treats it as one. Paged, because a household's Drama shelf is not a screenful.
|
||||||
v1.Handle("GET /v1/genres/{genre}/items", s.authed(s.handleGenreItems))
|
v1.Handle("GET /v1/genres/{genre}/items", s.authed(s.handleGenreItems))
|
||||||
v1.Handle("GET /v1/library/items", s.authed(s.handleLibraryItems))
|
v1.Handle("GET /v1/library/items", s.authed(s.handleLibraryItems))
|
||||||
v1.Handle("GET /v1/search/history", s.authed(s.handleRecentSearches))
|
|
||||||
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
|
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
|
||||||
v1.Handle("GET /v1/requests/lookup", s.authed(s.handleRequestLookup))
|
v1.Handle("GET /v1/requests/lookup", s.authed(s.handleRequestLookup))
|
||||||
v1.Handle("GET /v1/requests", s.authed(s.handleMyRequests))
|
v1.Handle("GET /v1/requests", s.authed(s.handleMyRequests))
|
||||||
@@ -291,6 +290,9 @@ func (s *Server) Routes() http.Handler {
|
|||||||
v1.Handle("DELETE /v1/my-shows/{id}", s.authed(s.handleMyShow))
|
v1.Handle("DELETE /v1/my-shows/{id}", s.authed(s.handleMyShow))
|
||||||
v1.Handle("GET /v1/notifications", s.authed(s.handleNotifications))
|
v1.Handle("GET /v1/notifications", s.authed(s.handleNotifications))
|
||||||
v1.Handle("PUT /v1/notifications", s.authed(s.handleNotifications))
|
v1.Handle("PUT /v1/notifications", s.authed(s.handleNotifications))
|
||||||
|
// Ahead of the per-alert route: three path segments rather than four, so the two never
|
||||||
|
// compete, and a shortcut that clears the lot needs one request and one log line.
|
||||||
|
v1.Handle("POST /v1/notifications/clear", s.authed(s.handleClearNotifications))
|
||||||
v1.Handle("POST /v1/notifications/{id}/{action}", s.authed(s.handleNotificationAction))
|
v1.Handle("POST /v1/notifications/{id}/{action}", s.authed(s.handleNotificationAction))
|
||||||
v1.Handle("GET /v1/features", s.authed(s.handleFeatures))
|
v1.Handle("GET /v1/features", s.authed(s.handleFeatures))
|
||||||
// A viewer's settings follow the person, not the television. Both verbs land on one
|
// A viewer's settings follow the person, not the television. Both verbs land on one
|
||||||
|
|||||||
@@ -298,21 +298,6 @@ func TestHomeResponseEncodesEmptyRowsAsArrays(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSearchHistoryResponseEncodesEmptyQueriesAsArray(t *testing.T) {
|
|
||||||
resp := searchHistoryResponse{Queries: []string{}}
|
|
||||||
body, err := json.Marshal(resp)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal: %v", err)
|
|
||||||
}
|
|
||||||
var decoded map[string]any
|
|
||||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
|
||||||
t.Fatalf("unmarshal: %v", err)
|
|
||||||
}
|
|
||||||
if _, ok := decoded["queries"].([]any); !ok {
|
|
||||||
t.Fatalf("queries encoded as %T, want array", decoded["queries"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both routes that write search_history apply one rule, so a query /v1/search records is
|
// Both routes that write search_history apply one rule, so a query /v1/search records is
|
||||||
// exactly one /v1/search/history would have accepted. The length is counted in runes:
|
// exactly one /v1/search/history would have accepted. The length is counted in runes:
|
||||||
// bytes would reject a Japanese title at a third of an English one's length.
|
// bytes would reject a Japanese title at a third of an English one's length.
|
||||||
|
|||||||
@@ -664,37 +664,6 @@ type searchHistoryRequest struct {
|
|||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type searchHistoryResponse struct {
|
|
||||||
Queries []string `json:"queries"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
recentSearchDays = 30
|
|
||||||
recentSearchLimit = 10
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Server) handleRecentSearches(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
|
||||||
if s.store == nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "could not load recent searches")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
since := time.Now().Add(-recentSearchDays * 24 * time.Hour)
|
|
||||||
queries, err := s.store.RecentSearches(
|
|
||||||
r.Context(),
|
|
||||||
sess.EmbyUserID,
|
|
||||||
since,
|
|
||||||
recentSearchLimit,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "could not load recent searches")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if queries == nil {
|
|
||||||
queries = []string{}
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, searchHistoryResponse{Queries: queries})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleSearchHistory(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
func (s *Server) handleSearchHistory(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||||
var req searchHistoryRequest
|
var req searchHistoryRequest
|
||||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); err != nil {
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); err != nil {
|
||||||
|
|||||||
@@ -229,6 +229,72 @@ func (s *Server) syncReturnNotifications(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clearNotificationsResponse says how many of this viewer's notifications the gateway
|
||||||
|
// actually cleared. The television prints the figure back as its confirmation, so it must be
|
||||||
|
// what happened rather than what was asked for.
|
||||||
|
type clearNotificationsResponse struct {
|
||||||
|
Cleared int `json:"cleared"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleClearNotifications empties one viewer's list in a single request.
|
||||||
|
//
|
||||||
|
// It exists because clearing from the user picker is a shortcut for somebody who does not
|
||||||
|
// want to go into the page at all, and a television looping the per-alert dismiss route
|
||||||
|
// could neither report a trustworthy count nor leave one line in the log an operator could
|
||||||
|
// read. The two rules worth preserving:
|
||||||
|
//
|
||||||
|
// - What it clears is what that viewer can *see*. filterStoredNotifications is what the
|
||||||
|
// list route already applies, so a summary their preferences have withdrawn is not
|
||||||
|
// quietly dismissed underneath them by a press aimed at the seven alerts on screen —
|
||||||
|
// and the count agrees with the badge that was showing.
|
||||||
|
// - Nothing to clear is a success, not an error. It answers 0 and says so, because the
|
||||||
|
// television disables the action on an empty list and a race with another set finishing
|
||||||
|
// the job first is not a failure anybody should be shown.
|
||||||
|
//
|
||||||
|
// clearableNotificationIDs is the rows a clear-all press may take: exactly the ones the
|
||||||
|
// list route would have shown this viewer, and nothing their preferences have withdrawn.
|
||||||
|
//
|
||||||
|
// Pure and separate from the handler so the one rule that matters here — a press aimed at
|
||||||
|
// what is on screen never reaches past it — is pinned by a test rather than by a database.
|
||||||
|
func clearableNotificationIDs(
|
||||||
|
notifications []store.UserNotification, prefs store.NotificationPreferences,
|
||||||
|
) []int64 {
|
||||||
|
visible := filterStoredNotifications(notifications, prefs)
|
||||||
|
ids := make([]int64, 0, len(visible))
|
||||||
|
for _, notification := range visible {
|
||||||
|
ids = append(ids, notification.ID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleClearNotifications(
|
||||||
|
w http.ResponseWriter, r *http.Request, sess store.Session,
|
||||||
|
) {
|
||||||
|
log := s.loggerFor(r.Context())
|
||||||
|
prefs, err := s.notificationPreferencesFor(r.Context(), sess.EmbyUserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("notifications not cleared", "reason", "preferences unavailable", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not load notification preferences")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notifications, err := s.store.UserNotifications(r.Context(), sess.EmbyUserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("notifications not cleared", "reason", "list unavailable", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not load notifications")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := clearableNotificationIDs(notifications, prefs)
|
||||||
|
cleared, err := s.store.DismissNotifications(r.Context(), sess.EmbyUserID, ids)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("notifications not cleared", "reason", "write failed",
|
||||||
|
"requested", len(ids), "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not clear notifications")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Info("notifications cleared", "cleared", cleared, "source", "user-switcher")
|
||||||
|
writeJSON(w, http.StatusOK, clearNotificationsResponse{Cleared: cleared})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleNotificationAction(
|
func (s *Server) handleNotificationAction(
|
||||||
w http.ResponseWriter, r *http.Request, sess store.Session,
|
w http.ResponseWriter, r *http.Request, sess store.Session,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -55,3 +55,29 @@ func TestUpdatePreferenceNeverSuppressesMandatoryUpdate(t *testing.T) {
|
|||||||
t.Fatalf("optional update was not suppressed: %#v", got)
|
t.Fatalf("optional update was not suppressed: %#v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A clear-all press aimed at the alerts on screen must never reach past them. The one case
|
||||||
|
// that can differ is a kind the viewer's own preferences have withdrawn: it is still a row
|
||||||
|
// in the table, it is not in their list, and clearing it would be this shortcut deciding
|
||||||
|
// something the viewer never saw.
|
||||||
|
func TestClearableNotificationIDsHonourPreferences(t *testing.T) {
|
||||||
|
notifications := []store.UserNotification{
|
||||||
|
{ID: 1, Kind: "show-return"},
|
||||||
|
{ID: 2, Kind: watchTimeWeeklyKind},
|
||||||
|
{ID: 3, Kind: "library-added"},
|
||||||
|
}
|
||||||
|
prefs := store.DefaultNotificationPreferences()
|
||||||
|
prefs.WatchTimeDigest = false
|
||||||
|
ids := clearableNotificationIDs(notifications, prefs)
|
||||||
|
if len(ids) != 2 || ids[0] != 1 || ids[1] != 3 {
|
||||||
|
t.Fatalf("expected the two visible rows, got %v", ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs.Enabled = false
|
||||||
|
if ids := clearableNotificationIDs(notifications, prefs); len(ids) != 0 {
|
||||||
|
t.Fatalf("notifications switched off should clear nothing, got %v", ids)
|
||||||
|
}
|
||||||
|
if ids := clearableNotificationIDs(nil, store.DefaultNotificationPreferences()); len(ids) != 0 {
|
||||||
|
t.Fatalf("an empty list should clear nothing, got %v", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -354,3 +354,28 @@ func (s *Store) DismissNotification(ctx context.Context, userID string, id int64
|
|||||||
WHERE id = $1 AND emby_user_id = $2`, id, userID)
|
WHERE id = $1 AND emby_user_id = $2`, id, userID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DismissNotifications clears several of one viewer's notifications at once and reports how
|
||||||
|
// many rows it actually took.
|
||||||
|
//
|
||||||
|
// The count is the whole reason this is a route rather than the client's loop: "cleared 7"
|
||||||
|
// is what the log line and the activity record are worth reading for, and a television
|
||||||
|
// counting the requests it made would be counting what it asked for rather than what
|
||||||
|
// happened — a row somebody dismissed on another set in the meantime is one this must not
|
||||||
|
// claim. Already-dismissed rows are excluded rather than re-stamped, so a repeated press
|
||||||
|
// honestly reports nothing left to clear.
|
||||||
|
func (s *Store) DismissNotifications(
|
||||||
|
ctx context.Context, userID string, ids []int64,
|
||||||
|
) (int, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
tag, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE user_notifications SET dismissed_at = now()
|
||||||
|
WHERE emby_user_id = $1 AND id = ANY($2::bigint[]) AND dismissed_at IS NULL`,
|
||||||
|
userID, ids)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("store: dismiss notifications: %w", err)
|
||||||
|
}
|
||||||
|
return int(tag.RowsAffected()), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Search history is the record of what a household looks for, and it has two readers with
|
// Search history is the record of what a household looks for, read by the console as the
|
||||||
// quite different appetites: a television asking for one viewer's last few queries, and
|
// summary of what the house searches for and as the uncollapsed log of what happened just
|
||||||
// the console asking what the house as a whole has been searching. Both read the one
|
// now. The writer's rules live here beside those readers.
|
||||||
// table, which is why the writer's rules live here beside them.
|
|
||||||
|
|
||||||
// SearchDedupeWindow is how long an identical query counts as the same search.
|
// SearchDedupeWindow is how long an identical query counts as the same search.
|
||||||
//
|
//
|
||||||
@@ -27,8 +26,8 @@ const SearchRetention = 30 * 24 * time.Hour
|
|||||||
|
|
||||||
// RecordSearch stores a normalized query for future per-user ranking analysis.
|
// RecordSearch stores a normalized query for future per-user ranking analysis.
|
||||||
//
|
//
|
||||||
// Case-insensitive within the dedupe window, matching RecentSearches, which collapses
|
// Case-insensitive within the dedupe window, so a query retyped with a different
|
||||||
// case-only duplicates when it reads them back.
|
// capitalisation is not a second search.
|
||||||
func (s *Store) RecordSearch(ctx context.Context, userID, query string) error {
|
func (s *Store) RecordSearch(ctx context.Context, userID, query string) error {
|
||||||
_, err := s.pool.Exec(ctx,
|
_, err := s.pool.Exec(ctx,
|
||||||
`WITH inserted AS (
|
`WITH inserted AS (
|
||||||
@@ -49,44 +48,6 @@ func (s *Store) RecordSearch(ctx context.Context, userID, query string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecentSearches returns a user's distinct queries in most-recently-used order.
|
|
||||||
// Case-only duplicates collapse to the spelling used most recently.
|
|
||||||
func (s *Store) RecentSearches(
|
|
||||||
ctx context.Context,
|
|
||||||
userID string,
|
|
||||||
since time.Time,
|
|
||||||
limit int,
|
|
||||||
) ([]string, error) {
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
|
||||||
SELECT query
|
|
||||||
FROM (
|
|
||||||
SELECT DISTINCT ON (lower(query)) query, occurred_at
|
|
||||||
FROM search_history
|
|
||||||
WHERE emby_user_id = $1 AND occurred_at >= $2
|
|
||||||
ORDER BY lower(query), occurred_at DESC
|
|
||||||
) AS latest
|
|
||||||
ORDER BY occurred_at DESC
|
|
||||||
LIMIT $3`,
|
|
||||||
userID, since, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("store: recent searches: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
queries := make([]string, 0, limit)
|
|
||||||
for rows.Next() {
|
|
||||||
var query string
|
|
||||||
if err := rows.Scan(&query); err != nil {
|
|
||||||
return nil, fmt.Errorf("store: scan recent search: %w", err)
|
|
||||||
}
|
|
||||||
queries = append(queries, query)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("store: read recent searches: %w", err)
|
|
||||||
}
|
|
||||||
return queries, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchTerm is one query the household searched for, aggregated across everyone.
|
// SearchTerm is one query the household searched for, aggregated across everyone.
|
||||||
type SearchTerm struct {
|
type SearchTerm struct {
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
@@ -114,8 +75,8 @@ type SearchTotals struct {
|
|||||||
|
|
||||||
// SearchTerms aggregates the household's queries since a point in time, most-searched
|
// SearchTerms aggregates the household's queries since a point in time, most-searched
|
||||||
// first. Grouped case-insensitively and labelled with the spelling used most recently,
|
// first. Grouped case-insensitively and labelled with the spelling used most recently,
|
||||||
// the same rule RecentSearches applies, so one query cannot appear as two rows because
|
// the same rule the writer's dedupe window applies, so one query cannot appear as two
|
||||||
// somebody's on-screen keyboard capitalised it.
|
// rows because somebody's on-screen keyboard capitalised it.
|
||||||
func (s *Store) SearchTerms(ctx context.Context, since time.Time, limit int) ([]SearchTerm, error) {
|
func (s *Store) SearchTerms(ctx context.Context, since time.Time, limit int) ([]SearchTerm, error) {
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT (array_agg(query ORDER BY occurred_at DESC))[1] AS query,
|
SELECT (array_agg(query ORDER BY occurred_at DESC))[1] AS query,
|
||||||
|
|||||||
Reference in New Issue
Block a user