0.3.23 - Search radarr/sonarr
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.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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user