0.3.27 - Omni search fixes, Services in genre browser..

This commit is contained in:
ponzischeme89
2026-08-26 08:42:56 +12:00
parent 919a12f8d5
commit 9d11bb7282
26 changed files with 2202 additions and 590 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
val defaultVersionName = "0.3.26"
val defaultVersionName = "0.3.27"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -964,7 +964,9 @@ class EmbyRepository internal constructor(
return searchRepository.search(term, limit)
}
fun searchProgress(term: String, limit: Int = 40) = searchRepository.searchProgress(term, limit)
/** External discovery, kept apart from [search] so a keystroke never waits on an *arr. */
suspend fun discover(term: String, limit: Int = DISCOVERY_LIMIT): DiscoveryResult =
searchRepository.discover(term, limit)
/**
* One page of a genre, dual-path like the search beside it.
@@ -990,6 +992,20 @@ class EmbyRepository internal constructor(
return searchRepository.browseGenre(genre, offset, limit, itemType)
}
/**
* One stable page filtered by streaming service shortcut (Apple TV+, Netflix, …), the
* [browseGenre] shape filtered on Emby's Studios field instead of Genres. See
* `ui/genre/GenreCategories.kt` for the service catalogue and its Emby spellings.
*/
suspend fun browseService(
service: String,
offset: Int = 0,
limit: Int = GENRE_PAGE_SIZE,
itemType: String? = null,
): GenrePage {
return searchRepository.browseService(service, offset, limit, itemType)
}
/**
* Which genres this viewer actually watches, for the order of the Genres browser's rail.
*
@@ -0,0 +1,62 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
/**
* What Sonarr and Radarr had to say about a query, and whether they were both able to say
* it.
*
* [partial] is separate from an empty list on purpose: "nothing else exists" is an answer
* and "we could not ask" is not, and only the second is worth a quiet line under the
* results. Neither is ever an error the viewer sees — an *arr being down must leave library
* search working exactly as it did.
*/
data class DiscoveryResult(
val items: List<BaseItem> = emptyList(),
val partial: Boolean = false,
/** Why nothing was asked at all, when nothing was. Diagnostics, never displayed. */
val skipped: String? = null,
)
/**
* The floor for asking somebody else's service.
*
* Two characters is the library's floor ([shouldSearch]); three is this one's. A two-letter
* query matches a large part of any catalogue, and unlike Emby the *arrs are not Memby's to
* make slow — so the letters on the way to a word are never sent.
*/
const val MIN_DISCOVERY_QUERY_LENGTH = 3
/** How many external candidates are worth carrying. The library section is the main event. */
const val DISCOVERY_LIMIT = 20
/**
* The one rule for when two searches are the same search.
*
* It must agree with the gateway's `normaliseDiscoveryQuery`, because that is what the
* server-side cooldown is keyed on: "Disclosure", "disclosure" and "disclosure " are one
* question, and asking any of them after another has been asked should reach the cache
* rather than Radarr.
*/
fun normaliseDiscoveryQuery(query: String): String =
query.trim().lowercase().split(WHITESPACE).filter(String::isNotEmpty).joinToString(" ")
/** True when a query is worth putting to Sonarr and Radarr at all. */
fun shouldDiscover(query: String): Boolean =
normaliseDiscoveryQuery(query).length >= MIN_DISCOVERY_QUERY_LENGTH
/**
* Whether discovery results for [discovered] still plausibly answer what is now typed.
*
* They are kept while the viewer is still extending the same word, because taking a section
* away between two keystrokes is worse than briefly showing one that is a letter behind —
* the newer answer is already on its way. Anything else is a different question and its
* results are dropped.
*/
fun discoveryStillRelevant(discovered: String, query: String): Boolean {
val current = normaliseDiscoveryQuery(query)
val previous = normaliseDiscoveryQuery(discovered)
return previous.isNotEmpty() && current.startsWith(previous)
}
private val WHITESPACE = Regex("\\s+")
@@ -69,20 +69,24 @@ class SearchRepository internal constructor(
)
}
/** Emits cumulative search snapshots as Emby, Sonarr and Radarr finish independently. */
fun searchProgress(term: String, limit: Int = 40): Flow<List<BaseItem>> = flow {
if (!ServerConfig.isGateway) { emit(search(term, limit)); return@flow }
requireGateway().searchStream(term.trim(), limit).use { body ->
val reader = body.charStream().buffered()
while (true) {
val line = reader.readLine() ?: break
if (line.isBlank()) continue
val snapshot = runCatching {
Json { ignoreUnknownKeys = true }.decodeFromString<GatewayItems>(line)
}.getOrNull()
snapshot?.let { emit(it.items) }
}
/**
* External discovery: what Sonarr and Radarr know that the library does not.
*
* A second request rather than part of [search], because those are somebody else's
* services and a keystroke must never wait on them. The gateway holds the cooldown —
* one lookup answers the whole household for ten minutes — so calling this after a
* pause in typing is cheap and calling it on a query already asked is free.
*
* The direct path has nobody to ask: with no gateway there is no Sonarr and no Radarr,
* and the honest answer is that discovery was skipped.
*/
suspend fun discover(term: String, limit: Int = DISCOVERY_LIMIT): DiscoveryResult {
val trimmed = term.trim()
if (!ServerConfig.isGateway || !shouldDiscover(trimmed)) {
return DiscoveryResult(skipped = if (ServerConfig.isGateway) "query_too_short" else "no_gateway")
}
val answer = requireGateway().searchDiscover(trimmed, limit)
return DiscoveryResult(items = answer.items, partial = answer.partial, skipped = answer.skipped)
}
/** One stable page filtered by genre; episodes are excluded deliberately. */
@@ -134,6 +138,56 @@ class SearchRepository internal constructor(
return GenrePage(items, offset, inferredTotal(offset, limit, items.size))
}
/**
* One stable page filtered by streaming service (Emby's Studios field); episodes are
* excluded, the [browseGenre] rule.
*
* Deliberately no keyword-search fallback for a gateway that predates the route: a
* service name is not a term anybody typed, and a text search for "Netflix" would
* answer with titles *about* Netflix rather than titles *on* it. An older gateway
* simply returns nothing, which the client reads as an empty shelf.
*/
suspend fun browseService(
service: String,
offset: Int = 0,
limit: Int = GENRE_PAGE_SIZE,
itemType: String? = null,
): GenrePage {
val trimmed = service.trim()
if (trimmed.isEmpty()) return GenrePage(emptyList(), offset, 0)
val embyItemType = itemTypes(itemType)
if (ServerConfig.isGateway) {
runCatching {
requireGateway().serviceItems(
trimmed,
offset,
limit,
embyItemType.takeUnless { it == ALL_ITEM_TYPES },
)
}
.onSuccess { page -> return GenrePage(page.items, offset, page.total) }
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
return GenrePage(emptyList(), offset, 0)
}
}
val items = directItems(
params = mapOf(
"Studios" to trimmed,
"IncludeItemTypes" to embyItemType,
"Recursive" to "true",
"StartIndex" to offset.toString(),
"Limit" to limit.toString(),
"SortBy" to "PremiereDate,SortName",
"SortOrder" to "Descending",
),
fields = "ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
imageTypes = "Backdrop,Primary,Logo",
includeUserData = true,
)
return GenrePage(items, offset, inferredTotal(offset, limit, items.size))
}
/** One stable page of all films, all series, or both. */
suspend fun browseLibrary(
offset: Int = 0,
@@ -632,6 +632,22 @@ data class GatewayItems(
val items: List<BaseItem> = emptyList(),
)
/**
* `GET /v1/search/discover`.
*
* [partial] is why this is not simply a list: "there is nothing else to be had" and "we
* could not ask" are different answers, and only the second is worth a line under the
* results. [skipped] names why nothing was asked at all — the query was too short, or the
* household runs neither *arr — which is diagnostics rather than anything a viewer reads.
*/
@Serializable
data class GatewayDiscovery(
val query: String = "",
val items: List<BaseItem> = emptyList(),
val partial: Boolean = false,
val skipped: String? = null,
)
/**
* One page of `GET /v1/genres/{genre}/items` or `GET /v1/library/items`.
*
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.data.remote
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayDiscovery
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.GatewayDevices
@@ -118,9 +119,19 @@ interface GatewayApi {
@GET("v1/search")
suspend fun search(@Query("q") term: String, @Query("limit") limit: Int): GatewayItems
@Streaming
@GET("v1/search/stream")
suspend fun searchStream(@Query("q") term: String, @Query("limit") limit: Int): ResponseBody
/**
* External discovery: Sonarr and Radarr, and nothing the library can answer.
*
* Deliberately a second request rather than a field on [search]. Those two are
* somebody else's services on somebody else's network, and keeping them apart is the
* whole of the promise that a keystroke never waits on them — see the gateway's
* `search_discover.go`.
*/
@GET("v1/search/discover")
suspend fun searchDiscover(
@Query("q") term: String,
@Query("limit") limit: Int,
): GatewayDiscovery
/**
* One page of a genre. A filter, not a query: the genre is the path rather than a term,
@@ -152,6 +163,19 @@ interface GatewayApi {
@Query("type") itemType: String,
): com.ponzischeme89.memby.data.model.GatewayGenrePage
/**
* One page of a streaming service shortcut on the Genres page — the same shelf as
* [genreItems], filtered on Emby's Studios field instead of Genres. See the gateway's
* `handleServiceItems`.
*/
@GET("v1/services/{service}/items")
suspend fun serviceItems(
@Path("service") service: String,
@Query("offset") offset: Int,
@Query("limit") limit: Int,
@Query("type") itemType: String? = null,
): com.ponzischeme89.memby.data.model.GatewayGenrePage
/**
* One month of the TV calendar. An absent [month] is the household's present month,
* which is what a television asks for when the page opens.
@@ -62,12 +62,21 @@ internal fun MembyPlayButton(
onFocused: () -> Unit,
modifier: Modifier = Modifier,
compact: Boolean = false,
/**
* The mark on the primary action.
*
* A parameter rather than a second button, because the *surface* is the thing that has
* to be identical everywhere — this is the one control on a page that says "this is
* what you came here to press". Only the glyph differs: a page whose primary action is
* Request must not wear a Play triangle, which is a claim about what pressing it does.
*/
icon: MembyIcon = MembyIcon.Play,
) {
var focused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (focused) 1.055f else 1f, tween(100), label = "play-focus")
PrimaryActionSurface(
label = label,
icon = MembyIcon.Play.mark,
icon = icon.mark,
focused = focused,
compact = compact,
modifier = modifier
@@ -337,7 +337,7 @@ private val RadarrPageMargin = 30.dp
private val RadarrOverviewLineHeight = 21.sp
@Composable
private fun RadarrPoster(url: String?, title: String) {
internal fun RadarrPoster(url: String?, title: String) {
val shape = RoundedCornerShape(MembyCardCorner)
Box(
Modifier
@@ -0,0 +1,248 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.layout.Arrangement
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.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
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.tv.material3.Text
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import kotlinx.coroutines.delay
/**
* A title Sonarr or Radarr knows about and the household does not have.
*
* Search now finds these, so pressing one had to lead somewhere. It deliberately leads
* *here* rather than straight to a request: a card in a list is a name and a year, and
* "Disclosure Day, 2027" is not enough to decide by. This is the page where somebody can
* read what it is before asking for it and it is a Memby page, with the same backdrop,
* poster, fact line and button language every other detail page has, rather than a form
* that suddenly reads as an administrative screen belonging to somebody else's software.
*
* It is deliberately *not* the ordinary movie or series page. That layout is built around a
* file that exists Play, a resume bar, watched state, tabs of cast and extras and
* carrying those over a title nobody can watch would be four lies arranged as furniture.
* This is the same judgement [RadarrMovieDetailsOverlay] makes for the schedule row, and the
* two pages are deliberately built from the same pieces.
*
* Stateless: everything it draws is a parameter, so it can be screenshotted with no gateway
* behind it and so the screen that owns the request owns its outcome.
*/
@Composable
fun RequestDetailsOverlay(
card: BaseItem,
/** The *arr's own poster. A parameter, so this page needs no repository to draw. */
posterUrl: String?,
onRequest: (GatewayRequestCandidate) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
/** In flight. The button says so rather than the page going blank. */
requesting: Boolean = false,
/** What came of the last attempt, if anything has been attempted. */
message: String? = null,
messageIsError: Boolean = false,
) {
val candidate = remember(card.id) { card.toRequestCandidate() }
val series = card.isSeries
val requestable = card.membyRequestable && candidate != null
val request = remember(card.id) { FocusRequester() }
val back = remember(card.id) { FocusRequester() }
// Request takes the remote when there is one — it is the only thing anybody opened this
// page to press. Back claims it otherwise, so a title that is already being fetched
// still has somewhere for the focus to be.
LaunchedEffect(card.id, requestable) {
delay(32L)
runCatching { if (requestable) request.requestFocus() else back.requestFocus() }
}
Box(modifier.fillMaxSize()) {
DetailBackdrop(card, Modifier.fillMaxSize())
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = RequestPageGutter, vertical = RequestPageMargin),
verticalAlignment = Alignment.CenterVertically,
) {
RadarrPoster(posterUrl, card.name)
Spacer(Modifier.width(38.dp))
// The actions are measured before the prose, never after it. A Column hands
// each unweighted child only what the ones above it left over, so a button row
// declared last takes whatever a long description did not want — which renders
// as a squeezed sliver rather than as a page that ran out of room. Everything
// that can honestly give way is inside the one weighted child. It is the same
// inversion the home hero and the detail hero make.
Column(Modifier.weight(1f)) {
RequestAvailabilityLine(card)
Spacer(Modifier.height(14.dp))
Text(
text = card.name,
color = Color.White,
fontSize = 36.sp,
lineHeight = 40.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val facts = remember(card.id) {
listOfNotNull(
card.productionYear?.toString(),
if (series) "Series" else "Film",
card.genres.firstOrNull()?.takeIf(String::isNotBlank),
)
}
if (facts.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
Text(
facts.joinToString(FactSeparator),
color = MembyMutedText,
fontSize = 14.sp,
maxLines = 1,
)
}
card.overview?.takeIf(String::isNotBlank)?.let { overview ->
Column(Modifier.weight(1f, fill = false).clipToBounds()) {
Spacer(Modifier.height(16.dp))
Text(
text = overview,
color = MembyMutedText,
fontSize = 15.sp,
lineHeight = RequestOverviewLineHeight,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth(0.86f)
// Prose gives way a whole line at a time. A Text handed
// less room still draws every line it was asked for, so
// the clip sits outside the snap or the dropped lines
// paint over the buttons underneath.
.clipToBounds()
.wholeLines(RequestOverviewLineHeight),
)
}
}
message?.let {
Spacer(Modifier.height(14.dp))
Text(
it,
color = if (messageIsError) MembyMutedText else MembyAccent,
fontSize = 14.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(22.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
// Offered only when the gateway said this title can be asked for. A
// Request button that fails after being pressed is the one outcome this
// page must not produce, and the answer arrived with the card.
if (requestable && candidate != null) {
MembyPlayButton(
label = when {
requesting -> "Requesting…"
series -> "Request series"
else -> "Request movie"
},
onClick = { if (!requesting) onRequest(candidate) },
onFocused = {},
icon = MembyIcon.Add,
modifier = Modifier.focusRequester(request),
)
}
MembySecondaryButton(
label = "Back",
onClick = onClose,
modifier = Modifier.focusRequester(back),
)
}
}
}
}
}
/**
* Why this title is on a page with no Play button.
*
* The wording is the gateway's vocabulary rather than a sentence assembled here, and an
* unrecognised state says nothing at all instead of guessing a page that invents a reason
* is worse than one that simply offers the button.
*/
@Composable
private fun RequestAvailabilityLine(card: BaseItem) {
val label = when (card.membySearchState) {
"requestable" -> "NOT IN YOUR LIBRARY"
"requested", "processing" -> "ALREADY REQUESTED"
"pending" -> "REQUESTED — NOT OUT YET"
else -> return
}
Text(
label,
color = MembyQuietText,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.4.sp,
maxLines = 1,
)
}
/**
* The *arr candidate behind a search card.
*
* Keyed on the provider id rather than on the card's own id, because that id is Memby's
* (`radarr:12345`) and the request route speaks TMDb and TVDb. A card carrying neither
* cannot be requested, which is what withdraws the button rather than producing one that
* fails.
*/
internal fun BaseItem.toRequestCandidate(): GatewayRequestCandidate? {
val mediaType = when (membySource) {
"sonarr" -> "series"
"radarr" -> "movie"
else -> return null
}
val foreignId = when (mediaType) {
"series" -> providerIds["Tvdb"]
else -> providerIds["Tmdb"]
}?.toIntOrNull() ?: return null
return GatewayRequestCandidate(
mediaType = mediaType,
foreignId = foreignId,
title = name,
year = productionYear ?: 0,
overview = overview.orEmpty(),
posterUrl = membyPosterUrl.orEmpty(),
)
}
private val RequestPageGutter = DetailSideGutter
private val RequestPageMargin = 30.dp
/** The description's line box, shared by the text style and by [wholeLines]. */
private val RequestOverviewLineHeight = 21.sp
@@ -171,6 +171,13 @@ fun GenreBrowseScreen(
genreCategoryTabs(itemType).associate { it.id to FocusRequester() }
}
// Same shape, one per service icon. The Services strip is not itemType-specific, so
// this is stable for the life of the screen rather than keyed to it.
val serviceFocusRequesters = remember { serviceCategoryTabs().associate { it.id to FocusRequester() } }
/** Every requester the remote can land on across both strips, for a lookup by id. */
val allEntryFocusRequesters = railFocusRequesters + serviceFocusRequesters
// 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.
val gridStates = remember(itemType) { mutableMapOf<String, LazyGridState>() }
@@ -184,8 +191,9 @@ fun GenreBrowseScreen(
var gridEntryIndex by remember(itemType) { mutableStateOf(0) }
val shownCategoryId = state.selectedCategoryId
val selectedCategory = remember(shownCategoryId, itemType) {
genreCategory(itemType, shownCategoryId)
val selectedCategory = remember(shownCategoryId, itemType, state.services) {
(state.categories + state.services).firstOrNull { it.id == shownCategoryId }
?: genreCategory(itemType, shownCategoryId)
}
val gridState = gridStates.getOrPut(shownCategoryId ?: initialCategoryId) { LazyGridState() }
@@ -211,7 +219,7 @@ fun GenreBrowseScreen(
* 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 }
allEntryFocusRequesters[activeCategoryId]?.let { runCatching { it.requestFocus() }.isSuccess }
?: runCatching { contentFocusRequester.requestFocus() }.isSuccess
LaunchedEffect(Unit) {
@@ -270,6 +278,29 @@ fun GenreBrowseScreen(
activeCategoryId = id
},
onEnterContent = ::enterGrid,
// Up out of the top genre only reaches the strip above it when there is
// one to reach — a screenshot test renders the rail with no services at
// all, and Up from there must still stand down rather than throw.
servicesFocusRequester = serviceFocusRequesters[state.services.firstOrNull()?.id],
topContent = state.services.takeIf { it.isNotEmpty() }?.let { services ->
{
ServicesRail(
services = services,
activeServiceId = activeCategoryId,
selectedServiceId = shownCategoryId,
navigationFocusRequester = navigationFocusRequester,
activeFocusRequester = contentFocusRequester,
itemFocusRequesters = serviceFocusRequesters,
onServiceFocused = { id ->
onContentFocused()
activeCategoryId = id
},
// Down out of the strip lands on the top genre, in whatever
// order personalisation put it in — never a fixed one.
downFocusRequester = railFocusRequesters[state.categories.firstOrNull()?.id],
)
}
},
)
BoxWithConstraints(
Modifier
@@ -484,6 +515,14 @@ internal fun GenreRail(
* capture that could only ever photograph the first would prove nothing.
*/
focusForCapture: String? = null,
/**
* Where Up out of the top genre lands the first Services icon, when there is a
* Services strip to reach. Null (a screenshot test, or a household with no service
* shortcuts configured) keeps the rail's original behaviour: Up is cancelled there.
*/
servicesFocusRequester: FocusRequester? = null,
/** The Services strip, rendered inside this rail's own column, background and hairline. */
topContent: (@Composable () -> Unit)? = null,
) {
val railState = rememberLazyListState()
// Only on arrival: once the viewer is in the rail, the lazy list scrolls itself as
@@ -530,6 +569,7 @@ internal fun GenreRail(
if (event.key == Key.DirectionRight) onEnterContent() else false
},
) {
topContent?.invoke()
Text(
"GENRES",
color = MembyQuietText,
@@ -572,7 +612,7 @@ internal fun GenreRail(
// item of the launcher's rail happens to be beside it.
left = navigationFocusRequester
up = if (index == 0) {
FocusRequester.Cancel
servicesFocusRequester ?: FocusRequester.Cancel
} else {
requesterFor(categories[index - 1].id)
}
@@ -693,6 +733,208 @@ private fun GenreRailItem(
}
}
/** Diameter of a service shortcut — the "small-button range" the design calls for. */
private val ServiceIconDiameter = 52.dp
/**
* The Services strip: a small label plus a horizontal row of circular shortcuts, sitting
* above the Genres rail with a gap wide enough to read as related but separate.
*
* It is a *filter*, not a destination selecting a service runs the same paged shelf the
* genre rail already draws, filtered on Emby's Studios field instead of Genres, and the
* viewer never leaves this page. See [BrowseFilterKind] and `server/internal/api/genres.go`.
*/
@Composable
internal fun ServicesRail(
services: List<GenreCategory>,
activeServiceId: String,
navigationFocusRequester: FocusRequester,
activeFocusRequester: FocusRequester,
onServiceFocused: (String) -> Unit,
/**
* One requester per service, owned by the screen for the same reason the genre rail's
* are: every way back into this strip names *which* icon it is returning to.
*/
itemFocusRequesters: Map<String, FocusRequester> = emptyMap(),
/** Where Down out of the strip lands — the top genre, in this viewer's own order. */
downFocusRequester: FocusRequester? = null,
/** The service whose shelf the grid is currently showing, drawn as the quiet wash. */
selectedServiceId: String? = null,
/** The [GenreRailItem] precedent: lets a screenshot render one icon as though focused. */
focusForCapture: String? = null,
) {
val ownedFocusRequesters = remember(services.map { it.id }) {
services.associate { it.id to FocusRequester() }
}
val requesterFor: (String) -> FocusRequester = { id ->
itemFocusRequesters[id] ?: ownedFocusRequesters.getValue(id)
}
Column(Modifier.fillMaxWidth()) {
Text(
"SERVICES",
color = MembyQuietText,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.6.sp,
modifier = Modifier.padding(start = 22.dp, top = 22.dp, bottom = 14.dp),
)
Row(
Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 14.dp)
.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
services.forEachIndexed { index, service ->
val requester = requesterFor(service.id)
ServiceIconButton(
service = service,
active = service.id == selectedServiceId,
focusedForCapture = service.id == focusForCapture,
onFocused = { onServiceFocused(service.id) },
onClick = { onServiceFocused(service.id) },
modifier = Modifier
.focusRequester(requester)
.then(
if (service.id == activeServiceId) {
Modifier.focusRequester(activeFocusRequester)
} else {
Modifier
},
)
.focusProperties {
// Explicit, the genre rail's rule: vertical travel must never
// leave the strip by accident, and Left/Right walk the row.
up = FocusRequester.Cancel
down = downFocusRequester ?: FocusRequester.Default
left = if (index == 0) {
navigationFocusRequester
} else {
requesterFor(services[index - 1].id)
}
right = if (index == services.lastIndex) {
FocusRequester.Cancel
} else {
requesterFor(services[index + 1].id)
}
},
)
}
}
// The gap that reads as "related but separate" from the genre rail beneath it.
Spacer(Modifier.height(18.dp))
Box(
Modifier
.padding(horizontal = 22.dp)
.fillMaxWidth()
.height(1.dp)
.background(Color.White.copy(alpha = 0.06f)),
)
}
}
/**
* One circular service shortcut.
*
* Focus and selection are marked separately, the [GenreRailItem] stance: the icon under
* the remote is a bright white ring, brighter than anything else on the strip; the
* service whose shelf is on screen keeps a quieter ring once focus moves on. Neither
* state touches the tint inside the circle, which is the only place a service's own
* identity shows the ring stays unmistakably Memby regardless of which brand it sits on.
*/
@Composable
private fun ServiceIconButton(
service: GenreCategory,
active: Boolean,
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
focusedForCapture: Boolean = false,
) {
FocusScaleContainer(
onFocused = onFocused,
onClick = onClick,
contentDescription = "Browse ${service.label}",
modifier = modifier.size(ServiceIconDiameter),
) { hasFocus ->
val focused = hasFocus || focusedForCapture
val emphasis = remember { Animatable(if (focused) 1f else 0f) }
LaunchedEffect(focused) {
emphasis.animateTo(targetValue = if (focused) 1f else 0f, animationSpec = tween(140))
}
val tint = serviceTint(service.icon)
Box(
Modifier
.fillMaxSize()
.drawBehind {
val radius = size.minDimension / 2f
val centre = androidx.compose.ui.geometry.Offset(size.width / 2f, size.height / 2f)
drawCircle(color = tint, radius = radius, center = centre)
drawCircle(
color = Color.Black.copy(alpha = 0.22f),
radius = radius,
center = centre,
)
// The ring is what says "Memby", never the brand tint underneath it —
// white always, brighter under focus than while merely active.
val ringAlpha = when {
focused -> 1f
active -> 0.5f
else -> 0f
}
if (ringAlpha > 0f) {
drawCircle(
color = Color.White.copy(alpha = ringAlpha),
radius = radius - (1.5f + 1.5f * emphasis.value).dp.toPx() / 2f,
center = centre,
style = androidx.compose.ui.graphics.drawscope.Stroke(
width = (2.dp + 1.dp * emphasis.value).toPx(),
),
)
}
},
contentAlignment = Alignment.Center,
) {
when (service.icon) {
GenreCategoryIcon.ALL_SERVICES -> Icon(
MembyIcon.Studio.mark,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(22.dp),
)
else -> Text(
serviceMark(service.icon),
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.Black,
maxLines = 1,
overflow = TextOverflow.Clip,
)
}
}
}
}
/**
* A quiet, brand-adjacent tint behind each mark enough to tell the shortcuts apart at a
* glance, deliberately not an attempt at exact brand colour. The ring above it, not this,
* is what carries the focus treatment.
*/
private fun serviceTint(icon: GenreCategoryIcon): Color = when (icon) {
GenreCategoryIcon.APPLE_TV -> Color(0xFF2B2B2E)
GenreCategoryIcon.NETFLIX -> Color(0xFF7A1420)
GenreCategoryIcon.DISNEY_PLUS -> Color(0xFF0E3F73)
else -> MembySurfaceRaised
}
/** The short mark drawn inside a service's circle. */
private fun serviceMark(icon: GenreCategoryIcon): String = when (icon) {
GenreCategoryIcon.APPLE_TV -> "tv+"
GenreCategoryIcon.NETFLIX -> "N"
GenreCategoryIcon.DISNEY_PLUS -> "D+"
else -> "?"
}
@Composable
private fun GenrePlaceholderGrid(
columns: Int,
@@ -19,6 +19,8 @@ private const val MAX_CACHED_CATEGORIES = 15
data class GenreBrowseUiState(
val categories: List<GenreCategory> = emptyList(),
/** The Services strip above [categories] — see [serviceCategoryTabs]. */
val services: List<GenreCategory> = emptyList(),
val selectedCategoryId: String? = null,
val items: List<BaseItem> = emptyList(),
/**
@@ -57,7 +59,14 @@ class GenreBrowseViewModel(
genreCategoryTabs(itemType),
repository.genreAffinitySnapshot(),
)
private val _state = MutableStateFlow(GenreBrowseUiState(categories = categories))
/** The Services strip. Not itemType-specific and not personalised — see [serviceCategoryTabs]. */
private val services = serviceCategoryTabs()
/** Both strips, for resolving a selection: an id may name either a genre or a service. */
private val selectable = categories + services
private val _state = MutableStateFlow(GenreBrowseUiState(categories = categories, services = services))
val state: StateFlow<GenreBrowseUiState> = _state.asStateFlow()
private var pageJob: Job? = null
@@ -81,7 +90,7 @@ class GenreBrowseViewModel(
private val warmJobs = mutableMapOf<String, Job>()
fun selectCategory(categoryId: String) {
val selected = categories.firstOrNull { it.id == categoryId } ?: categories.first()
val selected = selectable.firstOrNull { it.id == categoryId } ?: categories.first()
val current = state.value
if (current.selectedCategoryId == selected.id && (current.items.isNotEmpty() || current.isLoading)) return
val cached = categoryPages[selected.id]
@@ -130,7 +139,7 @@ class GenreBrowseViewModel(
fun loadMore() {
val current = state.value
val categoryId = current.selectedCategoryId ?: return
val category = categories.firstOrNull { it.id == categoryId } ?: return
val category = selectable.firstOrNull { it.id == categoryId } ?: return
if (!current.canLoadMore || current.isLoading || current.isLoadingMore) return
// Direct cache query prevents offset races caused by rapid concurrent state updates.
@@ -143,7 +152,7 @@ class GenreBrowseViewModel(
fun retry() {
val current = state.value
val categoryId = current.selectedCategoryId ?: return
val category = categories.firstOrNull { it.id == categoryId } ?: return
val category = selectable.firstOrNull { it.id == categoryId } ?: return
val offset = categoryPages[categoryId]?.readOffset ?: current.readOffset
_state.update {
it.copy(
@@ -202,15 +211,23 @@ class GenreBrowseViewModel(
private suspend fun loadPage(category: GenreCategory, offset: Int) {
runCatching {
if (category.genres.isEmpty()) {
repository.browseLibrary(offset = offset, limit = GENRE_PAGE_SIZE, itemType = itemType)
} else {
repository.browseGenre(
genre = category.filter,
offset = offset,
limit = GENRE_PAGE_SIZE,
itemType = itemType,
)
when {
category.genres.isEmpty() ->
repository.browseLibrary(offset = offset, limit = GENRE_PAGE_SIZE, itemType = itemType)
category.kind == BrowseFilterKind.STUDIO ->
repository.browseService(
service = category.filter,
offset = offset,
limit = GENRE_PAGE_SIZE,
itemType = itemType,
)
else ->
repository.browseGenre(
genre = category.filter,
offset = offset,
limit = GENRE_PAGE_SIZE,
itemType = itemType,
)
}
}.onSuccess { page ->
val read = page.offset + page.items.size
@@ -13,11 +13,22 @@ import com.ponzischeme89.memby.data.model.UserItemData
* filter. The order is product design, not server data, and therefore never jumps around
* while home rows are arriving.
*/
/**
* Which Emby field a category's [GenreCategory.genres] list is matched against.
*
* [GENRE] is the ordinary rail; [STUDIO] is a Services shortcut (Apple TV+, Netflix,
* Disney+, ). The two are the same shelf shape a page of items filtered on an OR of
* Emby spellings so one data class and one paging pipeline serve both, and only the
* Emby query parameter differs (`Genres` vs `Studios`; see `server/internal/api/genres.go`).
*/
enum class BrowseFilterKind { GENRE, STUDIO }
data class GenreCategory(
val id: String,
val label: String,
val genres: List<String>,
val icon: GenreCategoryIcon,
val kind: BrowseFilterKind = BrowseFilterKind.GENRE,
) {
val filter: String get() = genres.joinToString("|")
}
@@ -40,6 +51,10 @@ enum class GenreCategoryIcon {
MUSIC,
SPORT,
REALITY,
ALL_SERVICES,
APPLE_TV,
NETFLIX,
DISNEY_PLUS,
}
const val ALL_MEDIA_CATEGORY_ID = "all"
@@ -148,6 +163,59 @@ private val realityCategory = GenreCategory(
GenreCategoryIcon.REALITY,
)
const val ALL_SERVICES_CATEGORY_ID = "service-all"
/**
* The streaming-service shortcut strip above the Genres rail.
*
* Product design, not server data the [GenreCategory] precedent, and for the same
* reason: the order must never jump around while a page is loading, and the list an
* operator sees in the Genres page has to be one somebody chose rather than whatever
* Emby's `Studios` field happens to contain today (which is every production company a
* metadata agent ever wrote, most of them meaningless to a viewer). A service with no
* matching titles is not hidden the empty-shelf message the genre rail already shows
* for a genre with nothing in it is the honest answer here too, and hiding it would need
* a request per service before the strip could even be drawn.
*
* Extending this list is the whole of "configurable, ordered, extended in future": add an
* entry with the service's own name and the Emby `Studios` spellings a real library
* carries for it (Prime Video, Max, Neon, the BBC), in the position it should appear.
*/
private val coreServiceCategories = listOf(
GenreCategory(
"service-apple-tv-plus",
"Apple TV+",
listOf("Apple TV+", "Apple TV Plus", "AppleTV+"),
GenreCategoryIcon.APPLE_TV,
BrowseFilterKind.STUDIO,
),
GenreCategory(
"service-netflix",
"Netflix",
listOf("Netflix"),
GenreCategoryIcon.NETFLIX,
BrowseFilterKind.STUDIO,
),
GenreCategory(
"service-disney-plus",
"Disney+",
listOf("Disney+", "Disney Plus", "DisneyPlus"),
GenreCategoryIcon.DISNEY_PLUS,
BrowseFilterKind.STUDIO,
),
)
private val allServicesCategory = GenreCategory(
id = ALL_SERVICES_CATEGORY_ID,
label = "All Services",
genres = emptyList(),
icon = GenreCategoryIcon.ALL_SERVICES,
kind = BrowseFilterKind.STUDIO,
)
/** The Services strip, with the "All Services"/cleared entry first — the genre rail's shape. */
fun serviceCategoryTabs(): List<GenreCategory> = listOf(allServicesCategory) + coreServiceCategories
fun genreCategories(itemType: String): List<GenreCategory> =
if (itemType.equals("Movie", ignoreCase = true)) coreGenreCategories
// Reality is a television shelf, so it is offered wherever series can appear.
@@ -86,6 +86,7 @@ import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.MembyChoiceChip
import com.ponzischeme89.memby.ui.RequestDetailsOverlay
import com.ponzischeme89.memby.ui.search.components.SearchGenres
import com.ponzischeme89.memby.ui.search.components.SearchResults
import com.ponzischeme89.memby.ui.theme.MembyAccent
@@ -189,8 +190,8 @@ fun SearchScreen(
val hasResultsTarget = when {
state.errorMessage != null && state.results.isEmpty() -> true
state.isDiscovery -> state.genreSuggestions.isNotEmpty()
else -> state.results.any { it.isMovie || it.isSeries } || state.requestCandidates.isNotEmpty() ||
(state.requestsAvailable && shouldSearch(state.query))
else -> state.results.any { it.isMovie || it.isSeries } || state.discovery.isNotEmpty() ||
state.discoveryLoading
}
LaunchedEffect(voiceAvailable) { runCatching { contentFocusRequester.requestFocus() } }
@@ -223,9 +224,15 @@ fun SearchScreen(
viewModel.clearGenre()
}
// A title nothing in the library answers for, opened to be read before it is asked
// for. It is held here rather than by the launcher because the request itself belongs
// to this screen's view model — and because Back out of it is one more level of this
// screen, not a step out of it.
var requestDetailsItem by remember { mutableStateOf<BaseItem?>(null) }
BackHandler {
when {
state.requestMode -> viewModel.hideRequests()
requestDetailsItem != null -> requestDetailsItem = null
// A genre is its own level, between discovery and leaving Search. Close that
// level first regardless of which card currently owns focus.
state.genre != null -> closeGenre()
@@ -238,8 +245,9 @@ fun SearchScreen(
}
}
Box(modifier.fillMaxSize()) {
Row(
modifier = modifier
modifier = Modifier
.fillMaxSize()
// A USB keyboard, or a phone remote app sending key events, feeds exactly the
// same query state as the on-screen keys. Only printable characters and
@@ -274,7 +282,10 @@ fun SearchScreen(
onCharacter = viewModel::appendToQuery,
onBackspace = viewModel::backspace,
onClear = viewModel::clearQuery,
onVoiceResult = viewModel::onQueryChanged,
// A completed transcription is a submitted query, not a keystroke: the
// debounce exists to guess at somebody having finished, and here they have.
onVoiceResult = viewModel::onVoiceResult,
onSubmit = viewModel::submitQuery,
voiceAvailable = voiceAvailable,
voiceFocusRequester = contentFocusRequester,
modifier = Modifier
@@ -294,7 +305,7 @@ fun SearchScreen(
resultsFocusRequester = searchResultsEntry,
resultsHaveFocusTarget = state.results.any { it.isMovie || it.isSeries } ||
(state.errorMessage != null && state.results.isEmpty()) ||
(state.requestsAvailable && shouldSearch(state.query)),
state.discovery.isNotEmpty(),
onFocused = {
focusInResults = true
onContentFocused()
@@ -303,42 +314,52 @@ fun SearchScreen(
)
Spacer(Modifier.height(14.dp))
if (state.requestMode) {
RequestOptions(
state = state,
resultsEntry = searchResultsEntry,
keyboardReturn = keyboardReturn,
onRequest = viewModel::request,
modifier = Modifier.weight(1f),
)
} else {
SearchResults(
state = state,
entryFocusRequester = searchResultsEntry,
genresFocusRequester = genresEntry,
genresPresent = state.genreSuggestions.isNotEmpty(),
keyboardReturnFocusRequester = keyboardReturn,
returnFocusItemId = returnFocusItemId,
returnFocusRequester = returnFocusRequester,
artworkUrlFor = { item ->
repository.primaryUrl(item, 500) ?: repository.backdropUrl(item, 500)
},
onItemFocused = { item ->
focusInResults = true
onContentFocused()
onItemFocused(item)
},
onItemSelected = onItemSelected,
onRequest = viewModel::request,
onRetry = viewModel::retry,
onShowRequests = viewModel::showRequests,
onLoadMore = viewModel::loadMore,
voiceAvailable = voiceAvailable,
modifier = Modifier.weight(1f),
)
}
SearchResults(
state = state,
entryFocusRequester = searchResultsEntry,
genresFocusRequester = genresEntry,
genresPresent = state.genreSuggestions.isNotEmpty(),
keyboardReturnFocusRequester = keyboardReturn,
returnFocusItemId = returnFocusItemId,
returnFocusRequester = returnFocusRequester,
artworkUrlFor = { item ->
repository.primaryUrl(item, 500) ?: repository.backdropUrl(item, 500)
},
onItemFocused = { item ->
focusInResults = true
onContentFocused()
onItemFocused(item)
},
onItemSelected = { item ->
// A library result opens the ordinary Memby page, exactly as it did
// before there was a second section. Only a title the household does
// not have takes the other route, and it opens a page rather than
// sending a request: a name and a year is not enough to decide by.
if (item.membyPlayable || item.membySource == null) {
onItemSelected(item)
} else {
requestDetailsItem = item
}
},
onRetry = viewModel::retry,
onLoadMore = viewModel::loadMore,
voiceAvailable = voiceAvailable,
modifier = Modifier.weight(1f),
)
}
}
requestDetailsItem?.let { subject ->
RequestDetailsOverlay(
card = subject,
posterUrl = repository.primaryUrl(subject, 500),
onRequest = viewModel::request,
onClose = { requestDetailsItem = null },
requesting = state.requestingCandidateKey != null,
message = state.requestMessage,
messageIsError = state.requestMessageIsError,
)
}
}
}
@Composable
@@ -355,6 +376,7 @@ private fun SearchPane(
onBackspace: () -> Unit,
onClear: () -> Unit,
onVoiceResult: (String) -> Unit,
onSubmit: () -> Unit,
voiceAvailable: Boolean,
voiceFocusRequester: FocusRequester,
modifier: Modifier = Modifier,
@@ -393,6 +415,11 @@ private fun SearchPane(
onBackspace = onBackspace,
onClear = onClear,
upTarget = if (voiceAvailable) voiceFocusRequester else null,
// The library is searched as you type, so this key is not what produces the
// results — it is how somebody says they have finished typing, which releases
// external discovery from its debounce a good half-second early.
onSearch = onSubmit,
searchEnabled = shouldSearch(state.query),
)
}
}
@@ -575,10 +602,10 @@ internal fun TvKeyboard(
/**
* What the Search key does, and whether there is one at all.
*
* Null means there is none, which is the Search tab: it queries a local index as you
* type, and a submit key there would be a control that does nothing the page has not
* already done. Requests passes one because its lookup is a live Radarr and Sonarr
* query see [RequestsViewModel][com.ponzischeme89.memby.ui.requests.RequestsViewModel].
* Null means there is none. Both tabs that draw this keyboard pass one, and on Search
* it does not produce the library results those are already on screen but releases
* external discovery from its debounce, which is the one thing a timer can only guess
* at. Requests passes one because its whole lookup is a live Radarr and Sonarr query.
*/
onSearch: (() -> Unit)? = null,
/** Whether enough has been typed for that key to do anything. */
@@ -841,274 +868,6 @@ private fun LoadingDot() {
)
}
@Composable
private fun RequestOptions(
state: SearchUiState,
resultsEntry: FocusRequester,
keyboardReturn: FocusRequester,
onRequest: (GatewayRequestCandidate) -> Unit,
modifier: Modifier = Modifier,
previewArtwork: ImageBitmap? = null,
) {
Column(
modifier = modifier
.fillMaxSize()
.clip(RoundedCornerShape(16.dp))
.background(MembySurfaceRaised)
.border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(16.dp))
.padding(20.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier
.size(42.dp)
.clip(RoundedCornerShape(11.dp))
.background(Accent.copy(alpha = 0.16f)),
contentAlignment = Alignment.Center,
) {
Icon(MembyIcon.Add.mark, contentDescription = null, tint = Accent, modifier = Modifier.size(24.dp))
}
Spacer(Modifier.width(13.dp))
Column(Modifier.weight(1f)) {
Text("Request something new", color = Heading, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Text(
"Search for movies and series, then choose the exact title you want added.",
color = Muted,
fontSize = 13.sp,
maxLines = 2,
)
}
Text(
"${state.requestCandidates.take(6).size} MATCH${if (state.requestCandidates.size == 1) "" else "ES"}",
color = Accent,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.height(16.dp))
if (state.requestCandidates.isNotEmpty()) LazyVerticalGrid(
columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f),
) {
itemsIndexed(
state.requestCandidates.take(6),
key = { _, candidate -> "${candidate.mediaType}:${candidate.foreignId}" },
) { index, candidate ->
RequestCandidateCard(
candidate = candidate,
busy = state.requestingCandidateKey == "${candidate.mediaType}:${candidate.foreignId}",
onRequest = { onRequest(candidate) },
previewArtwork = previewArtwork,
modifier = Modifier
.then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier)
.focusProperties { if (index % 2 == 0) left = keyboardReturn },
)
}
}
else {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
if (state.requestLookupLoading) "Finding movies and series…" else "No request matches found.",
color = Muted,
fontSize = 14.sp,
)
}
}
state.requestMessage?.let { message ->
val statusColor = if (state.requestMessageIsError) Color(0xFFFF8A80) else Accent
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(statusColor.copy(alpha = 0.12f))
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (state.requestMessageIsError) MembyIcon.Close.mark else MembyIcon.CheckCircle.mark,
contentDescription = null,
tint = statusColor,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(9.dp))
Text(message, color = KeyLabel, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
}
}
}
@Composable
private fun RequestCandidateCard(
candidate: GatewayRequestCandidate,
busy: Boolean,
onRequest: () -> Unit,
modifier: Modifier = Modifier,
previewArtwork: ImageBitmap? = null,
) {
val mediaLabel = if (candidate.mediaType.equals("movie", ignoreCase = true)) "MOVIE" else "TV SERIES"
val actionLabel = when {
candidate.inLibrary -> "IN LIBRARY"
candidate.alreadyAdded -> "REQUESTED"
busy -> "REQUESTING…"
else -> "REQUEST"
}
FocusScaleContainer(
onFocused = {},
onClick = { if (!candidate.inLibrary && !candidate.alreadyAdded && !busy) onRequest() },
contentDescription = "${candidate.title}, $mediaLabel, $actionLabel",
modifier = modifier,
) { focused ->
Row(
modifier = Modifier
.fillMaxWidth()
.height(140.dp)
.clip(RoundedCornerShape(12.dp))
.background(
when {
focused -> MembyAccentMuted
candidate.inLibrary || candidate.alreadyAdded -> MembyAccentMuted
else -> MembyControlSurface
},
)
.border(
width = if (focused) 2.dp else 1.dp,
color = when {
focused -> Accent
candidate.inLibrary || candidate.alreadyAdded -> Accent.copy(alpha = 0.45f)
else -> Color.White.copy(alpha = 0.10f)
},
shape = RoundedCornerShape(12.dp),
)
.padding(9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.width(76.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(8.dp))
.background(MembySurface),
contentAlignment = Alignment.Center,
) {
if (previewArtwork != null) {
Image(
bitmap = previewArtwork,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else if (candidate.posterUrl.isNotBlank()) {
AsyncImage(
model = candidate.posterUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Icon(
if (mediaLabel == "MOVIE") MembyIcon.Movie.mark else MembyIcon.LiveTv.mark,
contentDescription = null,
tint = Muted.copy(alpha = 0.65f),
modifier = Modifier.size(30.dp),
)
}
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(mediaLabel, color = Accent, fontSize = 10.sp, fontWeight = FontWeight.Bold)
candidate.year.takeIf { it > 0 }?.let { year ->
Text("$year", color = Muted, fontSize = 11.sp)
}
}
Text(
candidate.title,
color = Heading,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (candidate.overview.isNotBlank()) {
Text(
candidate.overview,
color = Muted,
fontSize = 11.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.weight(1f))
Row(verticalAlignment = Alignment.CenterVertically) {
if (candidate.inLibrary || candidate.alreadyAdded) {
Icon(MembyIcon.CheckCircle.mark, contentDescription = null, tint = Accent, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(5.dp))
}
Text(
actionLabel,
color = if (candidate.inLibrary || candidate.alreadyAdded || focused) Accent else KeyLabel,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
)
if (!candidate.inLibrary && !candidate.alreadyAdded && !busy) {
Text("", color = Accent, fontSize = 13.sp)
}
}
}
}
}
}
/** Stable, data-free fixture used by the screenshot test and design reviews. */
@Composable
internal fun RecommendationRequestPreview(previewArtwork: ImageBitmap? = null) {
val firstCardFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { firstCardFocus.requestFocus() } }
Box(
Modifier
.fillMaxSize()
.background(MembySurface)
.padding(28.dp),
) {
RequestOptions(
state = SearchUiState(
query = "the last voyage",
hasSearched = true,
requestCandidates = listOf(
GatewayRequestCandidate(
mediaType = "movie", foreignId = 101, title = "The Last Voyage",
year = 2026,
overview = "A lone cartographer follows a signal beyond the edge of every known map.",
),
GatewayRequestCandidate(
mediaType = "series", foreignId = 102, title = "Voyagers",
year = 2024,
overview = "Six strangers wake aboard a ship already halfway to another world.",
),
GatewayRequestCandidate(
mediaType = "movie", foreignId = 103, title = "The Long Way Home",
year = 2022,
overview = "A rescue pilot gets one final chance to cross the storm.",
),
GatewayRequestCandidate(
mediaType = "series", foreignId = 104, title = "Beyond the Horizon",
year = 2025,
overview = "An observatory discovers that tomorrow is broadcasting back.",
alreadyAdded = true,
),
),
),
resultsEntry = firstCardFocus,
keyboardReturn = remember { FocusRequester() },
onRequest = {},
previewArtwork = previewArtwork,
)
}
}
private const val KEYBOARD_COLUMNS = 6
private const val ACTION_ROW_INDEX = 36
@@ -0,0 +1,66 @@
package com.ponzischeme89.memby.ui.search
import android.util.Log
import com.ponzischeme89.memby.BuildConfig
/**
* Where a search's time went.
*
* "Search feels slow" is no more actionable than "playback feels slow" was, and this is the
* same answer `PlaybackTrace` gives: a line per stage, carrying the numbers that separate
* the four things that could be responsible. `local=` is Memby and Emby, `discovery=` is
* Sonarr and Radarr, `cache=hit` is neither, and a discovery line that never appears at all
* says the debounce and the floor did their job.
*
* Debug-only, like `PerformanceMonitor` and `StartupTrace`: a television has no log anybody
* reads, so in production these would be pure cost. Every entry point returns before
* formatting anything on a release build.
*
* Read it with `adb logcat -s MembySearch`.
*/
internal object SearchTrace {
private const val TAG = "MembySearch"
fun local(searchId: Long, query: String, results: Int, cacheHit: Boolean, elapsedMs: Long) {
write("event=local id=$searchId ${term(query)} results=$results ${cache(cacheHit)} ms=$elapsedMs")
}
fun discovery(
searchId: Long,
query: String,
results: Int,
cacheHit: Boolean,
partial: Boolean,
elapsedMs: Long,
) {
write(
"event=discovery id=$searchId ${term(query)} results=$results ${cache(cacheHit)} " +
"partial=$partial ms=$elapsedMs",
)
}
/**
* The most useful line here, and the one that is a *decision* rather than a
* measurement: an external lookup that never happened is the feature working, and
* without a record of why it is indistinguishable from one that silently failed.
*/
fun discoverySkipped(searchId: Long, query: String, reason: String) {
write("event=discovery_skipped id=$searchId ${term(query)} reason=$reason")
}
fun discoveryFailed(searchId: Long, query: String, error: Throwable) {
write("event=discovery_failed id=$searchId ${term(query)} error=${error.javaClass.simpleName}")
}
private fun term(query: String) = "q=${query.length}c"
private fun cache(hit: Boolean) = if (hit) "cache=hit" else "cache=miss"
private fun write(line: String) {
if (!BuildConfig.DEBUG) return
// A trace must never be a reason something fails, and this file is also reached
// from plain JUnit tests where android.util.Log is not mocked.
runCatching { Log.d(TAG, line) }
}
}
@@ -3,16 +3,21 @@ package com.ponzischeme89.memby.ui.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.ponzischeme89.memby.data.DISCOVERY_LIMIT
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.GENRE_PAGE_SIZE
import com.ponzischeme89.memby.data.discoveryStillRelevant
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.hasMoreGenreItems
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.data.normaliseDiscoveryQuery
import com.ponzischeme89.memby.data.shouldDiscover
import com.ponzischeme89.memby.ui.distinctItems
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -20,12 +25,17 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.collect
data class SearchUiState(
val query: String = "",
/**
* What the household already has. This section is the whole of the promise the search
* screen makes: it comes from Emby and Memby's own index, and nothing about external
* discovery is allowed to delay it, reorder it or take a card out of it.
*/
val results: List<BaseItem> = emptyList(),
/**
* The genre being browsed, or null when this pane is showing a search.
@@ -62,17 +72,41 @@ data class SearchUiState(
val hasSearched: Boolean = false,
val errorMessage: String? = null,
val pagingErrorMessage: String? = null,
val requestCandidates: List<GatewayRequestCandidate> = emptyList(),
/**
* Titles Sonarr and Radarr know about that the library does not the second section,
* which arrives whenever it arrives.
*
* Kept apart from [results] rather than merged into them, because that separation is
* what makes every promise below it true: the library list cannot be reordered by a
* late answer, nothing already on screen moves when these arrive, and the section is
* simply appended underneath whatever the viewer is already looking at.
*/
val discovery: List<BaseItem> = emptyList(),
/** The query [discovery] answers, which may be a keystroke or two behind [query]. */
val discoveryQuery: String = "",
/** An external lookup is in flight. Never blocks anything; worth one quiet line. */
val discoveryLoading: Boolean = false,
/**
* Sonarr or Radarr could not be asked. Deliberately distinct from an empty [discovery]:
* "there is nothing else to be had" is an answer and this is not, and neither is ever
* allowed to look like a failure of search itself.
*/
val discoveryUnavailable: Boolean = false,
/**
* The search this state belongs to. Bumped for every distinct query, so an answer can
* be checked against the question rather than against the order it happened to arrive
* in and so the diagnostics for one search can be read as one search.
*/
val searchId: Long = 0,
val requestsAvailable: Boolean = false,
val requestMode: Boolean = false,
val requestLookupLoading: Boolean = false,
val requestingCandidateKey: String? = null,
val requestMessage: String? = null,
val requestMessageIsError: Boolean = false,
) {
/** The query is long enough to search but nothing came back. */
/** The query is long enough to search but nothing came back, from either source. */
val isEmptyResult: Boolean
get() = hasSearched && !isLoading && errorMessage == null && results.isEmpty()
get() = hasSearched && !isLoading && !discoveryLoading && errorMessage == null &&
results.isEmpty() && discovery.isEmpty()
/** Nothing typed and no genre open: the pane shows discovery rather than results. */
val isDiscovery: Boolean
@@ -80,12 +114,20 @@ data class SearchUiState(
}
/**
* Instant search.
* Search, in two pipelines that never wait for each other.
*
* Typing feeds [onQueryChanged]; a debounce, a `distinctUntilChanged` and a
* `collectLatest` do the rest. The last of those is what makes rapid typing safe: it
* cancels the in-flight request when a newer query arrives, so a slow response for "bre"
* can never overwrite the results for "break".
* **The library is the fast one.** Typing feeds [onQueryChanged]; a short debounce, a
* `distinctUntilChanged` and a `collectLatest` do the rest. The last of those is what makes
* rapid typing safe: it cancels the in-flight request when a newer query arrives, so a slow
* response for "bre" can never overwrite the results for "break".
*
* **External discovery is the slow one, and it is deliberately hard to trigger.** Sonarr
* and Radarr are somebody else's services, so the letters on the way to a word are never
* sent: a longer debounce, a three-character floor, a normalised `distinctUntilChanged`
* (so "Disclosure" after "disclosure " is not a second lookup) and a session cache stand
* between typing and a request, with the gateway's own ten-minute cooldown behind all of
* them. Pressing Search, or finishing a voice query, skips the debounce those are
* somebody saying they have finished typing, which is the one thing a timer is guessing at.
*/
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
@@ -97,6 +139,16 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
private val queryFlow = MutableStateFlow("")
/**
* A query somebody finished deliberately the keyboard's Search key, or a completed
* voice transcription. It bypasses the discovery debounce, which exists only to guess
* at this moment.
*
* `extraBufferCapacity` rather than a replay: a submission is an event, and one
* replayed to a collector that restarts would re-ask a question nobody asked twice.
*/
private val submissions = MutableSharedFlow<String>(extraBufferCapacity = 4)
/**
* Results for queries typed earlier in this session. Bounded and access-ordered, so
* backspacing through a word redraws instantly instead of re-querying every prefix.
@@ -107,8 +159,22 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
): Boolean = size > CACHE_ENTRIES
}
/**
* The same, for external discovery, keyed on the *normalised* query.
*
* The gateway holds the real cooldown this only saves the round trip. It is what makes
* leaving Search and coming back, or deleting and retyping a title, cost nothing at all.
*/
private val discoveryCache = object : LinkedHashMap<String, com.ponzischeme89.memby.data.DiscoveryResult>(16, 0.75f, true) {
override fun removeEldestEntry(
eldest: MutableMap.MutableEntry<String, com.ponzischeme89.memby.data.DiscoveryResult>?,
): Boolean = size > CACHE_ENTRIES
}
private var genreSuggestions: List<String> = emptyList()
private var searchId = 0L
/**
* The page in flight, so a viewer who leaves a genre or scrolls a second page while
* the first is still coming is never overtaken by an answer to a question they have
@@ -128,6 +194,19 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
// same terms while keeping the repository's plain suspend signature.
.collectLatest { term -> runSearch(term) }
}
viewModelScope.launch {
merge(
// Typing, once it has stopped for long enough to look like a word.
queryFlow.debounce(DISCOVERY_DEBOUNCE_MS).map(::normaliseDiscoveryQuery),
// Somebody saying they have finished. No debounce: the timer was only ever
// guessing at exactly this.
submissions.map(::normaliseDiscoveryQuery),
)
// Normalised, so trailing space and a changed capital are not a second
// lookup. This is the same rule the gateway keys its cooldown on.
.distinctUntilChanged()
.collectLatest { term -> runDiscovery(term) }
}
refreshSuggestions()
}
@@ -144,12 +223,10 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
genre = null,
isLoadingMore = false,
canLoadMore = false,
requestCandidates = emptyList(),
requestLookupLoading = false,
requestMessage = null,
requestMessageIsError = false,
pagingErrorMessage = null,
)
).withRelevantDiscovery()
}
queryFlow.value = query
}
@@ -158,6 +235,28 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
fun backspace() = onQueryChanged(state.value.query.dropLast(1))
/**
* The keyboard's Search key. Both halves run at once and neither waits for the other:
* the library answers from its own cache almost always, and discovery is released from
* its debounce because there is no longer anything to wait for.
*/
fun submitQuery() {
val term = state.value.query.trim()
if (!shouldSearch(term) || state.value.genre != null) return
viewModelScope.launch { runSearch(term) }
submissions.tryEmit(term)
}
/**
* A completed voice transcription, which is a submitted query and not a keystroke.
* Applying the typing debounce to it would delay a question somebody has already
* finished asking.
*/
fun onVoiceResult(spoken: String) {
onQueryChanged(spoken)
submitQuery()
}
fun clearQuery() {
// Clear immediately so results from a genre tile cannot remain visible while the
// debounced empty-query transition is pending.
@@ -166,13 +265,12 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
it.copy(
query = "", results = emptyList(), genreOffset = 0,
isLoading = false, hasSearched = false,
errorMessage = null, requestCandidates = emptyList(),
requestMode = false,
errorMessage = null,
pagingErrorMessage = null,
requestLookupLoading = false, requestMessage = null,
requestMessage = null,
requestMessageIsError = false, genre = null,
isLoadingMore = false, canLoadMore = false,
)
).withoutDiscovery()
}
queryFlow.value = ""
}
@@ -190,12 +288,11 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
query = "", genre = name, results = emptyList(), genreOffset = 0,
isLoading = true,
hasSearched = false, errorMessage = null, isLoadingMore = false,
canLoadMore = false, requestCandidates = emptyList(),
requestMode = false,
canLoadMore = false,
pagingErrorMessage = null,
requestLookupLoading = false, requestMessage = null,
requestMessage = null,
requestMessageIsError = false,
)
).withoutDiscovery()
}
// The typed query is dropped along with it, and the flow is told so a stale term
// cannot arrive from the debounce and overwrite the shelf that is opening.
@@ -250,7 +347,9 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
val term = current.query.trim()
if (!shouldSearch(term)) return
cache.remove(term)
discoveryCache.remove(normaliseDiscoveryQuery(term))
viewModelScope.launch { runSearch(term) }
submissions.tryEmit(term)
}
fun request(candidate: GatewayRequestCandidate) {
@@ -264,22 +363,21 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
viewModelScope.launch {
runCatching { repository.requestMedia(candidate) }
.onSuccess { title ->
// The card the viewer just pressed answers immediately rather than
// waiting for a re-search: the request has been accepted, and the
// section it lives in is the one they are still looking at.
discoveryCache.remove(normaliseDiscoveryQuery(queryAtRequest))
_state.update {
if (it.requestingCandidateKey != candidateKey) return@update it
val stillShowingRequest = it.query.trim() == queryAtRequest &&
it.genre == genreAtRequest
it.copy(
requestingCandidateKey = null,
results = if (stillShowingRequest) it.results.map { result ->
discovery = if (stillShowingRequest) it.discovery.map { result ->
if (searchRequestKey(result) == candidateKey) {
result.copy(membySearchState = "requested", membyRequestable = false)
} else result
} else it.results,
requestCandidates = if (stillShowingRequest) it.requestCandidates.map { option ->
if (option.mediaType == candidate.mediaType &&
option.foreignId == candidate.foreignId
) option.copy(alreadyAdded = true) else option
} else it.requestCandidates,
} else it.discovery,
requestMessage = if (stillShowingRequest) {
"${title.ifBlank { candidate.title }} was requested."
} else null,
@@ -302,23 +400,6 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
fun showRequests() {
val term = state.value.query.trim()
if (!shouldSearch(term)) return
_state.update {
it.copy(requestMode = true, requestCandidates = emptyList(), requestMessage = null,
requestMessageIsError = false)
}
viewModelScope.launch { loadRequestCandidates(term) }
}
fun hideRequests() {
_state.update {
it.copy(requestMode = false, requestCandidates = emptyList(), requestLookupLoading = false,
requestMessage = null, requestMessageIsError = false)
}
}
/**
* Genre chips for the empty state, taken from items the home screen already loaded.
* Nothing is fetched: if home has no data yet, the chips simply do not appear.
@@ -399,6 +480,10 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
/**
* The library half. One request to one backend that answers out of its own index and
* Redis no *arr is involved and none can make this slow.
*/
private suspend fun runSearch(term: String) {
// A genre shelf is not a query, so the empty-query transition has nothing to say
// about it. Without this, opening a genre — which clears the query — would arrive
@@ -411,89 +496,111 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
it.copy(
results = emptyList(), genreOffset = 0,
isLoading = false, hasSearched = false,
errorMessage = null, requestCandidates = emptyList(),
requestLookupLoading = false, requestMessage = null,
errorMessage = null,
requestMessage = null,
requestMessageIsError = false,
)
).withoutDiscovery()
}
return
}
val id = ++searchId
_state.update { it.copy(searchId = id) }
cache[term]?.let { cached ->
SearchTrace.local(id, term, cached.size, cacheHit = true, elapsedMs = 0)
_state.update {
if (!it.answers(term)) return@update it
it.copy(results = cached, isLoading = false, hasSearched = true, errorMessage = null)
}
if (_state.value.requestMode) loadRequestCandidates(term)
return
}
// Previous results stay on screen underneath the spinner: a flash of empty pane
// between two letters reads as breakage, not as progress.
_state.update { it.copy(isLoading = true, errorMessage = null) }
runCatching {
repository.searchProgress(term).collect { found ->
val startedAt = System.currentTimeMillis()
runCatching { repository.search(term) }
.onSuccess { found ->
// collectLatest cancels the preceding request, but cancellation is
// cooperative: an HTTP call that has already returned can still reach
// this non-suspending block. Drop it unless the field still asks the
// exact question that produced the answer.
if (_state.value.genre != null || _state.value.query.trim() != term) {
return@collect
// here. Drop it unless the field still asks the exact question that
// produced the answer.
if (!_state.value.answers(term)) return@onSuccess
// Keep the backend's relevance and personalisation order, but never let
// those softer signals bury the title the viewer typed exactly. This
// matters especially in the one-column list, where tenth place is ten rows
// away rather than the second grid row.
val ranked = rankSearchResults(term, found.distinctItems())
SearchTrace.local(id, term, ranked.size, cacheHit = false,
elapsedMs = System.currentTimeMillis() - startedAt)
cache[term] = ranked
_state.update {
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
}
// The gateway answers from the imported library and falls back to Emby
// before the first import has finished, so one title reaching the pane by
// both routes is a shape this search genuinely has. The result list is keyed by
// item id, where that is a crash rather than a repeated poster.
val items = mergeProgressiveSearchResults(_state.value.results, found).distinctItems()
// Keep the backend's relevance and personalisation order within each
// textual tier, but never let those softer signals bury the title the
// viewer typed exactly. This matters especially in the one-column list,
// where tenth place is ten rows away rather than the second grid row.
val ranked = if (_state.value.results.isEmpty()) rankSearchResults(term, items) else items
// Keep the loading signal alive after the first snapshot. The stream may still
// be waiting on Sonarr or Radarr, but the cards already on screen remain fully
// focusable and navigable while those additional results arrive.
_state.update { it.copy(results = ranked, isLoading = true, hasSearched = true, errorMessage = null) }
viewModelScope.launch { repository.recordSearch(term) }
}
}
.onFailure { error ->
// A cancelled search is the normal case while typing, not a failure.
if (error is kotlinx.coroutines.CancellationException) throw error
if (_state.value.genre != null || _state.value.query.trim() != term) {
return@onFailure
}
if (!_state.value.answers(term)) return@onFailure
_state.update {
it.copy(isLoading = false, hasSearched = true, errorMessage = friendlyEmbyError(error))
}
}
if (_state.value.genre == null && _state.value.query.trim() == term) {
_state.update { it.copy(isLoading = false) }
cache[term] = _state.value.results
viewModelScope.launch { repository.recordSearch(term) }
if (_state.value.requestMode) loadRequestCandidates(term)
}
}
private suspend fun loadRequestCandidates(term: String) {
_state.update { it.copy(requestLookupLoading = true, requestMessage = null) }
val candidates = runCatching { repository.lookupMediaRequests(term) }
.getOrElse { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
if (_state.value.query.trim() == term) {
_state.update {
it.copy(
requestLookupLoading = false,
requestMessage = friendlyEmbyError(error),
requestMessageIsError = true,
)
}
}
emptyList()
}
if (_state.value.query.trim() == term) {
_state.update {
it.copy(requestCandidates = candidates, requestLookupLoading = false)
}
/**
* The external half. Everything about it is arranged so that it cannot slow the screen
* down or move anything already on it.
*/
private suspend fun runDiscovery(term: String) {
if (!repository.supportsMediaRequests) return
val current = state.value
if (current.genre != null || !shouldDiscover(term)) {
SearchTrace.discoverySkipped(current.searchId, term,
if (current.genre != null) "genre_open" else "query_too_short")
_state.update { it.withoutDiscovery() }
return
}
discoveryCache[term]?.let { cached ->
SearchTrace.discovery(current.searchId, term, cached.items.size,
cacheHit = true, partial = cached.partial, elapsedMs = 0)
_state.update { it.withDiscovery(term, cached.items, cached.partial) }
return
}
_state.update { it.copy(discoveryLoading = true, discoveryUnavailable = false) }
val startedAt = System.currentTimeMillis()
runCatching { repository.discover(term, DISCOVERY_LIMIT) }
.onSuccess { result ->
val items = result.items.distinctItems()
SearchTrace.discovery(state.value.searchId, term, items.size,
cacheHit = false, partial = result.partial,
elapsedMs = System.currentTimeMillis() - startedAt)
discoveryCache[term] = result.copy(items = items)
_state.update {
// A discovery answer is only ever applied to a query it still describes:
// the viewer may have typed on, in which case a newer lookup is already
// on its way and this one is history.
if (!discoveryStillRelevant(term, it.query) || it.genre != null) {
return@update it.copy(discoveryLoading = false)
}
it.withDiscovery(term, items, result.partial)
}
}
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
SearchTrace.discoveryFailed(state.value.searchId, term, error)
_state.update {
// Never an error message: an *arr being unreachable must leave library
// search reading exactly as it does when everything is working. The
// section says so quietly, or says nothing at all.
it.copy(
discoveryLoading = false,
discoveryUnavailable = discoveryStillRelevant(term, it.query) && it.genre == null,
)
}
}
}
private fun refreshSuggestions() {
@@ -502,32 +609,92 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
companion object {
const val DEBOUNCE_MS = 250L
/**
* How long typing must stop before Sonarr and Radarr are asked anything.
*
* Deliberately several times the library's own debounce. Typing "Disclosure Day" on
* a remote is a dozen keystrokes over several seconds, and at 250 ms most of the
* gaps between them are long enough to look like the end of a word which is how
* one title becomes nine external lookups. At this figure it is one, or two if
* somebody genuinely stops to think half way through.
*/
const val DISCOVERY_DEBOUNCE_MS = 700L
const val MIN_QUERY_LENGTH = 2
private const val CACHE_ENTRIES = 24
private const val MAX_GENRE_SUGGESTIONS = 6
}
}
/** Merges cumulative server snapshots without moving cards already visible to the viewer. */
private fun mergeProgressiveSearchResults(
current: List<BaseItem>, incoming: List<BaseItem>,
): List<BaseItem> {
val byKey = incoming.associateBy { searchIdentity(it) }
val used = HashSet<String>()
val stable = current.mapNotNull { old -> byKey[searchIdentity(old)]?.also { used += searchIdentity(old) } ?: old }
return stable + incoming.filter { used.add(searchIdentity(it)) }
}
/** Is this state still the answer to [term]? The one staleness rule the library half uses. */
private fun SearchUiState.answers(term: String): Boolean =
genre == null && query.trim() == term
private fun searchIdentity(item: BaseItem): String = sequenceOf("Tmdb", "Tvdb", "Imdb")
.mapNotNull { item.providerIds[it]?.takeIf(String::isNotBlank)?.let { id -> "$it:$id" } }
private fun SearchUiState.withDiscovery(
term: String,
items: List<BaseItem>,
partial: Boolean,
): SearchUiState = copy(
discovery = items,
discoveryQuery = term,
discoveryLoading = false,
discoveryUnavailable = partial && items.isEmpty(),
)
private fun SearchUiState.withoutDiscovery(): SearchUiState =
copy(discovery = emptyList(), discoveryQuery = "", discoveryLoading = false,
discoveryUnavailable = false)
/**
* Keeps a discovery section only while it still plausibly answers what is typed.
*
* It survives somebody extending the word they were typing the newer answer is already on
* its way, and taking the section away between two keystrokes is worse than briefly showing
* one a letter behind. Anything else is a different question, and its results go.
*/
private fun SearchUiState.withRelevantDiscovery(): SearchUiState =
if (discoveryQuery.isNotEmpty() && !discoveryStillRelevant(discoveryQuery, query)) {
withoutDiscovery()
} else {
this
}
/**
* One title's identity across three systems.
*
* Emby, Sonarr and Radarr can each know about the same film, so the only safe way to
* collapse them into one card is a stable external id. The title fallback is what is left
* when nothing carries one, and it is deliberately the weakest rule here rather than the
* first one tried.
*/
internal fun searchIdentity(item: BaseItem): String = sequenceOf("Tmdb", "Tvdb", "Imdb")
.mapNotNull { key -> item.providerIds[key]?.takeIf(String::isNotBlank)?.let { id -> "$key:$id" } }
.firstOrNull() ?: "${item.type}:${item.name.trim().lowercase()}"
private fun searchRequestKey(item: BaseItem): String? = when (item.membySource) {
internal fun searchRequestKey(item: BaseItem): String? = when (item.membySource) {
"sonarr" -> item.providerIds["Tvdb"]?.let { "series:$it" }
"radarr" -> item.providerIds["Tmdb"]?.let { "movie:$it" }
else -> null
}
/**
* The external section, with anything the library already answered for removed.
*
* The gateway drops what its own catalogue knows about, which covers nearly everything.
* This is the other half of that: a title Emby holds but the imported catalogue has not
* caught up with would otherwise appear once as playable and once as requestable, which is
* the single worst thing a progressive search can show.
*/
fun discoverySection(results: List<BaseItem>, discovery: List<BaseItem>): List<BaseItem> {
if (discovery.isEmpty()) return discovery
val known = results.mapTo(HashSet()) { searchIdentity(it) }
val titles = results.mapTo(HashSet()) { "${it.type}:${it.name.trim().lowercase()}" }
return discovery.filter { candidate ->
searchIdentity(candidate) !in known &&
"${candidate.type}:${candidate.name.trim().lowercase()}" !in titles
}
}
class SearchViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = SearchViewModel(repository) as T
@@ -23,6 +23,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
@@ -42,10 +43,12 @@ import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.ui.MembyChoiceChip
import com.ponzischeme89.memby.ui.toRequestCandidate
import com.ponzischeme89.memby.ui.components.media.MediaResultCard
import com.ponzischeme89.memby.ui.components.media.MediaResultCardSkeleton
import com.ponzischeme89.memby.ui.components.media.MediaResultTypeGlyph
import com.ponzischeme89.memby.ui.search.SearchUiState
import com.ponzischeme89.memby.ui.search.discoverySection
import com.ponzischeme89.memby.ui.search.shouldSearch
import com.ponzischeme89.memby.ui.requests.RequestCard
import com.ponzischeme89.memby.ui.requests.RequestActionRequest
@@ -58,7 +61,19 @@ import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.mark
import kotlinx.coroutines.flow.distinctUntilChanged
/** Search-owned result state rendered through the same media row as My Requests. */
/**
* Search results, in two sections that arrive at different times.
*
* **AVAILABLE** is the library, and it is drawn the moment it answers. **AVAILABLE TO
* REQUEST** is Sonarr and Radarr, and it is appended underneath whenever it turns up
* underneath being the whole of the design. Nothing above it moves, nothing is re-sorted,
* the list is keyed by stable ids so no card is rebuilt, and a remote parked on a result
* stays exactly where it was. A section that arrives while somebody is choosing must never
* be a reason they lose their place.
*
* Both headings appear only when there is a second section to distinguish the first from.
* A single list under a heading saying AVAILABLE is a heading that answers nothing.
*/
@Composable
internal fun SearchResults(
state: SearchUiState,
@@ -71,14 +86,18 @@ internal fun SearchResults(
artworkUrlFor: (BaseItem) -> String?,
onItemFocused: (BaseItem) -> Unit,
onItemSelected: (BaseItem) -> Unit,
onRequest: (GatewayRequestCandidate) -> Unit,
onRetry: () -> Unit,
onShowRequests: () -> Unit,
onLoadMore: () -> Unit,
voiceAvailable: Boolean,
modifier: Modifier = Modifier,
) {
val items = state.results.filter { it.isMovie || it.isSeries }
// Remembered, or the section is re-derived on every recomposition — including the ones
// a focus move causes, which is every press of Down through a long result list.
val requestable = remember(state.results, state.discovery) {
discoverySection(state.results, state.discovery)
}
val sectioned = requestable.isNotEmpty() || state.discoveryLoading
val listState = rememberLazyListState()
val currentOnLoadMore by rememberUpdatedState(onLoadMore)
@@ -92,21 +111,16 @@ internal fun SearchResults(
}
}
val showStartPrompt = state.isDiscovery && items.isEmpty()
val showStartPrompt = state.isDiscovery && items.isEmpty() && requestable.isEmpty()
val nothingFound = items.isEmpty() && requestable.isEmpty() && !state.discoveryLoading
Column(modifier) {
if (!showStartPrompt) {
SearchResultsHeading(
state = state,
resultCount = items.size,
onShowRequests = onShowRequests,
requestFocusRequester = entryFocusRequester,
keyboardReturnFocusRequester = keyboardReturnFocusRequester,
)
SearchResultsHeading(state = state, resultCount = items.size)
Spacer(Modifier.height(8.dp))
}
when {
state.isLoading && items.isEmpty() -> Column(
state.isLoading && items.isEmpty() && requestable.isEmpty() -> Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxSize(),
) {
@@ -124,37 +138,18 @@ internal fun SearchResults(
showStartPrompt -> SearchStartPrompt(voiceAvailable)
items.isEmpty() -> SearchResultMessage(message = emptyMessage(state))
nothingFound -> SearchResultMessage(message = emptyMessage(state))
else -> LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxSize(),
) {
if (sectioned && items.isNotEmpty()) {
item(key = "section-available") { SearchSectionHeading("AVAILABLE") }
}
itemsIndexed(items, key = { _, item -> searchResultKey(item) }) { index, item ->
val requestable = item.membyRequestable
val requestCandidate = item.toSearchRequestCandidate()
if (requestable && requestCandidate != null) RequestCard(
title = item.name,
subtitle = searchResultSubtitle(item),
detail = item.overview.orEmpty().ifBlank { "Request this title" },
status = RequestStatus.REQUESTABLE,
statusLabel = "",
mediaType = requestCandidate.mediaType,
artworkUrl = artworkUrlFor(item),
onFocused = { onItemFocused(item) },
onClick = { onRequest(requestCandidate) },
busy = state.requestingCandidateKey ==
"${requestCandidate.mediaType}:${requestCandidate.foreignId}",
action = RequestActionRequest,
modifier = Modifier
.then(if (index == 0) Modifier.focusRequester(entryFocusRequester) else Modifier)
.then(if (item.id == returnFocusItemId) Modifier.focusRequester(returnFocusRequester) else Modifier)
.focusProperties {
left = keyboardReturnFocusRequester
if (index == 0 && genresPresent) up = genresFocusRequester
},
) else MediaResultCard(
MediaResultCard(
title = item.name,
subtitle = searchResultSubtitle(item),
detail = item.genres.firstOrNull().orEmpty().ifBlank { "In your library" },
@@ -182,14 +177,71 @@ internal fun SearchResults(
},
)
}
if (requestable.isNotEmpty()) {
item(key = "section-requestable") { SearchSectionHeading("AVAILABLE TO REQUEST") }
}
// Keys are prefixed rather than shared with the library section. The
// gateway drops what its catalogue already has and discoverySection drops
// the rest, but a keyed list throws on a repeated key — the one failure
// here that is a crash rather than a duplicate poster.
itemsIndexed(requestable, key = { _, item -> "request:" + searchResultKey(item) }) { index, item ->
val candidate = item.toRequestCandidate()
val firstOverall = items.isEmpty() && index == 0
RequestCard(
title = item.name,
subtitle = searchResultSubtitle(item),
detail = item.overview.orEmpty().ifBlank { "Not in your library yet" },
status = requestCardStatus(item),
statusLabel = "",
mediaType = if (item.isSeries) "series" else "movie",
artworkUrl = artworkUrlFor(item),
onFocused = { onItemFocused(item) },
// The whole card opens the title, exactly as a library result does.
// Requesting from a list is a decision made about a name and a year;
// the page behind it is where somebody can see what they are asking
// for before they ask for it.
onClick = { onItemSelected(item) },
busy = candidate != null && state.requestingCandidateKey ==
"${candidate.mediaType}:${candidate.foreignId}",
// The affordance advertises what is behind the press rather than
// performing it: pressing opens the title's page, and Request is
// the action waiting there.
action = if (item.membyRequestable && candidate != null) RequestActionRequest else null,
modifier = Modifier
.then(if (firstOverall) Modifier.focusRequester(entryFocusRequester) else Modifier)
.then(
if (item.id == returnFocusItemId) {
Modifier.focusRequester(returnFocusRequester)
} else {
Modifier
},
)
.focusProperties {
left = keyboardReturnFocusRequester
if (firstOverall && genresPresent) up = genresFocusRequester
},
)
}
// Never a spinner over the results and never a blocking state: the library
// section above stays fully usable while this is on screen.
if (state.discoveryLoading) {
item(key = "search-discovery-loading") {
SearchFooterNote("Finding more titles…", MembyAccent)
}
}
if (state.discoveryUnavailable) {
item(key = "search-discovery-unavailable") {
SearchFooterNote("Additional results unavailable", MembyMutedText)
}
}
state.requestMessage?.let { message ->
item(key = "search-request-message") {
SearchFooterNote(message, if (state.requestMessageIsError) MembyMutedText else MembyAccent)
}
}
if (state.isLoadingMore) {
item(key = "search-loading-more") {
Text(
"Loading more",
color = MembyMutedText,
fontSize = 13.sp,
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
)
SearchFooterNote("Loading more", MembyMutedText)
}
}
state.pagingErrorMessage?.let {
@@ -209,24 +261,52 @@ internal fun SearchResults(
}
}
private fun BaseItem.toSearchRequestCandidate(): GatewayRequestCandidate? {
val mediaType = when (membySource) {
"sonarr" -> "series"
"radarr" -> "movie"
else -> return null
/** A section rule. Never focusable: it is a label, and a remote must pass straight over it. */
@Composable
private fun SearchSectionHeading(label: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
label,
color = MembyMutedText,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 1.2.sp,
)
Spacer(Modifier.width(10.dp))
Box(
Modifier
.weight(1f)
.height(1.dp)
.background(MembyMutedText.copy(alpha = 0.22f)),
)
}
val provider = if (mediaType == "series") "Tvdb" else "Tmdb"
val foreignId = providerIds[provider]?.toIntOrNull() ?: return null
return GatewayRequestCandidate(
mediaType = mediaType,
foreignId = foreignId,
title = name,
year = productionYear ?: 0,
overview = overview.orEmpty(),
posterUrl = membyPosterUrl.orEmpty(),
}
@Composable
private fun SearchFooterNote(message: String, colour: Color) {
Text(
message,
color = colour,
fontSize = 13.sp,
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
)
}
/**
* What the card says about a title nothing in the library answers for.
*
* The vocabulary is the gateway's an unknown slug is read as requestable rather than
* dropped, so a state invented on the server tomorrow still draws a card today.
*/
private fun requestCardStatus(item: BaseItem): String = when (item.membySearchState) {
"requested", "processing" -> RequestStatus.PROCESSING
"pending" -> RequestStatus.PENDING
else -> RequestStatus.REQUESTABLE
}
@Composable
private fun SearchStartPrompt(voiceAvailable: Boolean) {
val pulse = rememberInfiniteTransition(label = "search-start-prompt").animateFloat(
@@ -279,9 +359,6 @@ private fun SearchStartPrompt(voiceAvailable: Boolean) {
private fun SearchResultsHeading(
state: SearchUiState,
resultCount: Int,
onShowRequests: () -> Unit,
requestFocusRequester: FocusRequester,
keyboardReturnFocusRequester: FocusRequester,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -305,20 +382,6 @@ private fun SearchResultsHeading(
Spacer(Modifier.width(9.dp))
Text("Searching…", color = MembyAccent, fontSize = 12.sp)
}
if (
state.requestsAvailable && state.genre == null && shouldSearch(state.query) &&
!state.requestMode && state.errorMessage == null
) {
Spacer(Modifier.width(12.dp))
MembyChoiceChip(
label = "Request",
selected = false,
onClick = onShowRequests,
modifier = Modifier
.then(if (resultCount == 0) Modifier.focusRequester(requestFocusRequester) else Modifier)
.focusProperties { left = keyboardReturnFocusRequester },
)
}
}
}
@@ -0,0 +1,110 @@
package com.ponzischeme89.memby.ui.genre
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ui.TvRailCollapsedWidth
import com.ponzischeme89.memby.ui.theme.MembySurface
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
/**
* Renders the Services strip above the Genres rail to PNGs under
* `build/screenshots/services-rail/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*ServicesRailScreenshotTest"
* ```
*
* The thing worth judging here is the same one [GenreRailScreenshotTest] exists for: no
* unit test can say whether the gap between the two strips reads as "related but
* separate" at three metres, or whether four circular shortcuts sit comfortably above a
* vertical list of text rows without overpowering it.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class ServicesRailScreenshotTest {
@get:Rule
val compose = createComposeRule()
/** Nothing chosen on either strip — the page's default state. */
@Test
fun `no service or genre selected`() {
capture("services-rail-default", activeId = ALL_SERVICES_CATEGORY_ID, selectedServiceId = null)
}
/** The remote on a service icon, with that service's shelf on screen. */
@Test
fun `remote on netflix`() {
capture(
"services-rail-netflix-focused",
activeId = "service-netflix",
selectedServiceId = "service-netflix",
)
}
/**
* The remote has moved down into the genre rail; Netflix stays selected underneath it
* and must still say so without wearing the focus ring the [GenreRailItem] and
* [ServiceIconButton] promise that focus and selection are marked separately.
*/
@Test
fun `service selection survives focus moving to genres`() {
capture(
"services-rail-selection-persists",
activeId = "drama",
selectedServiceId = "service-netflix",
genreActiveId = "drama",
)
}
private fun capture(
name: String,
activeId: String,
selectedServiceId: String?,
genreActiveId: String = ALL_MEDIA_CATEGORY_ID,
) {
val services = serviceCategoryTabs()
val categories = genreCategoryTabs(ALL_MEDIA_ITEM_TYPE)
compose.setContent {
Row(Modifier.fillMaxSize().background(MembySurface)) {
Box(Modifier.width(TvRailCollapsedWidth).fillMaxSize().background(MembySurface))
GenreRail(
categories = categories,
activeCategoryId = genreActiveId,
navigationFocusRequester = FocusRequester(),
activeFocusRequester = FocusRequester(),
onCategoryFocused = {},
onEnterContent = { false },
focusForCapture = genreActiveId.takeIf { activeId == genreActiveId && activeId != ALL_SERVICES_CATEGORY_ID && services.none { it.id == activeId } },
topContent = {
ServicesRail(
services = services,
activeServiceId = activeId,
selectedServiceId = selectedServiceId,
navigationFocusRequester = FocusRequester(),
activeFocusRequester = FocusRequester(),
onServiceFocused = {},
focusForCapture = activeId.takeIf { services.any { s -> s.id == activeId } },
)
},
)
Box(Modifier.fillMaxSize().background(MembySurface))
}
}
compose.onRoot().captureRoboImage("build/screenshots/services-rail/$name.png")
}
}
@@ -1,35 +0,0 @@
package com.ponzischeme89.memby.ui.search
import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
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
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class RecommendationRequestScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `recommendation request panel`() {
val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png"))
.use(BitmapFactory::decodeStream)
.asImageBitmap()
compose.setContent { RecommendationRequestPreview(previewArtwork = artwork) }
compose.onNodeWithText("Request something new").fetchSemanticsNode()
compose.onRoot().captureRoboImage(
"build/screenshots/recommendation-request/recommendation-request-panel.png",
)
}
}
@@ -0,0 +1,111 @@
package com.ponzischeme89.memby.ui.search
import com.ponzischeme89.memby.data.MIN_DISCOVERY_QUERY_LENGTH
import com.ponzischeme89.memby.data.discoveryStillRelevant
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.normaliseDiscoveryQuery
import com.ponzischeme89.memby.data.shouldDiscover
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The rules that decide when somebody else's service gets asked anything, and what happens
* to the answer. They are pure so that "Sonarr is not queried on every keystroke" can be an
* assertion rather than something read off a log.
*/
class SearchDiscoveryTest {
@Test
fun `typing a title produces one external query, not one per letter`() {
// Every prefix on the way to a word, as the on-screen keyboard produces them.
val typed = listOf(
"D", "Di", "Dis", "Disc", "Discl", "Disclo", "Disclos", "Disclosu",
"Disclosur", "Disclosure", "Disclosure ", "Disclosure D", "Disclosure Da",
"Disclosure Day",
)
// The debounce collapses the run to whatever was typed when it stopped; what this
// pins is the half that is not a timer — the floor and the normalisation, which is
// what stops a pause mid-word and a trailing space being their own lookups.
val eligible = typed.filter(::shouldDiscover).map(::normaliseDiscoveryQuery).distinct()
assertFalse("a one- or two-letter fragment must never reach an *arr", eligible.contains("d"))
assertFalse(eligible.contains("di"))
assertEquals(
"a trailing space is not a new question",
1,
eligible.count { it == "disclosure" },
)
assertTrue(eligible.contains("disclosure day"))
}
@Test
fun `the same question in different clothes is one question`() {
val expected = normaliseDiscoveryQuery("disclosure day")
for (query in listOf("Disclosure Day", " disclosure day ", "DISCLOSURE DAY")) {
assertEquals(query, expected, normaliseDiscoveryQuery(query))
}
// And a genuinely different one is not.
assertTrue(normaliseDiscoveryQuery("disclosure") != expected)
}
@Test
fun `the floor is counted in characters of the normalised query`() {
assertEquals(3, MIN_DISCOVERY_QUERY_LENGTH)
assertFalse(shouldDiscover(" d "))
assertFalse(shouldDiscover("di"))
assertTrue(shouldDiscover(" dis "))
}
@Test
fun `a section survives the word being extended and not anything else`() {
// The newer answer is already on its way; taking the section away between two
// keystrokes is worse than briefly showing one a letter behind.
assertTrue(discoveryStillRelevant("disclosure", "Disclosure Day"))
assertTrue(discoveryStillRelevant("disclosure", "disclosure"))
// A different question, and one being backspaced away, are not.
assertFalse(discoveryStillRelevant("disclosure", "severance"))
assertFalse(discoveryStillRelevant("disclosure", "disclos"))
assertFalse(discoveryStillRelevant("disclosure", ""))
}
@Test
fun `a title the library answered for is never offered as requestable as well`() {
val library = listOf(
item(id = "emby-1", name = "Disclosure", type = "Movie", tmdb = "551"),
item(id = "emby-2", name = "Severance", type = "Series"),
)
val external = listOf(
// The same film by its stable id, which is how a catalogue that has not caught
// up would otherwise produce two cards.
item(id = "radarr:551", name = "Disclosure (1994)", type = "Movie", tmdb = "551"),
// The same series with no id in common: the title fallback is what catches it.
item(id = "sonarr:9", name = "Severance", type = "Series", tvdb = "9"),
item(id = "radarr:77", name = "Disclosure Day", type = "Movie", tmdb = "77"),
)
val offered = discoverySection(library, external)
assertEquals(listOf("Disclosure Day"), offered.map { it.name })
}
@Test
fun `nothing in the library means everything external is still offered`() {
val external = listOf(item(id = "radarr:77", name = "Disclosure Day", type = "Movie", tmdb = "77"))
assertEquals(external, discoverySection(emptyList(), external))
}
private fun item(
id: String,
name: String,
type: String,
tmdb: String? = null,
tvdb: String? = null,
) = BaseItem(
id = id,
name = name,
type = type,
providerIds = buildMap {
tmdb?.let { put("Tmdb", it) }
tvdb?.let { put("Tvdb", it) }
},
)
}
@@ -0,0 +1,164 @@
package com.ponzischeme89.memby.ui.search
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import androidx.test.core.app.ApplicationProvider
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.RequestDetailsOverlay
import com.ponzischeme89.memby.ui.search.components.SearchResults
import com.ponzischeme89.memby.ui.theme.MembySurface
import org.junit.Before
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 claim this feature makes is a *visual* one that a second section arriving under the
* first reads as more being found rather than as the results having been replaced and no
* unit test can check it. These are the four states worth looking at: the library alone,
* the library with requestable titles under it, the moment in between while the *arrs are
* still answering, and the case the library cannot answer at all.
*
* `build/screenshots/search-sections/`
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class SearchSectionsScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
@Test
fun `the library alone carries no section headings`() {
capture("library-only", state(results = library))
}
@Test
fun `requestable titles arrive under their own heading`() {
capture("with-requestable", state(results = library, discovery = external))
}
@Test
fun `the library stays usable while the arrs are still answering`() {
capture(
"discovery-in-flight",
state(results = library, discoveryLoading = true, discoveryQuery = "disclosure"),
)
}
@Test
fun `an arr that cannot be asked says so quietly and never as an error`() {
capture(
"discovery-unavailable",
state(results = library, discoveryUnavailable = true),
)
}
@Test
fun `nothing in the library still finds something to request`() {
capture("requestable-only", state(results = emptyList(), discovery = external))
}
@Test
fun `a requestable title opens a page before it is asked for`() {
compose.setContent {
Box(Modifier.fillMaxSize().background(MembySurface)) {
RequestDetailsOverlay(
card = external.first(),
posterUrl = null,
onRequest = {},
onClose = {},
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/search-sections/request-page.png")
}
private fun capture(name: String, state: SearchUiState) {
compose.setContent {
Box(Modifier.fillMaxSize().background(MembySurface).padding(28.dp)) {
SearchResults(
state = state,
entryFocusRequester = FocusRequester(),
genresFocusRequester = FocusRequester(),
genresPresent = false,
keyboardReturnFocusRequester = FocusRequester(),
returnFocusItemId = null,
returnFocusRequester = FocusRequester(),
artworkUrlFor = { null },
onItemFocused = {},
onItemSelected = {},
onRetry = {},
onLoadMore = {},
voiceAvailable = true,
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/search-sections/$name.png")
}
private fun state(
results: List<BaseItem>,
discovery: List<BaseItem> = emptyList(),
discoveryLoading: Boolean = false,
discoveryUnavailable: Boolean = false,
discoveryQuery: String = "",
) = SearchUiState(
query = "disclosure",
results = results,
discovery = discovery,
discoveryQuery = discoveryQuery.ifEmpty { if (discovery.isEmpty()) "" else "disclosure" },
discoveryLoading = discoveryLoading,
discoveryUnavailable = discoveryUnavailable,
hasSearched = true,
requestsAvailable = true,
)
private val library = listOf(
BaseItem(
id = "emby-1", name = "Disclosure", type = "Movie", productionYear = 1994,
genres = listOf("Thriller"), providerIds = mapOf("Tmdb" to "9314"),
membySearchState = "available",
),
BaseItem(
id = "emby-2", name = "The Disclosure Tapes", type = "Series", productionYear = 2021,
genres = listOf("Documentary"), providerIds = mapOf("Tvdb" to "4411"),
membySearchState = "available",
),
)
private val external = listOf(
BaseItem(
id = "radarr:77", name = "Disclosure Day", type = "Movie", productionYear = 2027,
overview = "A leaked memo gives one archivist forty-eight hours to decide what the " +
"world is allowed to know.",
providerIds = mapOf("Tmdb" to "77"), membySource = "radarr",
membySearchState = "requestable", membyRequestable = true,
),
BaseItem(
id = "sonarr:88", name = "Disclosure: The Hearings", type = "Series",
productionYear = 2026,
overview = "Six weeks of testimony, reconstructed from the transcripts.",
providerIds = mapOf("Tvdb" to "88"), membySource = "sonarr",
membySearchState = "processing",
),
)
}