From 0ff02514d7b4b89b4323d90d5ed0f90141628d2e Mon Sep 17 00:00:00 2001 From: silentbil Date: Fri, 9 Oct 2026 22:20:43 +0300 Subject: [PATCH] feat(search): recent searches, clear button, encoded addon search URLs - Recent searches: the profile's last 8 queries, shown as a chip row under the search bar while it is empty. A query is kept on submit or when a result is opened, and only if it found something. One "Clear" chip removes them all; the row has its own D-pad stop between the search bar and the filters. - The search bar shows a clear button while it holds a query (D-pad toward its end, or tap on touch). - Addon catalog search encodes the catalog type and id like the other catalog URLs, so types such as "Live Docs" are no longer sent raw. --- .../ui/screens/search/SearchLiveDeviceTest.kt | 4 +- .../data/repository/RecentSearchRepository.kt | 83 ++++++++ .../tv/data/repository/StreamRepository.kt | 4 +- .../tv/ui/screens/search/RecentSearchRow.kt | 191 ++++++++++++++++++ .../tv/ui/screens/search/SearchScreen.kt | 181 +++++++++++++++-- .../tv/ui/screens/search/SearchViewModel.kt | 56 ++++- app/src/main/res/values/strings.xml | 3 + .../repository/NativeAddonTypeStreamTest.kt | 16 ++ .../tv/ui/screens/search/DiscoverGridTest.kt | 2 +- .../tv/ui/screens/search/RecentSearchTest.kt | 127 ++++++++++++ .../ui/screens/search/SearchViewModelTest.kt | 2 +- .../screens/search/SearchWatchedLookupTest.kt | 2 +- 12 files changed, 645 insertions(+), 26 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/RecentSearchRepository.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/screens/search/RecentSearchRow.kt create mode 100644 app/src/test/kotlin/com/arflix/tv/ui/screens/search/RecentSearchTest.kt diff --git a/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/search/SearchLiveDeviceTest.kt b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/search/SearchLiveDeviceTest.kt index 26f2b7fe9..43156f0ea 100644 --- a/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/search/SearchLiveDeviceTest.kt +++ b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/search/SearchLiveDeviceTest.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import com.arflix.tv.data.repository.RecentSearchRepository import com.arflix.tv.di.RepositoryAccessEntryPoint import com.arflix.tv.util.DeviceType import com.arflix.tv.util.LocalDeviceType @@ -37,7 +38,8 @@ class SearchLiveDeviceTest { lateinit var model: SearchViewModel var openedId: Int? = null compose.runOnUiThread { - model = SearchViewModel(repository, access.traktRepository()) + model = SearchViewModel(repository, access.traktRepository(), + RecentSearchRepository(instrumentation.targetContext, access.profileManager())) compose.activity.viewModelStore.put("live-search", model) } compose.setContent { diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/RecentSearchRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/RecentSearchRepository.kt new file mode 100644 index 000000000..8b3469025 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/RecentSearchRepository.kt @@ -0,0 +1,83 @@ +package com.arflix.tv.data.repository + +import android.content.Context +import android.util.Log +import androidx.datastore.preferences.core.edit +import com.arflix.tv.util.settingsDataStore +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** The active profile's last [MAX_RECENT_SEARCHES] search queries, newest first. */ +@Singleton +class RecentSearchRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val profileManager: ProfileManager, +) { + private val gson = Gson() + + @OptIn(ExperimentalCoroutinesApi::class) + val recentSearches: Flow> = profileManager.currentProfileId + .flatMapLatest { profileId -> + val key = profileManager.profileStringKeyFor(profileId, KEY) + context.settingsDataStore.data.map { decode(it[key]) } + } + .catch { e -> + Log.w(TAG, "read failed: ${e.message}") + emit(emptyList()) + } + .distinctUntilChanged() + + suspend fun add(query: String) = update { withRecentSearch(it, query) } + + suspend fun clear() = update { emptyList() } + + private suspend fun update(change: (List) -> List) { + try { + val key = profileManager.profileStringKey(KEY) + context.settingsDataStore.edit { prefs -> + val updated = change(decode(prefs[key])) + if (updated.isEmpty()) prefs.remove(key) else prefs[key] = gson.toJson(updated) + } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Log.w(TAG, "save failed: ${e.message}") + } + } + + private fun decode(raw: String?): List { + if (raw.isNullOrBlank()) return emptyList() + // Gson can put nulls into a List despite its type. + return runCatching { gson.fromJson>(raw, LIST_TYPE) }.getOrNull() + .orEmpty() + .mapNotNull { it?.trim()?.takeIf { query -> query.isNotEmpty() } } + .distinctBy { it.lowercase() } + .take(MAX_RECENT_SEARCHES) + } + + private companion object { + const val TAG = "RecentSearches" + const val KEY = "recent_searches_v1" + val LIST_TYPE = object : TypeToken>() {}.type + } +} + +const val MAX_RECENT_SEARCHES = 8 + +/** + * [query] moved to the front of [current]. The same query in another case counts as the same + * search, so it is moved rather than listed twice. + */ +fun withRecentSearch(current: List, query: String, max: Int = MAX_RECENT_SEARCHES): List { + val trimmed = query.trim() + if (trimmed.isEmpty()) return current + return (listOf(trimmed) + current.filterNot { it.equals(trimmed, ignoreCase = true) }).take(max) +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index 76e87ba7b..4afa825b3 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -1563,7 +1563,7 @@ class StreamRepository @Inject constructor( withContext(Dispatchers.IO) { val trimmed = query.trim() if (trimmed.isEmpty()) return@withContext emptyList() - val encoded = URLEncoder.encode(trimmed, "UTF-8").replace("+", "%20") + val encoded = encodePathSegment(trimmed) val targets = installedAddons.first() .filter { it.isInstalled && it.isEnabled && it.runtimeKind == RuntimeKind.STREMIO && !it.url.isNullOrBlank() } .flatMap { addon -> @@ -1589,7 +1589,7 @@ class StreamRepository @Inject constructor( try { val (baseUrl, queryParams) = getAddonBaseUrl(addon.url.orEmpty()) val query = queryParams?.takeIf { it.isNotBlank() }?.let { "?$it" }.orEmpty() - val url = "$baseUrl/catalog/${catalog.type}/${catalog.id}/search=$encoded.json$query" + val url = "$baseUrl/catalog/${encodePathSegment(catalog.type)}/${encodePathSegment(catalog.id)}/search=$encoded.json$query" val response = streamApi.getAddonCatalog(url) val metas = response.metas ?: response.items ?: emptyList() if (metas.isEmpty()) null diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/RecentSearchRow.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/RecentSearchRow.kt new file mode 100644 index 000000000..80f7e1e4a --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/RecentSearchRow.kt @@ -0,0 +1,191 @@ +package com.arflix.tv.ui.screens.search + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.History +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Text +import com.arflix.tv.R +import com.arflix.tv.ui.focus.arvioDpadFocusGroup +import com.arflix.tv.ui.skin.resolveAccentColor +import com.arflix.tv.ui.theme.ArflixTypography + +/** One D-pad stop in the recent-searches row: each query, then one stop that clears them all. */ +internal sealed interface RecentSearchSlot { + data class Query(val query: String) : RecentSearchSlot + data object Clear : RecentSearchSlot +} + +internal fun recentSearchSlotCount(queries: List): Int = + if (queries.isEmpty()) 0 else queries.size + 1 + +internal fun recentSearchSlot(queries: List, index: Int): RecentSearchSlot? = when { + index < 0 || index >= recentSearchSlotCount(queries) -> null + index == queries.size -> RecentSearchSlot.Clear + else -> RecentSearchSlot.Query(queries[index]) +} + +private val CHIP_HEIGHT = 36.dp +private val CHIP_RADIUS = 8.dp + +/** + * The profile's recent searches, shown under the search bar while it is empty. Focus is + * painted from [focusedIndex], like the filter row below it: the screen's D-pad handler owns + * the row, and the row itself is the only native focus target. + */ +@Composable +internal fun RecentSearchRow( + queries: List, + focusedIndex: Int, + isRowFocused: Boolean, + isTouchDevice: Boolean, + onSearch: (String) -> Unit, + onClear: () -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester? = null +) { + if (queries.isEmpty()) return + val rowState = rememberLazyListState() + // List items: the label, one per query, then the clear chip. + LaunchedEffect(focusedIndex, isRowFocused) { + if (!isRowFocused) return@LaunchedEffect + val item = 1 + focusedIndex.coerceAtMost(queries.size) + val visible = rowState.layoutInfo.visibleItemsInfo + val layout = rowState.layoutInfo + val info = visible.firstOrNull { it.index == item } + val fullyVisible = info != null && info.offset >= layout.viewportStartOffset && + info.offset + info.size <= layout.viewportEndOffset + if (!fullyVisible) rowState.animateScrollToItem((item - 1).coerceAtLeast(0)) + } + fun painted(index: Int) = !isTouchDevice && isRowFocused && focusedIndex == index + LazyRow( + state = rowState, + modifier = modifier + .testTag("search-recent") + .then( + if (!isTouchDevice && focusRequester != null) { + Modifier.focusRequester(focusRequester).focusable() + } else Modifier + ) + .arvioDpadFocusGroup(), + horizontalArrangement = Arrangement.spacedBy(if (isTouchDevice) 8.dp else 9.dp), + verticalAlignment = Alignment.CenterVertically, + contentPadding = PaddingValues( + start = if (isTouchDevice) 16.dp else 22.dp, + end = if (isTouchDevice) 16.dp else 22.dp, + top = 2.dp, + bottom = 4.dp + ) + ) { + item(key = "label") { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.History, contentDescription = null, + tint = Color.White.copy(alpha = 0.6f), modifier = Modifier.size(15.dp)) + Spacer(Modifier.width(6.dp)) + Text( + stringResource(R.string.search_recent), + style = ArflixTypography.caption.copy(fontSize = 12.sp, fontWeight = FontWeight.Medium), + color = Color.White.copy(alpha = 0.6f) + ) + } + } + itemsIndexed(queries, key = { _, query -> "q:$query" }) { index, query -> + ChipBox( + focused = painted(index), + isTouchDevice = isTouchDevice, + onClick = { onSearch(query) }, + modifier = Modifier.testTag("search-recent-$query") + ) { + ChipText(query) + } + } + item(key = "clear") { + val focused = painted(queries.size) + ChipBox( + focused = focused, + isTouchDevice = isTouchDevice, + onClick = onClear, + modifier = Modifier.testTag("search-recent-clear") + ) { + ChipText(stringResource(R.string.search_recent_clear)) + } + } + } +} + +@Composable +private fun ChipBox( + focused: Boolean, + isTouchDevice: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + val shape = RoundedCornerShape(CHIP_RADIUS) + val accent = resolveAccentColor(fallback = Color.White) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .semantics(mergeDescendants = true) { + role = Role.Button + // Accessibility activation must not add a second native D-pad focus target. + if (!isTouchDevice) onClick { onClick(); true } + } + .padding(vertical = 2.dp) + .graphicsLayer { if (focused) { scaleX = 1.05f; scaleY = 1.05f } } + .height(CHIP_HEIGHT) + .background(if (focused) Color.White.copy(alpha = 0.16f) else Color.White.copy(alpha = 0.075f), shape) + .border(if (focused) 2.5.dp else 1.dp, if (focused) accent else Color.White.copy(alpha = 0.24f), shape) + .then(if (isTouchDevice) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 13.dp) + ) { + content() + } +} + +@Composable +private fun ChipText(text: String) { + Text( + text = text, + style = ArflixTypography.caption.copy(fontSize = 12.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.3.sp), + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 200.dp) + ) +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt index 8d0c10aed..430554fda 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt @@ -41,12 +41,14 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField @@ -88,6 +90,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag +import androidx.compose.ui.draw.clip import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics @@ -156,6 +159,7 @@ fun SearchScreen( onBack: () -> Unit = {} ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val recentSearches by viewModel.recentSearches.collectAsStateWithLifecycle() val aiUsePosterCards = rememberCatalogueRowLayoutMode("search:ai") == CardLayoutMode.POSTER val configuration = LocalConfiguration.current val isCompactHeight = configuration.screenHeightDp <= 780 @@ -202,6 +206,10 @@ fun SearchScreen( var enterResultsOnLoad by remember { mutableStateOf(false) } var consumedDpadKey by remember { mutableStateOf(null) } var focusedFilterIndex by remember { mutableIntStateOf(0) } + var focusedRecentIndex by remember { mutableIntStateOf(0) } + // The search bar's clear button: a second stop inside the search bar, reached with the + // D-pad toward its end while there is a query to clear. + var isClearQueryFocused by remember { mutableStateOf(false) } // The filtered grid is driven the same way the rows are: one remembered index, one white // focus ring, no second native focus target (see RowsLayer). var gridFocusIndex by rememberSaveable { mutableIntStateOf(0) } @@ -212,6 +220,7 @@ fun SearchScreen( val searchFocusRequester = remember { FocusRequester() } val filtersFocusRequester = remember { FocusRequester() } + val recentFocusRequester = remember { FocusRequester() } val resultsFocusRequester = remember { FocusRequester() } val textInputFocusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current @@ -380,6 +389,51 @@ fun SearchScreen( } val showFilters = uiState.query.isEmpty() + val showRecent = showFilters && recentSearches.isNotEmpty() + // Opening a title from the results keeps the query among the recent searches. + val openSearchResult: (MediaItem) -> Unit = { item -> + viewModel.rememberSearch() + onNavigateToDetails(item.mediaType, item.id) + } + fun focusRecent() { + focusZone = FocusZone.RECENT + try { recentFocusRequester.requestFocus() } catch (_: Exception) {} + } + // Enter the filter row on the chip that is set, or the first one. + fun focusFilters() { + focusZone = FocusZone.FILTERS + val selectedIdx = quickFilters.indexOfFirst { it.isSet }.coerceAtLeast(0) + focusedFilterIndex = if (selectedIdx in quickFilters.indices) selectedIdx else 0 + try { filtersFocusRequester.requestFocus() } catch (_: Exception) {} + } + fun activateRecentSlot() { + when (val slot = recentSearchSlot(recentSearches, focusedRecentIndex)) { + is RecentSearchSlot.Query -> { + viewModel.searchRecent(slot.query) + focusZone = FocusZone.SEARCH_INPUT + try { searchFocusRequester.requestFocus() } catch (_: Exception) {} + enterResultsOnLoad = !isTouchDevice + } + RecentSearchSlot.Clear -> viewModel.clearRecentSearches() + null -> Unit + } + } + LaunchedEffect(uiState.query.isEmpty(), focusZone, isSearchEditing) { + if (uiState.query.isEmpty() || focusZone != FocusZone.SEARCH_INPUT || isSearchEditing) isClearQueryFocused = false + } + LaunchedEffect(recentSearches.size) { + focusedRecentIndex = focusedRecentIndex.coerceIn(0, (recentSearchSlotCount(recentSearches) - 1).coerceAtLeast(0)) + } + // The row goes away under the focus when it is cleared: hand the focus on to the filters. + LaunchedEffect(showRecent) { + if (!showRecent && focusZone == FocusZone.RECENT) { + if (showFilters && quickFilters.isNotEmpty()) focusFilters() + else { + focusZone = FocusZone.SEARCH_INPUT + try { searchFocusRequester.requestFocus() } catch (_: Exception) {} + } + } + } // Rows while nothing is filtered, one endlessly paging grid from the first filter on (H9). val showGrid = showFilters && uiState.hasDiscoverFilters val gridSlotCount = gridItems.size + if ((uiState.gridLoadFailed || uiState.gridScanPaused)) 1 else 0 @@ -438,7 +492,7 @@ fun SearchScreen( try { searchFocusRequester.requestFocus() } catch (_: Exception) {} } } - FocusZone.FILTERS -> { + FocusZone.FILTERS, FocusZone.RECENT -> { focusZone = FocusZone.SEARCH_INPUT try { searchFocusRequester.requestFocus() } catch (_: Exception) {} } @@ -552,7 +606,7 @@ fun SearchScreen( else { focusZone = FocusZone.SEARCH_INPUT; searchFocusRequester.requestFocus() } true } - FocusZone.FILTERS -> { focusZone = FocusZone.SEARCH_INPUT; searchFocusRequester.requestFocus(); true } + FocusZone.FILTERS, FocusZone.RECENT -> { focusZone = FocusZone.SEARCH_INPUT; searchFocusRequester.requestFocus(); true } FocusZone.SEARCH_INPUT -> { // Always progress toward sidebar so repeated Back presses can exit Search. isSearchEditing = false @@ -572,7 +626,12 @@ fun SearchScreen( focusZone = FocusZone.SIDEBAR true } - FocusZone.FILTERS -> { focusZone = FocusZone.SEARCH_INPUT; searchFocusRequester.requestFocus(); true } + FocusZone.RECENT -> { focusZone = FocusZone.SEARCH_INPUT; searchFocusRequester.requestFocus(); true } + FocusZone.FILTERS -> { + if (showRecent) focusRecent() + else { focusZone = FocusZone.SEARCH_INPUT; searchFocusRequester.requestFocus() } + true + } FocusZone.RESULTS -> { if (hasAiResults) false // AI grid: let native focus handle navigation else if (hasGridResults && gridFocusIndex >= gridColumns) { @@ -600,12 +659,8 @@ fun SearchScreen( FocusZone.SEARCH_INPUT -> { isSearchEditing = false keyboardController?.hide() - if (showFilters && quickFilters.isNotEmpty()) { - focusZone = FocusZone.FILTERS - val selectedIdx = quickFilters.indexOfFirst { it.isSet }.coerceAtLeast(0) - focusedFilterIndex = if (selectedIdx in quickFilters.indices) selectedIdx else 0 - try { filtersFocusRequester.requestFocus() } catch (_: Exception) {} - } + if (showRecent) focusRecent() + else if (showFilters && quickFilters.isNotEmpty()) focusFilters() else if (canEnterResults) { resultsLastNavEventTime = SystemClock.elapsedRealtime() focusZone = FocusZone.RESULTS @@ -614,6 +669,14 @@ fun SearchScreen( } true } + FocusZone.RECENT -> { + if (showFilters && quickFilters.isNotEmpty()) focusFilters() + else if (canEnterResults) { + resultsLastNavEventTime = SystemClock.elapsedRealtime() + focusZone = FocusZone.RESULTS + } + true + } FocusZone.FILTERS -> { if (canEnterResults) { resultsLastNavEventTime = SystemClock.elapsedRealtime() @@ -660,6 +723,13 @@ fun SearchScreen( } true } + FocusZone.RECENT -> { + if (focusedRecentIndex > 0) focusedRecentIndex-- + true + } + FocusZone.SEARCH_INPUT -> { + if (isClearQueryFocused) { isClearQueryFocused = false; true } else false + } else -> false } Key.DirectionRight -> when (focusZone) { @@ -688,6 +758,13 @@ fun SearchScreen( } true } + FocusZone.RECENT -> { + if (focusedRecentIndex < recentSearchSlotCount(recentSearches) - 1) focusedRecentIndex++ + true + } + FocusZone.SEARCH_INPUT -> { + if (uiState.query.isNotEmpty() && !isClearQueryFocused) { isClearQueryFocused = true; true } else false + } else -> false } Key.Enter, Key.DirectionCenter -> { @@ -700,7 +777,17 @@ fun SearchScreen( true } FocusZone.SEARCH_INPUT -> { - startSearchEditing(event.nativeKeyEvent.repeatCount) + if (isClearQueryFocused) { + isClearQueryFocused = false + enterResultsOnLoad = false + viewModel.updateQuery("") + } else { + startSearchEditing(event.nativeKeyEvent.repeatCount) + } + true + } + FocusZone.RECENT -> { + activateRecentSlot() true } FocusZone.FILTERS -> { @@ -733,7 +820,7 @@ fun SearchScreen( // Use stable category lookup to avoid race condition with dynamic list updates val cats = activeCategories.filter { it.items.isNotEmpty() } val item = cats.getOrNull(currentRowIndex)?.items?.getOrNull(currentItemIndex) - if (item != null) onNavigateToDetails(item.mediaType, item.id) + if (item != null) openSearchResult(item) true } } @@ -776,11 +863,14 @@ fun SearchScreen( isTouchDevice = isTouchDevice, isFocused = focusZone == FocusZone.SEARCH_INPUT || isSearchEditing, isEditing = isSearchEditing, + isClearFocused = isClearQueryFocused && focusZone == FocusZone.SEARCH_INPUT, + onClear = { enterResultsOnLoad = false; viewModel.updateQuery("") }, searchFocusRequester = searchFocusRequester, textInputFocusRequester = textInputFocusRequester, onQueryChange = { enterResultsOnLoad = false; viewModel.updateQuery(it) }, onSearch = { viewModel.search() + viewModel.rememberSearch() keyboardController?.hide() isSearchEditing = false enterResultsOnLoad = !isTouchDevice @@ -800,11 +890,10 @@ fun SearchScreen( onMoveDown = { isSearchEditing = false keyboardController?.hide() - if (showFilters && quickFilters.isNotEmpty()) { - focusZone = FocusZone.FILTERS - val selectedIdx = quickFilters.indexOfFirst { it.isSet }.coerceAtLeast(0) - focusedFilterIndex = if (selectedIdx in quickFilters.indices) selectedIdx else 0 - try { filtersFocusRequester.requestFocus() } catch (_: Exception) {} + if (showRecent) { + focusRecent() + } else if (showFilters && quickFilters.isNotEmpty()) { + focusFilters() } else if (canEnterResults) { resultsLastNavEventTime = SystemClock.elapsedRealtime() focusZone = FocusZone.RESULTS @@ -813,6 +902,23 @@ fun SearchScreen( ) } + if (showRecent) { + RecentSearchRow( + queries = recentSearches, + focusedIndex = focusedRecentIndex, + isRowFocused = focusZone == FocusZone.RECENT, + isTouchDevice = isTouchDevice, + onSearch = { query -> + keyboardController?.hide() + isSearchEditing = false + viewModel.searchRecent(query) + }, + onClear = viewModel::clearRecentSearches, + modifier = Modifier.align(Alignment.CenterHorizontally), + focusRequester = if (isTouchDevice) null else recentFocusRequester + ) + } + // ── Filter row (discover mode) — one painted focus, no native focus target ── if (showFilters) { DiscoverFilterRow( @@ -855,7 +961,7 @@ fun SearchScreen( Icon(Icons.Default.AutoAwesome, null, tint = AccentGreen, modifier = Modifier.size(16.dp)); Spacer(Modifier.width(6.dp)) Text(uiState.aiInterpretation ?: "", style = ArflixTypography.body.copy(fontSize = 14.sp, fontWeight = FontWeight.Medium), color = Color.White.copy(alpha = 0.85f)) } - ContentGrid(items = uiState.aiResults, usePosterCards = aiUsePosterCards, isLoading = false, isTouchDevice = isTouchDevice, onItemClick = { onNavigateToDetails(it.mediaType, it.id) }, onLoadMore = {}) + ContentGrid(items = uiState.aiResults, usePosterCards = aiUsePosterCards, isLoading = false, isTouchDevice = isTouchDevice, onItemClick = openSearchResult, onLoadMore = {}) } uiState.query.isNotEmpty() && !uiState.isAiSearch && !hasSearchResults -> { @@ -911,7 +1017,7 @@ fun SearchScreen( isFocused = focusZone == FocusZone.RESULTS, isTouchDevice = isTouchDevice, modifier = if (isTouchDevice) Modifier else Modifier.focusRequester(resultsFocusRequester).focusable(), - onItemClick = { onNavigateToDetails(it.mediaType, it.id) } + onItemClick = openSearchResult ) } } } @@ -969,6 +1075,8 @@ private fun SearchInputBar( isTouchDevice: Boolean, isFocused: Boolean, isEditing: Boolean, + isClearFocused: Boolean, + onClear: () -> Unit, searchFocusRequester: FocusRequester, textInputFocusRequester: FocusRequester, onQueryChange: (String) -> Unit, @@ -985,6 +1093,21 @@ private fun SearchInputBar( onValueChange = onQueryChange, placeholder = { Text(stringResource(R.string.search), style = ArflixTypography.body, color = TextSecondary) }, leadingIcon = { Icon(Icons.Default.Search, null, tint = if (isFocused) Pink else TextSecondary, modifier = Modifier.size(22.dp)) }, + trailingIcon = if (query.isNotEmpty()) { + { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.search_clear_query), + tint = TextSecondary, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .clickable(onClick = onClear) + .padding(9.dp) + .testTag("search-clear") + ) + } + } else null, textStyle = ArflixTypography.body.copy(color = TextPrimary), singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), @@ -1071,6 +1194,26 @@ private fun SearchInputBar( inner() } ) + if (query.isNotEmpty()) { + Spacer(Modifier.width(8.dp)) + // Painted, not focusable: the screen's D-pad handler moves onto it (see isClearFocused). + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(if (isClearFocused) Color.White else Color.White.copy(alpha = 0.08f)) + .semantics { onClick { onClear(); true } } + .testTag("search-clear") + ) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.search_clear_query), + tint = if (isClearFocused) Color.Black else TextSecondary, + modifier = Modifier.size(18.dp) + ) + } + } } } } @@ -1427,4 +1570,4 @@ private fun buildCardSubtitle(item: MediaItem): String { * direction keys belong to it and to nothing else — the chip row underneath must not move at * the same time, which is the usual way a panel ends up reordering things behind itself. */ -private enum class FocusZone { SIDEBAR, SEARCH_INPUT, FILTERS, RESULTS, PANEL } +private enum class FocusZone { SIDEBAR, SEARCH_INPUT, RECENT, FILTERS, RESULTS, PANEL } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchViewModel.kt index c1509f0e0..44d9c4b5e 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchViewModel.kt @@ -8,6 +8,7 @@ import com.arflix.tv.data.model.Category import com.arflix.tv.data.repository.MediaRepository import com.arflix.tv.data.repository.MediaSearchResults import com.arflix.tv.data.repository.PersonMediaSearchResult +import com.arflix.tv.data.repository.RecentSearchRepository import com.arflix.tv.data.repository.TraktRepository import com.arflix.tv.util.ContentRating import dagger.hilt.android.lifecycle.HiltViewModel @@ -20,8 +21,10 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -155,12 +158,19 @@ data class SearchUiState( @HiltViewModel class SearchViewModel @Inject constructor( private val mediaRepository: MediaRepository, - private val traktRepository: TraktRepository + private val traktRepository: TraktRepository, + private val recentSearchRepository: RecentSearchRepository ) : ViewModel() { private val _uiState = MutableStateFlow(SearchUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** The profile's recent queries, newest first; shown while the search bar is empty. */ + val recentSearches: StateFlow> = recentSearchRepository.recentSearches + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + /** A submitted query waiting for its results: only a query that found something is kept. */ + private var pendingRecentSearch: String? = null + private var searchJob: Job? = null private var discoverJob: Job? = null private var filterDebounceJob: Job? = null @@ -843,6 +853,7 @@ class SearchViewModel @Inject constructor( _uiState.update { it.copy(isLoading = sorted.isEmpty() && peopleRows.isEmpty() && peopleNeedingCredits.isNotEmpty(), results = marked, movieResults = marked.filter { item -> item.mediaType == MediaType.MOVIE }, tvResults = marked.filter { item -> item.mediaType == MediaType.TV }, personResults = markedPeople) } + if (sorted.isNotEmpty() || peopleRows.isNotEmpty()) savePendingRecentSearch(query) // Cards are usable now. Bounded, cancellable logo enrichment never replaces the rows. val slots = Semaphore(3) @@ -859,6 +870,7 @@ class SearchViewModel @Inject constructor( _uiState.update { it.copy(isLoading = false, results = markedMerged, movieResults = markedMerged.filter { item -> item.mediaType == MediaType.MOVIE }, tvResults = markedMerged.filter { item -> item.mediaType == MediaType.TV }) } + savePendingRecentSearch(query) } launch { for (person in peopleNeedingCredits) { @@ -871,6 +883,7 @@ class SearchViewModel @Inject constructor( if (credits.isNotEmpty()) { val row = Category("person_${person.personId}", person.name, credits.distinctBy { it.mediaType to it.id }) cachedPeopleResults = cachedPeopleResults + row + savePendingRecentSearch(query) val creditsWatched = watchedMatcher() _uiState.update { it.copy(personResults = it.personResults + row.copy(items = markWatched(row.items, creditsWatched)), isLoading = false) } } @@ -919,6 +932,7 @@ class SearchViewModel @Inject constructor( } private fun executeSmartSearch(sq: SmartQuery) { + val query = _uiState.value.query.trim() searchJob?.cancel(); searchJob = viewModelScope.launch { _uiState.value = _uiState.value.copy(isLoading = true, isAiSearch = true, aiInterpretation = sq.interpretation, error = null, movieResults = EMPTY_MEDIA_ITEMS, tvResults = EMPTY_MEDIA_ITEMS, personResults = EMPTY_CATEGORIES) try { @@ -943,11 +957,48 @@ class SearchViewModel @Inject constructor( items.forEach { mediaRepository.cacheItem(it) } val isWatched = watchedMatcher() _uiState.value = _uiState.value.copy(isLoading = false, aiResults = markWatched(if (sq.limit != null) items.take(sq.limit) else items, isWatched)) + if (items.isNotEmpty()) savePendingRecentSearch(query) } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e _uiState.value = _uiState.value.copy(isLoading = false, error = e.message) } } } + // ── Recent searches ───────────────────────────────────────────────── + + /** + * Keeps the current query once it has results: on submit, and when a result is opened. + * Typing alone never saves, or every pause on the remote keyboard would land in the list. + */ + fun rememberSearch() { + val query = _uiState.value.query.trim() + if (query.length < MIN_RECENT_SEARCH_LENGTH) return + pendingRecentSearch = query + // A changed query clears the results (updateQuery), so any on screen are this query's. + val state = _uiState.value + val hasResults = if (state.isAiSearch) state.aiResults.isNotEmpty() + else state.results.isNotEmpty() || state.personResults.isNotEmpty() + if (hasResults) savePendingRecentSearch(query) + } + + /** Searches a recent query straight away, without the typing debounce. */ + fun searchRecent(query: String) { + val trimmed = query.trim() + if (trimmed.isEmpty()) return + pendingRecentSearch = trimmed + updateQuery(trimmed) + search() + } + + fun clearRecentSearches() { + viewModelScope.launch { recentSearchRepository.clear() } + } + + private fun savePendingRecentSearch(query: String) { + if (!pendingRecentSearch.equals(query, ignoreCase = true)) return + pendingRecentSearch = null + viewModelScope.launch { recentSearchRepository.add(query) } + } + private fun debounceSearch() { searchJob?.cancel(); searchJob = viewModelScope.launch { delay(260); search() } } fun clearSearch() { @@ -971,6 +1022,9 @@ class SearchViewModel @Inject constructor( private fun interleave(a: List, b: List): List { val r = mutableListOf(); for (i in 0 until maxOf(a.size, b.size)) { if (i < a.size) r.add(a[i]); if (i < b.size) r.add(b[i]) }; return r } } +/** A single letter is a search still being typed, not one worth offering again. */ +private const val MIN_RECENT_SEARCH_LENGTH = 2 + private object SearchRegexes { val LIKE_MATCH_REGEX = Regex("(?:movies?|shows?|series|films?)\\s+like\\s+(.+)", RegexOption.IGNORE_CASE) val LIMIT_MATCH_REGEX = Regex("top\\s+(\\d+)", RegexOption.IGNORE_CASE) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cb5bd6d63..826296f7e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1089,6 +1089,9 @@ Rating up to %1$s – %2$s Clear filters + Recent + Clear + Clear search The grid updates as you tick · BACK closes the panel Movies only — TMDB has no age filter for series The language a title was made in — not the language of the app diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/NativeAddonTypeStreamTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/NativeAddonTypeStreamTest.kt index d69af1734..2594ac866 100644 --- a/app/src/test/kotlin/com/arflix/tv/data/repository/NativeAddonTypeStreamTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/NativeAddonTypeStreamTest.kt @@ -108,4 +108,20 @@ class NativeAddonTypeStreamTest { coVerify(exactly = 1) { api.getAddonMeta("https://ch.test/meta/Live%20Docs/chx_doc_1.json") } } + + @Test fun searchUrlsEncodeTheCatalogTypeAndIdLikeCatalogUrls() = runBlocking { + val api = mockk() + val base = strictAddon("Live Docs") + val addon = base.copy(manifest = base.manifest!!.copy(catalogs = listOf( + AddonCatalog("Live Docs", "docs/new", extra = listOf(AddonCatalogExtra("search")))))) + val repository = repository(api, addon) + coEvery { api.getAddonCatalog(any()) } returns + StremioCatalogResponse(metas = listOf(StremioMetaPreview(id = "chx_doc_1", name = "Doc"))) + + assertEquals(1, repository.searchNativeAddonCatalogs("deep sea").size) + + coVerify(exactly = 1) { + api.getAddonCatalog("https://ch.test/catalog/Live%20Docs/docs%2Fnew/search=deep%20sea.json") + } + } } diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/search/DiscoverGridTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/search/DiscoverGridTest.kt index cfafdeaf3..15eb401ac 100644 --- a/app/src/test/kotlin/com/arflix/tv/ui/screens/search/DiscoverGridTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/search/DiscoverGridTest.kt @@ -114,7 +114,7 @@ class DiscoverGridTest { coEvery { repository.getLogoUrl(any(), any()) } returns null coEvery { repository.discoverMovies(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns emptyList() coEvery { repository.discoverTv(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns emptyList() - model = SearchViewModel(repository, trakt) + model = SearchViewModel(repository, trakt, mockk(relaxed = true)) store.put("search", model) } diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/search/RecentSearchTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/search/RecentSearchTest.kt new file mode 100644 index 000000000..890599fa6 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/search/RecentSearchTest.kt @@ -0,0 +1,127 @@ +package com.arflix.tv.ui.screens.search + +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.viewModelScope +import com.arflix.tv.data.model.MediaItem +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.repository.MediaRepository +import com.arflix.tv.data.repository.MediaSearchResults +import com.arflix.tv.data.repository.RecentSearchRepository +import com.arflix.tv.data.repository.TraktRepository +import com.arflix.tv.data.repository.withRecentSearch +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RecentSearchTest { + private val repository = mockk(relaxed = true) + private val recent = mockk(relaxed = true) + private val saved = MutableStateFlow(listOf("Older")) + private val store = ViewModelStore() + private lateinit var model: SearchViewModel + private val show = MediaItem(id = 2, title = "Some Show", mediaType = MediaType.TV) + + @Before fun setUp() { + Dispatchers.setMain(Dispatchers.Unconfined) + every { recent.recentSearches } returns saved + coEvery { repository.getLogoUrl(any(), any()) } returns null + coEvery { repository.searchWithPeople("Some Show", any()) } returns MediaSearchResults(listOf(show), emptyList()) + coEvery { repository.searchWithPeople("zzzz", any()) } returns MediaSearchResults(emptyList(), emptyList()) + model = SearchViewModel(repository, mockk(relaxed = true), recent) + store.put("search", model) + } + + @After fun tearDown() { + val job = model.viewModelScope.coroutineContext[Job] + try { + store.clear() + runBlocking { withTimeout(5_000) { job?.join() } } + } finally { + Dispatchers.resetMain() + } + } + + @Test fun newestFirstWithoutCaseDuplicatesAndCapped() { + assertEquals(listOf("Dune", "Alien"), withRecentSearch(listOf("Alien", "dune"), " Dune ")) + assertEquals(listOf("Alien"), withRecentSearch(listOf("Alien"), " ")) + val full = (1..8).map { "q$it" } + assertEquals(listOf("new") + full.take(7), withRecentSearch(full, "new")) + } + + @Test fun slotsAreTheQueriesThenClear() { + val queries = listOf("a", "b") + assertEquals(3, recentSearchSlotCount(queries)) + assertEquals(RecentSearchSlot.Query("a"), recentSearchSlot(queries, 0)) + assertEquals(RecentSearchSlot.Query("b"), recentSearchSlot(queries, 1)) + assertEquals(RecentSearchSlot.Clear, recentSearchSlot(queries, 2)) + assertNull(recentSearchSlot(queries, 3)) + assertNull(recentSearchSlot(queries, -1)) + assertEquals(0, recentSearchSlotCount(emptyList())) + } + + @Test fun typingAloneDoesNotSave() = runBlocking { + model.updateQuery("Some Show"); model.search() + awaitResults("Some Show") + coVerify(exactly = 0) { recent.add(any()) } + } + + @Test fun submittedQueryWithResultsIsSaved() = runBlocking { + model.updateQuery("Some Show"); model.search() + awaitResults("Some Show") + model.rememberSearch() + coVerify(exactly = 1) { recent.add("Some Show") } + } + + @Test fun submitBeforeResultsSavesOnceTheyArrive() = runBlocking { + val release = CompletableDeferred() + coEvery { repository.searchWithPeople("Some Show", any()) } coAnswers { + release.await(); MediaSearchResults(listOf(show), emptyList()) + } + model.updateQuery("Some Show"); model.search(); model.rememberSearch() + coVerify(exactly = 0) { recent.add(any()) } + release.complete(Unit) + awaitResults("Some Show") + awaitSaved("Some Show") + } + + @Test fun queryWithoutResultsIsNotSaved() = runBlocking { + model.updateQuery("zzzz"); model.search(); model.rememberSearch() + awaitResults("zzzz") + coVerify(exactly = 0) { recent.add(any()) } + } + + @Test fun recentQueryRunsAtOnceAndMovesToTheFront() = runBlocking { + assertEquals(listOf("Older"), withTimeout(5_000) { model.recentSearches.first { it.isNotEmpty() } }) + model.searchRecent("Some Show") + assertEquals(listOf(show), awaitResults("Some Show").results) + awaitSaved("Some Show") + } + + @Test fun clearReachesTheStore() { + model.clearRecentSearches() + coVerify { recent.clear() } + } + + private suspend fun awaitResults(query: String) = withTimeout(5_000) { + model.uiState.first { it.query == query && !it.isLoading } + } + + /** The save is launched after the results land, so it is polled for rather than checked once. */ + private suspend fun awaitSaved(query: String) = withTimeout(5_000) { + while (true) { + try { coVerify(exactly = 1) { recent.add(query) }; return@withTimeout } + catch (_: AssertionError) { delay(20) } + } + } +} diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/search/SearchViewModelTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/search/SearchViewModelTest.kt index 66290c64e..2e0275bbc 100644 --- a/app/src/test/kotlin/com/arflix/tv/ui/screens/search/SearchViewModelTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/search/SearchViewModelTest.kt @@ -33,7 +33,7 @@ class SearchViewModelTest { @Before fun setUp() { Dispatchers.setMain(Dispatchers.Unconfined) coEvery { repository.getLogoUrl(any(), any()) } returns null - model = SearchViewModel(repository, trakt) + model = SearchViewModel(repository, trakt, mockk(relaxed = true)) store.put("search", model) } diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/search/SearchWatchedLookupTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/search/SearchWatchedLookupTest.kt index 12bd77261..7f8c00687 100644 --- a/app/src/test/kotlin/com/arflix/tv/ui/screens/search/SearchWatchedLookupTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/search/SearchWatchedLookupTest.kt @@ -53,7 +53,7 @@ class SearchWatchedLookupTest { history.get() } withContext(main) { - model = SearchViewModel(repository, trakt) + model = SearchViewModel(repository, trakt, mockk(relaxed = true)) store.put("search", model) } withTimeout(5_000) { model.uiState.first { !it.isDiscoverLoading } }