0.2.54
This commit is contained in:
@@ -42,7 +42,7 @@ val projectNoticeText =
|
||||
|
||||
// A release workflow can derive the app version from its Git tag without editing the
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.2.53"
|
||||
val defaultVersionName = "0.2.54"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -305,6 +305,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
|
||||
/** True when the backend can return the whole home screen in one request. */
|
||||
val supportsBatchHome: Boolean get() = ServerConfig.isGateway
|
||||
val supportsMediaRequests: Boolean get() = ServerConfig.isGateway
|
||||
|
||||
private fun apiFor(serverUrl: String): EmbyApi {
|
||||
val base = normalizeServerUrl(serverUrl)
|
||||
|
||||
@@ -9,7 +9,8 @@ import java.util.UUID
|
||||
|
||||
/**
|
||||
* Builds one ordered, user-scoped foreground journey. Callers provide controlled labels;
|
||||
* this collector has no field for titles, queries, setting values or arbitrary metadata.
|
||||
* Item names are retained so the operator can recognise what fired an event. Search
|
||||
* queries, setting values and arbitrary metadata remain outside this collector.
|
||||
*/
|
||||
class JourneyAnalytics(
|
||||
private val userId: String,
|
||||
@@ -30,7 +31,7 @@ class JourneyAnalytics(
|
||||
feature: String = "",
|
||||
source: String = "",
|
||||
target: String = "",
|
||||
itemId: String = "",
|
||||
itemName: String = "",
|
||||
itemType: String = "",
|
||||
outcome: String = "",
|
||||
) = synchronized(lock) {
|
||||
@@ -45,7 +46,7 @@ class JourneyAnalytics(
|
||||
feature = clean(feature),
|
||||
source = clean(source),
|
||||
target = clean(target),
|
||||
itemId = clean(itemId),
|
||||
itemName = cleanName(itemName),
|
||||
itemType = clean(itemType),
|
||||
outcome = clean(outcome),
|
||||
occurredAt = timestamp(),
|
||||
@@ -68,6 +69,8 @@ class JourneyAnalytics(
|
||||
it.isLetterOrDigit() || it == '-' || it == '_' || it == '.' || it == ':'
|
||||
}
|
||||
|
||||
private fun cleanName(value: String): String = value.trim().take(160).filterNot(Char::isISOControl)
|
||||
|
||||
private fun timestamp(): String = iso8601.get()!!.format(Date(now()))
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -434,6 +434,7 @@ data class GatewayRequestCandidate(
|
||||
val overview: String = "",
|
||||
val posterUrl: String = "",
|
||||
val alreadyAdded: Boolean = false,
|
||||
val inLibrary: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -736,7 +737,7 @@ data class GatewayJourneyEvent(
|
||||
val feature: String = "",
|
||||
val source: String = "",
|
||||
val target: String = "",
|
||||
val itemId: String = "",
|
||||
val itemName: String = "",
|
||||
val itemType: String = "",
|
||||
val outcome: String = "",
|
||||
val occurredAt: String = "",
|
||||
|
||||
@@ -373,21 +373,6 @@ private fun FeaturedMovieCard(
|
||||
),
|
||||
),
|
||||
)
|
||||
// Play is measured before the words, and that is the whole layout.
|
||||
//
|
||||
// This column is the height of a fixed-height card. A Column hands each child
|
||||
// the height left after the ones before it, so the chip — being last — was
|
||||
// given whatever a two-line title had not already taken, and rendered as a
|
||||
// green sliver with its label squeezed out of it. Not clipped: *compressed*,
|
||||
// which is why it looked malformed rather than missing.
|
||||
//
|
||||
// Putting the text in a `weight(1f, fill = false)` child inverts the order:
|
||||
// weighted children are measured from what is left over, so the spacer and the
|
||||
// chip take their natural size first and the prose is what gives way. The
|
||||
// synopsis still stands down on its own when the title wraps, so in practice
|
||||
// nothing has to be cut at all — but the button can no longer be the thing that
|
||||
// pays for a long title, whatever the artwork, the ratings strip or the
|
||||
// viewport do.
|
||||
var titleLines by remember(item.id) { mutableIntStateOf(1) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -472,8 +457,6 @@ private fun FeaturedMovieCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(14.dp))
|
||||
MembyPlayChip(label = "Play", focused = focused)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,9 +207,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
|
||||
fun trackJourney(
|
||||
category: String, action: String, screen: String = "", feature: String = "",
|
||||
source: String = "", target: String = "", itemId: String = "", itemType: String = "",
|
||||
source: String = "", target: String = "", itemName: String = "", itemType: String = "",
|
||||
outcome: String = "",
|
||||
) = journey.track(category, action, screen, feature, source, target, itemId, itemType, outcome)
|
||||
) = journey.track(category, action, screen, feature, source, target, itemName, itemType, outcome)
|
||||
|
||||
fun endJourney(screen: String) { journey.end(screen); flushAnalytics() }
|
||||
|
||||
|
||||
@@ -2129,7 +2129,7 @@ private fun HomeScreen(
|
||||
homeViewModel.trackJourney(
|
||||
category = "playback", action = "request", screen = selectedDestination.name.lowercase(),
|
||||
feature = "playback", source = returnRowId.orEmpty(), target = "player",
|
||||
itemId = item.id, itemType = item.type,
|
||||
itemName = item.name, itemType = item.type,
|
||||
)
|
||||
homeViewModel.flushAnalytics()
|
||||
launchingItem = item
|
||||
@@ -2490,7 +2490,7 @@ private fun HomeScreen(
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open", screen = "search",
|
||||
feature = "search", source = "search_results", target = "details",
|
||||
itemId = item.id, itemType = item.type,
|
||||
itemName = item.name, itemType = item.type,
|
||||
)
|
||||
detailsAiringNotice = null
|
||||
detailsItem = item
|
||||
@@ -2520,7 +2520,7 @@ private fun HomeScreen(
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open", screen = "calendar",
|
||||
feature = "tv_calendar", source = "calendar_day",
|
||||
target = "details", itemId = item.id, itemType = item.type,
|
||||
target = "details", itemName = item.name, itemType = item.type,
|
||||
)
|
||||
// The same substitution the schedule row makes: a calendar card
|
||||
// is an episode that has not aired, so what was asked for is the
|
||||
@@ -2567,7 +2567,7 @@ private fun HomeScreen(
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open", screen = "genre_browser",
|
||||
feature = "genre_browse", source = "genre_results", target = "details",
|
||||
itemId = item.id, itemType = item.type,
|
||||
itemName = item.name, itemType = item.type,
|
||||
)
|
||||
detailsAiringNotice = null
|
||||
detailsItem = item
|
||||
@@ -2709,7 +2709,13 @@ private fun HomeScreen(
|
||||
returnRowId = HOME_HERO_ROW_ID
|
||||
returnItemId = item.id
|
||||
homeViewModel.focusItem(item)
|
||||
playItem(item)
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open", screen = "home",
|
||||
feature = "hero", source = HOME_HERO_ROW_ID, target = "details",
|
||||
itemName = item.name, itemType = item.type,
|
||||
)
|
||||
detailsAiringNotice = null
|
||||
detailsItem = item
|
||||
},
|
||||
modifier = Modifier.height(metadataHeight),
|
||||
)
|
||||
@@ -2800,7 +2806,7 @@ private fun HomeScreen(
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open", screen = "shows",
|
||||
feature = "my_shows", source = "my_shows", target = "my_show_details",
|
||||
itemId = it.itemId,
|
||||
itemName = it.title,
|
||||
)
|
||||
myShowReturnItemId = it.itemId
|
||||
selectedMyShow = it
|
||||
@@ -2971,7 +2977,7 @@ private fun HomeScreen(
|
||||
category = "content", action = "open",
|
||||
screen = selectedDestination.name.lowercase(), feature = row.kind.name.lowercase(),
|
||||
source = row.id, target = if (item.membyPlayable) "details" else "content_action",
|
||||
itemId = item.id, itemType = item.type,
|
||||
itemName = item.name, itemType = item.type,
|
||||
)
|
||||
// A schedule card is an episode that has not aired, so
|
||||
// it is not playable and has no page of its own. What
|
||||
@@ -3223,7 +3229,7 @@ private fun HomeScreen(
|
||||
homeViewModel.trackJourney(
|
||||
category = "recommendations", action = "open", screen = "details",
|
||||
feature = "related", source = "related", target = "details",
|
||||
itemId = related.id, itemType = related.type,
|
||||
itemName = related.name, itemType = related.type,
|
||||
)
|
||||
detailsTrail = detailsTrail + selected
|
||||
restoreDetailPosition = false
|
||||
@@ -3249,7 +3255,7 @@ private fun HomeScreen(
|
||||
onToggleFavorite = { item, saved ->
|
||||
homeViewModel.trackJourney(
|
||||
category = "library", action = if (saved) "favourite" else "unfavourite",
|
||||
screen = "details", feature = "favorites", itemId = item.id, itemType = item.type,
|
||||
screen = "details", feature = "favorites", itemName = item.name, itemType = item.type,
|
||||
outcome = "success",
|
||||
)
|
||||
homeViewModel.setFavorite(item, saved)
|
||||
@@ -3258,7 +3264,7 @@ private fun HomeScreen(
|
||||
onToggleMyShow = { item, saved ->
|
||||
homeViewModel.trackJourney(
|
||||
category = "library", action = if (saved) "follow" else "unfollow",
|
||||
screen = "details", feature = "my_shows", itemId = item.id, itemType = item.type,
|
||||
screen = "details", feature = "my_shows", itemName = item.name, itemType = item.type,
|
||||
outcome = "success",
|
||||
)
|
||||
// Optimistic, the way a favourite already is. Following a show is a
|
||||
@@ -3297,7 +3303,7 @@ private fun HomeScreen(
|
||||
onTogglePlayed = { item, played ->
|
||||
homeViewModel.trackJourney(
|
||||
category = "library", action = if (played) "mark_played" else "mark_unplayed",
|
||||
screen = "details", feature = "played_status", itemId = item.id, itemType = item.type,
|
||||
screen = "details", feature = "played_status", itemName = item.name, itemType = item.type,
|
||||
outcome = "success",
|
||||
)
|
||||
homeViewModel.setPlayed(item, played)
|
||||
|
||||
@@ -213,7 +213,8 @@ fun SearchScreen(
|
||||
state.errorMessage != null && state.results.isEmpty() -> true
|
||||
state.isDiscovery -> discoveryItems.isNotEmpty() ||
|
||||
state.suggestions.any { it.kind == SearchSuggestion.Kind.GENRE }
|
||||
else -> state.results.isNotEmpty() || state.requestCandidates.isNotEmpty()
|
||||
else -> state.results.isNotEmpty() || state.requestCandidates.isNotEmpty() ||
|
||||
(state.requestsAvailable && shouldSearch(state.query))
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } }
|
||||
@@ -251,6 +252,7 @@ fun SearchScreen(
|
||||
|
||||
BackHandler {
|
||||
when {
|
||||
state.requestMode -> viewModel.hideRequests()
|
||||
// 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()
|
||||
@@ -319,6 +321,7 @@ fun SearchScreen(
|
||||
onItemSelected = onItemSelected,
|
||||
onRetry = viewModel::retry,
|
||||
onRequest = viewModel::request,
|
||||
onShowRequests = viewModel::showRequests,
|
||||
onSuggestionSelected = viewModel::onQueryChanged,
|
||||
onGenreSelected = viewModel::onGenreSelected,
|
||||
onBackFromGenre = closeGenre,
|
||||
@@ -443,7 +446,7 @@ private fun QueryField(
|
||||
color = if (query.isEmpty()) Muted else Heading,
|
||||
// Fixed size rather than shrinking as the query grows: readable at three
|
||||
// metres matters more than fitting a long query on one line.
|
||||
fontSize = if (query.isEmpty()) 15.sp else 17.sp,
|
||||
fontSize = if (query.isEmpty()) 12.sp else 17.sp,
|
||||
fontWeight = if (query.isEmpty()) FontWeight.Normal else FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
// A long query scrolls off the *start*, so the letters just typed stay visible.
|
||||
@@ -701,6 +704,7 @@ private fun ResultsPane(
|
||||
onItemSelected: (BaseItem) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onRequest: (GatewayRequestCandidate) -> Unit,
|
||||
onShowRequests: () -> Unit,
|
||||
onSuggestionSelected: (String) -> Unit,
|
||||
onGenreSelected: (String) -> Unit,
|
||||
onBackFromGenre: () -> Unit,
|
||||
@@ -726,6 +730,9 @@ private fun ResultsPane(
|
||||
state = state,
|
||||
showingDiscovery = showingDiscovery,
|
||||
onBackFromGenre = onBackFromGenre,
|
||||
onShowRequests = onShowRequests,
|
||||
requestFocusRequester = resultsEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
)
|
||||
val genres = state.suggestions.filter { it.kind == SearchSuggestion.Kind.GENRE }
|
||||
val recent = state.suggestions.filter { it.kind == SearchSuggestion.Kind.RECENT }
|
||||
@@ -767,7 +774,7 @@ private fun ResultsPane(
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
)
|
||||
!showingDiscovery && state.results.isEmpty() && state.requestCandidates.isNotEmpty() ->
|
||||
!showingDiscovery && state.requestMode ->
|
||||
RequestOptions(
|
||||
state = state,
|
||||
resultsEntry = resultsEntry,
|
||||
@@ -805,6 +812,9 @@ private fun ResultsHeading(
|
||||
state: SearchUiState,
|
||||
showingDiscovery: Boolean,
|
||||
onBackFromGenre: () -> Unit,
|
||||
onShowRequests: () -> Unit,
|
||||
requestFocusRequester: FocusRequester,
|
||||
keyboardReturn: FocusRequester,
|
||||
) {
|
||||
val title = when {
|
||||
showingDiscovery -> "Browse your library"
|
||||
@@ -854,6 +864,17 @@ private fun ResultsHeading(
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text("${state.results.size}", color = Muted, fontSize = 16.sp)
|
||||
}
|
||||
if (state.requestsAvailable && state.genre == null && !showingDiscovery && !state.requestMode) {
|
||||
Spacer(Modifier.width(14.dp))
|
||||
MembyChoiceChip(
|
||||
label = "Request",
|
||||
selected = false,
|
||||
onClick = onShowRequests,
|
||||
modifier = Modifier
|
||||
.then(if (state.results.isEmpty()) Modifier.focusRequester(requestFocusRequester) else Modifier)
|
||||
.focusProperties { left = keyboardReturn },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1008,7 +1029,7 @@ private fun RequestOptions(
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Request something new", color = Heading, fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"We found a few close matches. Choose the exact movie or series you want added.",
|
||||
"Search Radarr and Sonarr, then choose the exact movie or series you want added.",
|
||||
color = Muted,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 2,
|
||||
@@ -1022,7 +1043,7 @@ private fun RequestOptions(
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
LazyVerticalGrid(
|
||||
if (state.requestCandidates.isNotEmpty()) LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
@@ -1043,6 +1064,15 @@ private fun RequestOptions(
|
||||
)
|
||||
}
|
||||
}
|
||||
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))
|
||||
@@ -1077,13 +1107,14 @@ private fun RequestCandidateCard(
|
||||
) {
|
||||
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.alreadyAdded && !busy) onRequest() },
|
||||
onClick = { if (!candidate.inLibrary && !candidate.alreadyAdded && !busy) onRequest() },
|
||||
contentDescription = "${candidate.title}, $mediaLabel, $actionLabel",
|
||||
modifier = modifier,
|
||||
) { focused ->
|
||||
@@ -1095,7 +1126,7 @@ private fun RequestCandidateCard(
|
||||
.background(
|
||||
when {
|
||||
focused -> MembyAccentMuted
|
||||
candidate.alreadyAdded -> MembyAccentMuted
|
||||
candidate.inLibrary || candidate.alreadyAdded -> MembyAccentMuted
|
||||
else -> MembyControlSurface
|
||||
},
|
||||
)
|
||||
@@ -1103,7 +1134,7 @@ private fun RequestCandidateCard(
|
||||
width = if (focused) 2.dp else 1.dp,
|
||||
color = when {
|
||||
focused -> Accent
|
||||
candidate.alreadyAdded -> Accent.copy(alpha = 0.45f)
|
||||
candidate.inLibrary || candidate.alreadyAdded -> Accent.copy(alpha = 0.45f)
|
||||
else -> Color.White.copy(alpha = 0.10f)
|
||||
},
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -1169,17 +1200,19 @@ private fun RequestCandidateCard(
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (candidate.alreadyAdded) {
|
||||
if (candidate.inLibrary || candidate.alreadyAdded) {
|
||||
Icon(Icons.Default.CheckCircle, contentDescription = null, tint = Accent, modifier = Modifier.size(14.dp))
|
||||
Spacer(Modifier.width(5.dp))
|
||||
}
|
||||
Text(
|
||||
actionLabel,
|
||||
color = if (candidate.alreadyAdded || focused) Accent else KeyLabel,
|
||||
color = if (candidate.inLibrary || candidate.alreadyAdded || focused) Accent else KeyLabel,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
if (!candidate.alreadyAdded && !busy) Text(" →", color = Accent, fontSize = 13.sp)
|
||||
if (!candidate.inLibrary && !candidate.alreadyAdded && !busy) {
|
||||
Text(" →", color = Accent, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ data class SearchUiState(
|
||||
val errorMessage: String? = null,
|
||||
val pagingErrorMessage: String? = null,
|
||||
val requestCandidates: List<GatewayRequestCandidate> = emptyList(),
|
||||
val requestsAvailable: Boolean = false,
|
||||
val requestMode: Boolean = false,
|
||||
val requestLookupLoading: Boolean = false,
|
||||
val requestingCandidateKey: String? = null,
|
||||
val requestMessage: String? = null,
|
||||
@@ -91,7 +93,9 @@ data class SearchUiState(
|
||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||
class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(SearchUiState())
|
||||
private val _state = MutableStateFlow(
|
||||
SearchUiState(requestsAvailable = repository.supportsMediaRequests),
|
||||
)
|
||||
val state: StateFlow<SearchUiState> = _state.asStateFlow()
|
||||
|
||||
private val queryFlow = MutableStateFlow("")
|
||||
@@ -176,6 +180,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
query = "", results = emptyList(), genreOffset = 0,
|
||||
isLoading = false, hasSearched = false,
|
||||
errorMessage = null, requestCandidates = emptyList(),
|
||||
requestMode = false,
|
||||
pagingErrorMessage = null,
|
||||
requestLookupLoading = false, requestMessage = null,
|
||||
requestMessageIsError = false, genre = null,
|
||||
@@ -199,6 +204,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
isLoading = true,
|
||||
hasSearched = false, errorMessage = null, isLoadingMore = false,
|
||||
canLoadMore = false, requestCandidates = emptyList(),
|
||||
requestMode = false,
|
||||
pagingErrorMessage = null,
|
||||
requestLookupLoading = false, requestMessage = null,
|
||||
requestMessageIsError = false,
|
||||
@@ -261,7 +267,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
}
|
||||
|
||||
fun request(candidate: GatewayRequestCandidate) {
|
||||
if (candidate.alreadyAdded || state.value.requestingCandidateKey != null) return
|
||||
if (candidate.alreadyAdded || candidate.inLibrary || state.value.requestingCandidateKey != null) return
|
||||
val candidateKey = "${candidate.mediaType}:${candidate.foreignId}"
|
||||
val queryAtRequest = state.value.query.trim()
|
||||
val genreAtRequest = state.value.genre
|
||||
@@ -304,6 +310,23 @@ 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.
|
||||
@@ -408,7 +431,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
_state.update {
|
||||
it.copy(results = cached, isLoading = false, hasSearched = true, errorMessage = null)
|
||||
}
|
||||
if (cached.isEmpty()) loadRequestCandidates(term)
|
||||
if (_state.value.requestMode) loadRequestCandidates(term)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -442,7 +465,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
_state.update {
|
||||
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
|
||||
}
|
||||
if (ranked.isEmpty()) loadRequestCandidates(term)
|
||||
if (_state.value.requestMode) loadRequestCandidates(term)
|
||||
}
|
||||
.onFailure { error ->
|
||||
// A cancelled search is the normal case while typing, not a failure.
|
||||
@@ -457,10 +480,19 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
}
|
||||
|
||||
private suspend fun loadRequestCandidates(term: String) {
|
||||
_state.update { it.copy(requestLookupLoading = true) }
|
||||
_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) {
|
||||
|
||||
@@ -11,14 +11,14 @@ class JourneyAnalyticsTest {
|
||||
val analytics = JourneyAnalytics(userId = "user-1", now = { 1_700_000_000_000L }, journeyId = "journey-1")
|
||||
analytics.track(
|
||||
category = "content", action = "open", screen = "home screen",
|
||||
feature = "latest_movies", itemId = "item/unsafe", itemType = "Movie",
|
||||
feature = "latest_movies", itemName = "Whale Rider", itemType = "Movie",
|
||||
)
|
||||
val events = analytics.drain()
|
||||
|
||||
assertEquals(listOf(0, 1), events.map { it.sequence })
|
||||
assertEquals("user-1", events.last().userId)
|
||||
assertEquals("homescreen", events.last().screen)
|
||||
assertEquals("itemunsafe", events.last().itemId)
|
||||
assertEquals("Whale Rider", events.last().itemName)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -21,7 +21,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -31,6 +31,7 @@ import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import org.junit.Before
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -57,8 +58,8 @@ class HomeMovieHeroScreenshotTest {
|
||||
|
||||
/**
|
||||
* The case the audit reproduced: with a title long enough to wrap, the card's content
|
||||
* column overflowed its fixed height and the green Play chip — the last thing in the
|
||||
* column — was clipped away entirely. It has to survive here.
|
||||
* column used to overflow its fixed height around the individual Play chip. The hero
|
||||
* now opens the listing page as one clear card, including for a wrapping title.
|
||||
*/
|
||||
@Test
|
||||
fun `home hero with a wrapping title`() {
|
||||
@@ -81,7 +82,7 @@ class HomeMovieHeroScreenshotTest {
|
||||
|
||||
/**
|
||||
* The hero the gateway composes. Two things are only checkable by looking: the reason
|
||||
* takes the synopsis's place rather than adding a line above the Play chip, and a
|
||||
* takes the synopsis's place without crowding the facts beneath it, and a
|
||||
* series premiere leading the launcher has to read as deliberate rather than as a TV
|
||||
* show that wandered into the movie hero.
|
||||
*/
|
||||
@@ -211,7 +212,7 @@ class HomeMovieHeroScreenshotTest {
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Play").fetchSemanticsNode()
|
||||
assertTrue(compose.onAllNodesWithText("Play").fetchSemanticsNodes().isEmpty())
|
||||
compose.onRoot().captureRoboImage("build/screenshots/home-movie-hero/$name.png")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user