0.3.23 - Search radarr/sonarr

This commit is contained in:
ponzischeme89
2026-08-25 15:39:43 +12:00
parent 0fcc02f57e
commit 772635784c
21 changed files with 603 additions and 117 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
val defaultVersionName = "0.3.22"
val defaultVersionName = "0.3.23"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -964,6 +964,8 @@ class EmbyRepository internal constructor(
return searchRepository.search(term, limit)
}
fun searchProgress(term: String, limit: Int = 40) = searchRepository.searchProgress(term, limit)
/**
* One page of a genre, dual-path like the search beside it.
*
@@ -3334,6 +3336,7 @@ class EmbyRepository internal constructor(
/** Primary (poster) image URL, used as a card fallback. */
fun primaryUrl(item: BaseItem, maxWidth: Int = ARTWORK_CARD_MAX_WIDTH): String? {
item.membyPosterUrl?.takeIf(String::isNotBlank)?.let { return it }
val tag = item.imageTags["Primary"] ?: return null
return imageUrl(item.id, "Primary", tag, maxWidth)
}
@@ -3,6 +3,7 @@ package com.ponzischeme89.memby.data
import android.os.SystemClock
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GenreAffinity
import com.ponzischeme89.memby.data.model.GatewayItems
import com.ponzischeme89.memby.data.remote.EmbyApi
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
import com.ponzischeme89.memby.data.remote.GatewayApi
@@ -14,6 +15,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.json.Json
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -65,6 +69,22 @@ 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) }
}
}
}
/** One stable page filtered by genre; episodes are excluded deliberately. */
suspend fun browseGenre(
genre: String,
@@ -458,6 +458,10 @@ data class BaseItem(
@SerialName("MembyLifecycle") val membyLifecycle: String? = null,
@SerialName("MembyLifecycleText") val membyLifecycleText: String? = null,
@SerialName("MembyPlayable") val membyPlayable: Boolean = true,
@SerialName("MembySearchState") val membySearchState: String? = null,
@SerialName("MembyRequestable") val membyRequestable: Boolean = false,
@SerialName("MembyPosterURL") val membyPosterUrl: String? = null,
@SerialName("ProviderIds") val providerIds: Map<String, String> = emptyMap(),
// The Emby series a schedule card stands for, when the library holds it. Absent for a
// show Sonarr follows but Emby has never imported, so the card stays informational.
@SerialName("MembySeriesItemId") val membySeriesItemId: String? = null,
@@ -40,6 +40,8 @@ import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming
import okhttp3.ResponseBody
/**
* The Memby gateway API.
@@ -116,6 +118,10 @@ 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
/**
* One page of a genre. A filter, not a query: the genre is the path rather than a term,
* so the gateway can ask Emby the question actually being asked.
@@ -0,0 +1,84 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
/**
* How many digits a Memby PIN holds.
*
* Every entry surface setup, device recovery, and setting a PIN for somebody else from
* viewer management renders exactly this many boxes and asks for exactly this many
* digits. A PIN nobody can see needs its length stated visually rather than discovered by
* typing past the end, and a fixed length is what makes a single confirmation entry safe to
* drop: mistyping one digit of four is corrected by simply retyping it, not by comparing
* two blind fields against each other.
*/
internal const val PIN_LENGTH = 4
/**
* A row of [PIN_LENGTH] boxes driven by one hidden [BasicTextField].
*
* A television has one remote and no reliable way to move a D-pad between four separate
* text fields, so this is deliberately one focus target the boxes are decoration drawn
* from the field's own value, never fields of their own. That is also what lets the
* system's numeric keyboard drive it "beautifully": a remote's number keys, a phone-remote
* app, or an on-screen keypad all just type into the one field the platform already knows
* how to route digits into. `performTextInput`/`onNode(hasSetTextAction())` in the
* screenshot tests keeps working unchanged, because there is still exactly one settable
* node underneath the boxes.
*/
@Composable
internal fun PinBoxesField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
BasicTextField(
value = value,
onValueChange = { onValueChange(it.filter(Char::isDigit).take(PIN_LENGTH)) },
textStyle = TextStyle(color = Color.Transparent, fontSize = 1.sp),
cursorBrush = SolidColor(Color.Transparent),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
modifier = modifier,
decorationBox = {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
repeat(PIN_LENGTH) { index ->
val filled = index < value.length
val active = index == value.length
Box(
Modifier
.size(46.dp)
.background(MembyControlSurface, RoundedCornerShape(8.dp))
.border(
if (active) 2.dp else 0.dp,
if (active) MembyAccent else Color.Transparent,
RoundedCornerShape(8.dp),
),
contentAlignment = Alignment.Center,
) {
if (filled) Text("", color = MembyAccent, fontSize = 22.sp)
}
}
}
},
)
}
@@ -3,12 +3,12 @@ package com.ponzischeme89.memby.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -17,10 +17,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Button
@@ -28,7 +25,6 @@ import androidx.tv.material3.Text
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
@@ -37,54 +33,42 @@ import kotlinx.coroutines.launch
@Composable
internal fun PinSetupScreen(viewer: MembyViewer, onComplete: () -> Unit) {
var pin by remember { mutableStateOf("") }
var confirmation by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var saving by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Column(
Modifier.fillMaxSize().background(MembySurface).padding(72.dp),
Modifier.fillMaxSize().background(MembySurface).padding(48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Create your Memby PIN", color = MembyOnSurface, fontSize = 32.sp)
Spacer(Modifier.height(14.dp))
Text("Create your Memby PIN", color = MembyOnSurface, fontSize = 28.sp)
Spacer(Modifier.height(10.dp))
Text(
"This PIN belongs to your Memby profile and can be used to sign back in on this device, even after you log out or reinstall Memby.",
color = MembyQuietText, fontSize = 18.sp,
color = MembyQuietText,
fontSize = 15.sp,
textAlign = TextAlign.Center,
modifier = Modifier.width(460.dp),
)
Spacer(Modifier.height(24.dp))
PinField(pin, { pin = it }, "PIN")
Spacer(Modifier.height(10.dp))
PinField(confirmation, { confirmation = it }, "Confirm PIN")
error?.let { Text(it, color = MembyAccent) }
Spacer(Modifier.height(18.dp))
Button(enabled = !saving && pin.length in 4..12 && pin == confirmation, onClick = {
error = when {
pin != confirmation -> "The PINs do not match."
else -> null
}
if (error != null) return@Button
saving = true
scope.launch {
runCatching { ServiceLocator.repository.setViewerPIN(viewer, pin) }
.onSuccess { onComplete() }
.onFailure { error = "That PIN could not be saved." }
saving = false
}
}) { Text("Save PIN") }
PinBoxesField(pin, { pin = it; error = null })
error?.let {
Spacer(Modifier.height(10.dp))
Text(it, color = MembyAccent, fontSize = 13.sp)
}
Spacer(Modifier.height(20.dp))
Button(
enabled = !saving && pin.length == PIN_LENGTH,
contentPadding = PaddingValues(horizontal = 28.dp, vertical = 8.dp),
onClick = {
saving = true
scope.launch {
runCatching { ServiceLocator.repository.setViewerPIN(viewer, pin) }
.onSuccess { onComplete() }
.onFailure { error = "That PIN could not be saved." }
saving = false
}
},
) { Text("Save PIN") }
}
}
@Composable
private fun PinField(value: String, onValueChange: (String) -> Unit, label: String) {
BasicTextField(
value = value,
onValueChange = { onValueChange(it.filter(Char::isDigit).take(12)) },
textStyle = TextStyle(color = MembyOnSurface, fontSize = 22.sp),
cursorBrush = SolidColor(MembyAccent),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(260.dp).background(MembyControlSurface, RoundedCornerShape(8.dp)).padding(14.dp),
decorationBox = { field -> if (value.isEmpty()) Text(label, color = MembyQuietText); field() },
)
}
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -18,16 +17,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Button
import androidx.tv.material3.OutlinedButton
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.GatewayRecoveryProfile
@@ -50,39 +47,52 @@ internal fun DeviceRecoveryScreen(
val scope = rememberCoroutineScope()
val chosen = selected
Column(
Modifier.fillMaxSize().background(MembySurface).padding(72.dp),
Modifier.fillMaxSize().background(MembySurface).padding(horizontal = 56.dp, vertical = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Welcome back", color = MembyOnSurface, fontSize = 32.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp))
Text("This TV is already registered with Memby.", color = MembyQuietText, fontSize = 16.sp)
Spacer(Modifier.height(28.dp))
Text("Welcome back", color = MembyOnSurface, fontSize = 28.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(6.dp))
Text("This TV is already registered with Memby.", color = MembyQuietText, fontSize = 14.sp)
Spacer(Modifier.height(20.dp))
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
profiles.distinctBy { it.viewer.id }.forEach { profile ->
Column(Modifier.width(150.dp).background(MembyControlSurface, RoundedCornerShape(14.dp)).clickable { selected = profile; pin = ""; error = null }.padding(18.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(profile.viewer.initials, color = MembyAccent, fontSize = 36.sp, fontWeight = FontWeight.Bold)
Text(profile.viewer.name, color = MembyOnSurface, fontSize = 15.sp)
Column(
Modifier.width(136.dp).background(MembyControlSurface, RoundedCornerShape(14.dp)).clickable { selected = profile; pin = ""; error = null }.padding(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(profile.viewer.initials, color = MembyAccent, fontSize = 28.sp, fontWeight = FontWeight.Bold)
Text(profile.viewer.name, color = MembyOnSurface, fontSize = 14.sp)
}
}
}
chosen?.let { profile ->
Spacer(Modifier.height(24.dp))
Text(if (profile.viewer.hasPin) "Enter your PIN" else "Continue as ${profile.viewer.name}", color = MembyQuietText)
Spacer(Modifier.height(18.dp))
Text(if (profile.viewer.hasPin) "Enter your PIN" else "Continue as ${profile.viewer.name}", color = MembyQuietText, fontSize = 14.sp)
if (profile.viewer.hasPin) {
Spacer(Modifier.height(10.dp))
BasicTextField(pin, { pin = it.filter(Char::isDigit).take(12) }, textStyle = androidx.compose.ui.text.TextStyle(color = MembyOnSurface, fontSize = 22.sp), cursorBrush = SolidColor(MembyAccent), visualTransformation = PasswordVisualTransformation(), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.background(MembyControlSurface, RoundedCornerShape(8.dp)).padding(14.dp))
Spacer(Modifier.height(8.dp))
PinBoxesField(pin, { pin = it; error = null })
}
error?.let { Text(it, color = MembyAccent) }
Spacer(Modifier.height(14.dp))
Button(onClick = {
if (profile.viewer.hasPin && pin.isBlank()) { error = "Enter your PIN"; return@Button }
scope.launch {
runCatching { ServiceLocator.repository.recoverWithPIN(profile, pin) }.onSuccess { onRecovered() }.onFailure { error = "That PIN was not accepted." }
}
}) { Text("Continue") }
error?.let {
Spacer(Modifier.height(6.dp))
Text(it, color = MembyAccent, fontSize = 13.sp)
}
Spacer(Modifier.height(12.dp))
Button(
enabled = !profile.viewer.hasPin || pin.length == PIN_LENGTH,
contentPadding = PaddingValues(horizontal = 28.dp, vertical = 8.dp),
onClick = {
scope.launch {
runCatching { ServiceLocator.repository.recoverWithPIN(profile, pin) }.onSuccess { onRecovered() }.onFailure { error = "That PIN was not accepted." }
}
},
) { Text("Continue") }
}
Spacer(Modifier.height(22.dp))
Button(onClick = onFullSignIn) { Text("Use full Emby sign-in") }
Spacer(Modifier.height(14.dp))
OutlinedButton(
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 6.dp),
onClick = onFullSignIn,
) { Text("Use full Emby sign-in", fontSize = 13.sp) }
}
}
@@ -329,6 +329,7 @@ fun SearchScreen(
onItemFocused(item)
},
onItemSelected = onItemSelected,
onRequest = viewModel::request,
onRetry = viewModel::retry,
onShowRequests = viewModel::showRequests,
onLoadMore = viewModel::loadMore,
@@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.collect
data class SearchUiState(
val query: String = "",
@@ -269,6 +270,11 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
it.genre == genreAtRequest
it.copy(
requestingCandidateKey = null,
results = if (stillShowingRequest) it.results.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
@@ -424,32 +430,28 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
// 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.search(term) }
.onSuccess { found ->
runCatching {
repository.searchProgress(term).collect { 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@onSuccess
return@collect
}
// 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 = found.distinctItems()
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 = rankSearchResults(term, items)
cache[term] = ranked
viewModelScope.launch { repository.recordSearch(term) }
_state.update {
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
}
if (_state.value.requestMode) loadRequestCandidates(term)
val ranked = if (_state.value.results.isEmpty()) rankSearchResults(term, items) else items
_state.update { it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null) }
}
}
.onFailure { error ->
// A cancelled search is the normal case while typing, not a failure.
if (error is kotlinx.coroutines.CancellationException) throw error
@@ -460,6 +462,11 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
it.copy(isLoading = false, hasSearched = true, errorMessage = friendlyEmbyError(error))
}
}
if (_state.value.genre == null && _state.value.query.trim() == term) {
cache[term] = _state.value.results
viewModelScope.launch { repository.recordSearch(term) }
if (_state.value.requestMode) loadRequestCandidates(term)
}
}
private suspend fun loadRequestCandidates(term: String) {
@@ -497,6 +504,26 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
/** 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)) }
}
private fun searchIdentity(item: BaseItem): String = sequenceOf("Tmdb", "Tvdb", "Imdb")
.mapNotNull { item.providerIds[it]?.takeIf(String::isNotBlank)?.let { id -> "$it:$id" } }
.firstOrNull() ?: "${item.type}:${item.name.trim().lowercase()}"
private fun searchRequestKey(item: BaseItem): String? = when (item.membySource) {
"sonarr" -> item.providerIds["Tvdb"]?.let { "series:$it" }
"radarr" -> item.providerIds["Tmdb"]?.let { "movie:$it" }
else -> null
}
class SearchViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = SearchViewModel(repository) as T
@@ -40,12 +40,15 @@ import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
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.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.shouldSearch
import com.ponzischeme89.memby.ui.requests.RequestCard
import com.ponzischeme89.memby.ui.requests.RequestActionRequest
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyIcon
@@ -67,6 +70,7 @@ internal fun SearchResults(
artworkUrlFor: (BaseItem) -> String?,
onItemFocused: (BaseItem) -> Unit,
onItemSelected: (BaseItem) -> Unit,
onRequest: (GatewayRequestCandidate) -> Unit,
onRetry: () -> Unit,
onShowRequests: () -> Unit,
onLoadMore: () -> Unit,
@@ -126,8 +130,30 @@ internal fun SearchResults(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxSize(),
) {
itemsIndexed(items, key = { _, item -> item.id }) { index, item ->
MediaResultCard(
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(
title = item.name,
subtitle = searchResultSubtitle(item),
detail = item.genres.firstOrNull().orEmpty().ifBlank { "In your library" },
@@ -182,6 +208,24 @@ internal fun SearchResults(
}
}
private fun BaseItem.toSearchRequestCandidate(): GatewayRequestCandidate? {
val mediaType = when (membySource) {
"sonarr" -> "series"
"radarr" -> "movie"
else -> return null
}
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 SearchStartPrompt(voiceAvailable: Boolean) {
val pulse = rememberInfiniteTransition(label = "search-start-prompt").animateFloat(
@@ -319,6 +363,16 @@ private fun searchResultSubtitle(item: BaseItem): String = listOfNotNull(
item.isMovie -> "Film"
else -> null
},
when (item.membySearchState) {
"available" -> "Available"
"requested", "processing", "pending" -> "Requested / monitored"
"requestable" -> "Request"
else -> null
},
).joinToString(FactSeparator)
private fun searchResultKey(item: BaseItem): String = sequenceOf("Tmdb", "Tvdb", "Imdb")
.mapNotNull { provider -> item.providerIds[provider]?.takeIf(String::isNotBlank)?.let { "$provider:$it" } }
.firstOrNull() ?: item.id
private const val LOAD_MORE_ITEMS_AHEAD = 4
@@ -7,10 +7,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -18,16 +14,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.ui.PIN_LENGTH
import com.ponzischeme89.memby.ui.PinBoxesField
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
@@ -157,14 +151,14 @@ internal fun ViewerPinEntry(
Column(Modifier.fillMaxSize().background(MembySurface), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Text("Set a PIN for ${viewer.name}", color = MembyOnSurface, fontSize = 28.sp)
Spacer(Modifier.height(8.dp))
Text("Use 4 to 12 digits to protect this profile.", color = MembyQuietText)
Text("Use a 4-digit PIN to protect this profile.", color = MembyQuietText)
Spacer(Modifier.height(20.dp))
BasicTextField(pin, { pin = it.filter(Char::isDigit).take(12) }, textStyle = TextStyle(color = MembyOnSurface, fontSize = 24.sp), cursorBrush = SolidColor(MembyAccent), visualTransformation = PasswordVisualTransformation(), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.width(220.dp).background(MembyControlSurface, RoundedCornerShape(8.dp)).padding(14.dp))
PinBoxesField(pin, { pin = it })
failure?.let { Text(it, color = MembyAccent) }
Spacer(Modifier.height(20.dp))
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
ViewerActionButton(label = "Cancel", icon = MembyIcon.Close, onClick = onCancel)
ViewerActionButton(label = "Save PIN", icon = MembyIcon.Check, onClick = { onConfirm(pin) }, enabled = !saving && pin.length in 4..12, emphasised = true)
ViewerActionButton(label = "Save PIN", icon = MembyIcon.Check, onClick = { onConfirm(pin) }, enabled = !saving && pin.length == PIN_LENGTH, emphasised = true)
}
}
}
@@ -0,0 +1,80 @@
package com.ponzischeme89.memby.ui
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.data.model.GatewayRecoveryProfile
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.ui.theme.MembyTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Captures the two customer-facing PIN states under `build/screenshots/pin-login/`.
*
* These are deliberately rendered as plain state-driven screens: the repository call is
* outside the visual contract, while the selected profile and masked entry are the parts a
* viewer needs to understand at a glance.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class PinLoginScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `setting a pin`() {
compose.setContent {
MembyTheme {
PinSetupScreen(
viewer = MembyViewer(
id = "main",
name = "Matt",
kind = MembyViewer.KIND_MAIN,
),
onComplete = {},
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/pin-login/pin-setup.png")
}
@Test
fun `logging in with a pin`() {
compose.setContent {
MembyTheme {
DeviceRecoveryScreen(
profiles = listOf(
GatewayRecoveryProfile(
deviceId = "living-room",
viewer = MembyViewer(
id = "v1",
name = "Matt",
kind = MembyViewer.KIND_MAIN,
hasPin = true,
),
),
),
onRecovered = {},
onFullSignIn = {},
)
}
}
compose.onNode(hasClickAction() and hasText("Matt")).performClick()
compose.onNode(hasSetTextAction()).performTextInput("4826")
compose.onRoot().captureRoboImage("build/screenshots/pin-login/pin-login.png")
}
}