diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c1f3e2f..c6932c8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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.33" +val defaultVersionName = "0.3.34" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt index 010e9a2..96a3297 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt @@ -16,7 +16,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusable import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -323,6 +324,13 @@ internal fun DetailPageScaffold( playFocusRequester: FocusRequester, tabFocusRequester: FocusRequester, contentFocusRequester: FocusRequester, + /** + * Owned by the caller, like every other focus target here — the one exception used to be + * created inside this scaffold, which meant a page could not also hand it to + * [RestoreDetailFocus] as the control that should hold focus on the very first frame. + * Defaults for pages that never set [backNavigation] at all. + */ + backNavigationFocusRequester: FocusRequester = remember(item.id) { FocusRequester() }, modifier: Modifier = Modifier, tabs: List = emptyList(), selectedTab: DetailTab = DetailTab.OVERVIEW, @@ -393,7 +401,6 @@ internal fun DetailPageScaffold( List(6) { FocusRequester() } } val actionRequesters = allActionRequesters.take(heroActions.size) - val backNavigationFocusRequester = remember(item.id) { FocusRequester() } var lastHeroIndex by remember(item.id) { mutableIntStateOf(-1) } var focusedZone by remember(item.id) { mutableStateOf(DetailZone.PLAY) } // Reported on the way *in* to a band, never on every focus move inside one. The pane's @@ -862,6 +869,7 @@ private fun DetailHero( } Spacer(Modifier.height(16.dp)) DetailHeroActions( + itemId = item.id, playLabel = playLabel, onPlay = onPlay, playFocusRequester = playFocusRequester, @@ -875,7 +883,7 @@ private fun DetailHero( onPlayFocused() }, onBackNavigationFocused = { - actionCaption = null + actionCaption = backNavigation?.label onPlayFocused() }, onActionFocused = { index -> @@ -897,6 +905,7 @@ private fun DetailHero( */ @Composable private fun DetailHeroActions( + itemId: String, playLabel: String, onPlay: () -> Unit, playFocusRequester: FocusRequester, @@ -912,29 +921,49 @@ private fun DetailHeroActions( Column { // Scrollable rather than merely wide: a Row with more content than the hero has // room for does not shrink its children to fit — each button keeps the intrinsic - // size [MembySecondaryButton]/[MembyPlayButton]/[DetailCircularAction] give it, and - // it is the row's own bounds that give way. Without this, the contextual "Back to - // Search Results" button competing for space with Play and the circular actions - // could push the row past the width the hero has to offer, and a child measured - // with less room than it needs does not politely shrink — it distorts. Scrolling is - // also the one fix that already generalises: any page with enough actions to - // overflow (the franchise "Start with…" action included) gets the same safety net, - // not a special case for this one contextual button. - val actionsScroll = rememberScrollState() + // size [MembyPlayButton]/[DetailCircularAction] give it, and it is the row's own + // bounds that give way. Scrolling is the one fix that already generalises: any page + // with enough actions to overflow (the franchise "Start with…" action included) + // gets the same safety net. + // + // The back-navigation control is a circular icon action, not a labelled pill: a + // "Back to Search Results" pill was wide enough that bringing a freshly focused + // Play into view scrolled the row past zero, leaving the pill's rounded corner + // sheared off against Play's own edge — reading as a deformed Play button rather + // than a clipped neighbour. A 48dp circle, the same size every other secondary + // action here uses, never has to compete with Play for room in the first place. + // + // Keyed on the item: unkeyed, the scroll offset a wide row needed for one title + // would survive into the next composed in the same slot, opening it with Play + // already scrolled out of view for no reason of its own. + val actionsScroll = remember(itemId) { ScrollState(0) } Row( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier + // Cancels the leading Spacer below, so the first button still rests at + // the same x it always has — only the room around it changes. + .offset(x = -ActionsRowEdgeInset) .horizontalScroll(actionsScroll) .focusGroup(), ) { + // `horizontalScroll` clips its content to its own left/right bounds so that + // scrolled-past content is actually hidden — and the leftmost button sits + // flush against that left edge. On focus it grows a few percent around its own + // centre (plus a wider elevation shadow), so without this gap its left edge + // was sheared off by the container's clip rather than by anything about the + // button itself. The gap is scrollable content, so it sits inside the clip + // boundary rather than just moving the boundary, and the offset above puts the + // resting row back where it was before the gap was added. + Spacer(Modifier.width(ActionsRowEdgeInset)) if (backNavigation != null) { - MembySecondaryButton( - label = backNavigation.label, - onClick = backNavigation.onClick, + DetailCircularAction( + action = DetailHeroAction( + icon = MembyIcon.ArrowBack.mark, + description = backNavigation.label, + onClick = backNavigation.onClick, + ), onFocused = onBackNavigationFocused, - compact = true, - icon = MembyIcon.ArrowBack, modifier = Modifier .testTag("detail-back-to-search") .focusRequester(backNavigationFocusRequester), @@ -953,6 +982,9 @@ private fun DetailHeroActions( modifier = Modifier.focusRequester(actionRequesters[index]), ) } + // The same gap at the far end, for a row that overflows and is scrolled all + // the way to its last action. + Spacer(Modifier.width(ActionsRowEdgeInset)) } // Reserved, not conditional: a line that appears when focus reaches the second // button would move the whole hero every time somebody pressed Right. @@ -973,6 +1005,12 @@ private fun DetailHeroActions( /** Reserved for the focused action's description; see [DetailHeroActions]. */ private val DetailActionCaptionHeight = 22.dp +/** + * Room at each end of the actions row's scrollable content, clear of the horizontal-scroll + * clip boundary — see the comment where it's used in [DetailHeroActions]. + */ +private val ActionsRowEdgeInset = 16.dp + /** * The hero synopsis' line box, shared by the text style and by [wholeLines]. * diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt index bcc6955..504d3e1 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt @@ -546,25 +546,34 @@ internal fun EpisodeSeasonPane( seasonEpisodes.isEmpty() -> DetailFocusablePane(emptyFocusRequester) { Text("No episodes are available for this season.", color = DetailQuietText, fontSize = 15.sp) } - else -> LazyColumn( - state = listState, - verticalArrangement = Arrangement.spacedBy(10.dp), - contentPadding = PaddingValues(end = 12.dp, bottom = 18.dp), - modifier = Modifier.fillMaxSize(), - ) { - itemsIndexed(seasonEpisodes, key = { _, episode -> episode.id }) { index, episode -> - EpisodeCard( - episode = episode, - onClick = { onPlay(episode) }, - seasonFocusRequester = aboveEpisodes, - isFirst = index == 0, - isCurrent = episode.id == currentEpisodeId, - modifier = if (index == 0) { - Modifier.focusRequester(firstEpisodeFocusRequester) - } else { - Modifier - }, - ) + else -> { + // The list is scrolled to the *current* episode, not to the top of the season — + // Reacher's Continue Watching card is rarely S01E01. The entry requester has to + // follow that scroll target, or it names a row the LazyColumn never composes and + // every attempt to focus into the list (initial restore, Down from the strip or + // the hero) throws and silently falls through instead of landing on a real card. + val entryIndex = seasonEpisodes.indexOfFirst { it.id == currentEpisodeId } + .let { if (it >= 0) it else 0 } + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(end = 12.dp, bottom = 18.dp), + modifier = Modifier.fillMaxSize(), + ) { + itemsIndexed(seasonEpisodes, key = { _, episode -> episode.id }) { index, episode -> + EpisodeCard( + episode = episode, + onClick = { onPlay(episode) }, + seasonFocusRequester = aboveEpisodes, + isFirst = index == 0, + isCurrent = episode.id == currentEpisodeId, + modifier = if (index == entryIndex) { + Modifier.focusRequester(firstEpisodeFocusRequester) + } else { + Modifier + }, + ) + } } } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt index 187c8d2..fe7229d 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt @@ -167,6 +167,7 @@ internal fun MediaDetailContent( val selectedTab = detailTab(tabKey, tabs) val play = remember(item.id) { FocusRequester() } + val backToSearch = remember(item.id) { FocusRequester() } val tabStrip = remember(item.id) { FocusRequester() } // One requester per pane. Sharing a single "information pane" requester between two // panes attached it to two nodes at once for the 80ms AnimatedContent spends fading the @@ -211,6 +212,7 @@ internal fun MediaDetailContent( relatedReady = visibleRelated.isNotEmpty(), content = contentEntry, contentReady = true, + heroEntry = if (onBackToSearch != null) backToSearch else play, ) var confirmation by remember(item.id) { mutableStateOf(null) } @@ -253,6 +255,7 @@ internal fun MediaDetailContent( backNavigation = onBackToSearch?.let { DetailBackNavigation(label = "Back to Search Results", onClick = it) }, + backNavigationFocusRequester = backToSearch, confirmation = confirmation, onZoneFocused = { zone -> focusedZone = zone @@ -420,10 +423,17 @@ internal fun RestoreDetailFocus( relatedReady: Boolean, content: FocusRequester? = null, contentReady: Boolean = false, + /** + * What actually holds focus on the first frame — Play, unless the page opened with a + * contextual back action (`DetailBackNavigation`) ahead of it, in which case that is + * the control the remote should land on. Defaults to [play] for every page that has + * nothing to put before it. + */ + heroEntry: FocusRequester = play, ) { LaunchedEffect(itemId) { delay(32L) - runCatching { play.requestFocus() } + runCatching { heroEntry.requestFocus() } } // One shot. The readiness flags flip when the network lands, and re-running then would // haul focus out from under a viewer who has already started moving around the page. diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt index e154aac..6989215 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt @@ -317,6 +317,7 @@ internal fun SeriesDetailContent( val selectedTab = detailTab(tabKey, tabs) val play = remember(item.id) { FocusRequester() } + val backToSearch = remember(item.id) { FocusRequester() } val tabStrip = remember(item.id) { FocusRequester() } // One requester per pane: they used to share a single "information pane" requester, // which left it attached to two live nodes while AnimatedContent faded the outgoing one @@ -378,6 +379,7 @@ internal fun SeriesDetailContent( relatedReady = visibleRelated.isNotEmpty(), content = contentEntry, contentReady = true, + heroEntry = if (onBackToSearch != null) backToSearch else play, ) var confirmation by remember(item.id) { mutableStateOf(null) } @@ -430,6 +432,7 @@ internal fun SeriesDetailContent( backNavigation = onBackToSearch?.let { DetailBackNavigation(label = "Back to Search Results", onClick = it) }, + backNavigationFocusRequester = backToSearch, onZoneFocused = { zone -> focusedZone = zone detailPositions.update(item.id) { it.copy(zone = zone) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt index 8a215e7..b95114f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt @@ -332,6 +332,7 @@ fun SearchScreen( ) { SearchGenres( genres = state.genreSuggestions, + selectedGenre = state.genre, entryFocusRequester = genresEntry, keyboardReturnFocusRequester = keyboardReturn, resultsFocusRequester = searchResultsEntry, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt index cd3151a..3841a3f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt @@ -13,6 +13,7 @@ import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.GatewayRequestCandidate import com.ponzischeme89.memby.data.normaliseDiscoveryQuery import com.ponzischeme89.memby.data.shouldDiscover +import com.ponzischeme89.memby.ui.distinctForKeys import com.ponzischeme89.memby.ui.distinctItems import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -59,6 +60,15 @@ data class SearchUiState( * and would return the same cards again, for ever. */ val genreOffset: Int = 0, + /** + * How many titles this genre actually holds, from the backend's own count — never + * [SearchUiState.results]' size. The heading shows this rather than how much has + * loaded so far, or a genre with hundreds of titles reads as having exactly 48 the + * moment the first page lands, which looks identical to "that is all of them" and + * only ever corrects itself if somebody happens to scroll far enough to notice it + * change. Zero before a page has answered. + */ + val genreTotal: Int = 0, /** There is more of this genre to ask for. See [hasMoreGenreItems]. */ val canLoadMore: Boolean = false, /** @@ -263,7 +273,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { genrePageJob?.cancel() _state.update { it.copy( - query = "", results = emptyList(), genreOffset = 0, + query = "", results = emptyList(), genreOffset = 0, genreTotal = 0, isLoading = false, hasSearched = false, errorMessage = null, pagingErrorMessage = null, @@ -285,7 +295,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { genrePageJob?.cancel() _state.update { it.copy( - query = "", genre = name, results = emptyList(), genreOffset = 0, + query = "", genre = name, results = emptyList(), genreOffset = 0, genreTotal = 0, isLoading = true, hasSearched = false, errorMessage = null, isLoadingMore = false, canLoadMore = false, @@ -305,7 +315,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { genrePageJob?.cancel() _state.update { it.copy( - genre = null, results = emptyList(), genreOffset = 0, + genre = null, results = emptyList(), genreOffset = 0, genreTotal = 0, isLoading = false, hasSearched = false, errorMessage = null, isLoadingMore = false, canLoadMore = false, pagingErrorMessage = null, @@ -447,6 +457,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { current.copy( results = items, genreOffset = read, + genreTotal = page.total, isLoading = false, isLoadingMore = false, hasSearched = true, @@ -494,7 +505,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { // than left behind a shorter query they no longer match. _state.update { it.copy( - results = emptyList(), genreOffset = 0, + results = emptyList(), genreOffset = 0, genreTotal = 0, isLoading = false, hasSearched = false, errorMessage = null, requestMessage = null, @@ -527,11 +538,21 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { // here. Drop it unless the field still asks the exact question that // produced the answer. if (!_state.value.answers(term)) return@onSuccess + // Emby ids are enough to satisfy distinctItems(), but the results list is + // rendered keyed on cross-provider identity (searchResultKey in + // SearchResults.kt, the same shape as searchIdentity below) so that a title + // the library and Sonarr/Radarr both know about is one card, not two. Two + // distinct Emby items sharing one TMDb id — a duplicate library import is + // the common case — survive distinctItems() but collide on that render key, + // which crashes the LazyColumn ("deduplicate, never disambiguate" in + // ui/ListKeys.kt). Applied here, at the point the data enters state, rather + // than in the composable. + val deduped = found.distinctItems().distinctForKeys(::searchIdentity) // Keep the backend's relevance and personalisation order, but never let // those softer signals bury the title the viewer typed exactly. This // matters especially in the one-column list, where tenth place is ten rows // away rather than the second grid row. - val ranked = rankSearchResults(term, found.distinctItems()) + val ranked = rankSearchResults(term, deduped) // A decisive exact match must read as decisive: see [curateStrongMatches]. val curated = curateStrongMatches(term, ranked) SearchTrace.local(id, term, curated.size, cacheHit = false, @@ -575,7 +596,10 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { val startedAt = System.currentTimeMillis() runCatching { repository.discover(term, DISCOVERY_LIMIT) } .onSuccess { result -> - val items = result.items.distinctItems() + // Same render key, same failure mode as the library list above: Sonarr and + // Radarr can each offer a candidate for one film, and both surviving + // distinctItems() only means their (often synthetic) ids differ. + val items = result.items.distinctItems().distinctForKeys(::searchIdentity) SearchTrace.discovery(state.value.searchId, term, items.size, cacheHit = false, partial = result.partial, elapsedMs = System.currentTimeMillis() - startedAt) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchGenres.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchGenres.kt index cbecfd6..634d9d9 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchGenres.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchGenres.kt @@ -27,12 +27,21 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Text import com.ponzischeme89.memby.ui.FocusScaleContainer +import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyOnSurface /** Search's compact discovery strip. Genre colours stay recognisable but sit near-black. */ @Composable internal fun SearchGenres( genres: List, + /** + * The genre currently open below the strip, or null while browsing. Focus is + * momentary — it moves the instant the remote does, most obviously the moment the + * shelf takes it on load — but which genre is *open* is a fact that outlives that + * move, and a chip that only lit up while literally under the D-pad gave no answer + * to "which genre am I looking at" the moment somebody scrolled into the results. + */ + selectedGenre: String?, entryFocusRequester: FocusRequester, keyboardReturnFocusRequester: FocusRequester, resultsFocusRequester: FocusRequester, @@ -67,16 +76,28 @@ internal fun SearchGenres( .then(if (index == 0) Modifier.focusRequester(entryFocusRequester) else Modifier) .focusProperties { if (index == 0) left = keyboardReturnFocusRequester - if (resultsHaveFocusTarget) down = resultsFocusRequester + // Explicit either way: with nothing below to search results for + // (the empty/browsing state, before anything has been typed), + // leaving `down` unset hands the press to Compose's default + // nearest-neighbour search, which can land on an arbitrary + // keyboard key or fail to move at all. The keyboard's own + // "last key used" target is the one every other escape from + // this strip already returns to. + down = if (resultsHaveFocusTarget) resultsFocusRequester else keyboardReturnFocusRequester }, ) { focused -> + val selected = !focused && genre == selectedGenre Box( Modifier .fillMaxSize() .background(if (focused) colour.copy(alpha = 0.98f) else colour) .border( - if (focused) 2.dp else 1.dp, - if (focused) Color.White else Color.White.copy(alpha = 0.10f), + if (focused || selected) 2.dp else 1.dp, + when { + focused -> Color.White + selected -> MembyAccent + else -> Color.White.copy(alpha = 0.10f) + }, GenreShape, ) .padding(horizontal = 11.dp, vertical = 9.dp), @@ -92,7 +113,11 @@ internal fun SearchGenres( ) Text( "›", - color = Color.White.copy(alpha = if (focused) 1f else 0.72f), + color = when { + focused -> Color.White + selected -> MembyAccent + else -> Color.White.copy(alpha = 0.72f) + }, fontSize = 22.sp, fontWeight = FontWeight.Medium, modifier = Modifier.align(Alignment.CenterEnd), diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchResults.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchResults.kt index d51252c..2ddc6c2 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchResults.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchResults.kt @@ -374,9 +374,16 @@ private fun SearchResultsHeading( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), ) - if (resultCount > 0 && !state.isLoading) { + // A genre shelf's count is the backend's own total, never how much has loaded — + // a genre holding hundreds of titles would otherwise read as holding exactly 48 + // the moment the first page lands, indistinguishable from that genuinely being + // all of them, and only ever correct itself if somebody scrolls far enough to + // notice it change underneath them. A plain search has no such distinction: the + // backend answers everything at once, so what loaded is the whole count. + val displayedCount = if (state.genre != null) state.genreTotal else resultCount + if (displayedCount > 0 && !state.isLoading) { Spacer(Modifier.width(9.dp)) - Text(resultCount.toString(), color = MembyMutedText, fontSize = 14.sp) + Text(displayedCount.toString(), color = MembyMutedText, fontSize = 14.sp) } if (state.isLoading && resultCount > 0) { Spacer(Modifier.width(9.dp))