0.3.27 - Omni search fixes, Services in genre browser..
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
-35
@@ -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",
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user