Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.activity.result.contract.ActivityResultContracts
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
Expand Down Expand Up @@ -400,6 +401,7 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(

val settingsManager = context?.let { PreferenceManager.getDefaultSharedPreferences(it) }
val isAdvancedSearch = settingsManager?.getBoolean("advanced_search", true) ?: true
val isSearchSuggestionsEnabled = settingsManager?.getBoolean("search_suggestions_enabled", true) ?: true

selectedSearchTypes = DataStoreHelper.searchPreferenceTags.toMutableList()

Expand All @@ -408,9 +410,17 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
binding.searchFilter.isFocusableInTouchMode = true
}

// Hide suggestions when search view loses focus
binding.mainSearch.setOnQueryTextFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
searchViewModel.clearSuggestions()
}
}

binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
search(query)
searchViewModel.clearSuggestions()

binding.mainSearch.let {
hideKeyboard(it)
Expand All @@ -425,11 +435,19 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
if (showHistory) {
searchViewModel.clearSearch()
searchViewModel.updateHistory()
searchViewModel.clearSuggestions()
} else {
// Fetch suggestions when user is typing (if enabled)
if (isSearchSuggestionsEnabled) {
searchViewModel.fetchSuggestions(newText)
}
}
binding.apply {
searchHistoryHolder.isVisible = showHistory
searchMasterRecycler.isVisible = !showHistory && isAdvancedSearch
searchAutofitResults.isVisible = !showHistory && !isAdvancedSearch
// Hide suggestions when showing history or showing search results
searchSuggestionsRecycler.isVisible = !showHistory && isSearchSuggestionsEnabled
}

return true
Expand Down Expand Up @@ -579,11 +597,29 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
}
}

val suggestionAdapter = SearchSuggestionAdapter { callback ->
when (callback.clickAction) {
SEARCH_SUGGESTION_CLICK -> {
// Search directly
binding.mainSearch.setQuery(callback.suggestion, true)
searchViewModel.clearSuggestions()
}
SEARCH_SUGGESTION_FILL -> {
// Fill the search box without searching
binding.mainSearch.setQuery(callback.suggestion, false)
}
}
}

binding.apply {
searchHistoryRecycler.adapter = historyAdapter
searchHistoryRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF)
//searchHistoryRecycler.layoutManager = GridLayoutManager(context, 1)

// Setup suggestions RecyclerView
searchSuggestionsRecycler.adapter = suggestionAdapter
searchSuggestionsRecycler.layoutManager = LinearLayoutManager(context)

searchMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool)
searchMasterRecycler.adapter = masterAdapter
//searchMasterRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF)
Expand Down Expand Up @@ -612,6 +648,12 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
(binding.searchHistoryRecycler.adapter as? SearchHistoryAdaptor?)?.submitList(list)
}

// Observe search suggestions
observe(searchViewModel.searchSuggestions) { suggestions ->
binding.searchSuggestionsRecycler.isVisible = suggestions.isNotEmpty()
(binding.searchSuggestionsRecycler.adapter as? SearchSuggestionAdapter?)?.submitList(suggestions)
}

searchViewModel.updateHistory()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.lagradost.cloudstream3.ui.search

import android.view.LayoutInflater
import android.view.ViewGroup
import com.lagradost.cloudstream3.databinding.SearchSuggestionItemBinding
import com.lagradost.cloudstream3.ui.BaseDiffCallback
import com.lagradost.cloudstream3.ui.NoStateAdapter
import com.lagradost.cloudstream3.ui.ViewHolderState

const val SEARCH_SUGGESTION_CLICK = 0
const val SEARCH_SUGGESTION_FILL = 1

data class SearchSuggestionCallback(
val suggestion: String,
val clickAction: Int,
)

class SearchSuggestionAdapter(
private val clickCallback: (SearchSuggestionCallback) -> Unit,
) : NoStateAdapter<String>(diffCallback = BaseDiffCallback(itemSame = { a, b -> a == b })) {

override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> {
return ViewHolderState(
SearchSuggestionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false),
)
}

override fun onBindContent(
holder: ViewHolderState<Any>,
item: String,
position: Int
) {
val binding = holder.view as? SearchSuggestionItemBinding ?: return
binding.apply {
suggestionText.text = item

// Click on the whole item to search
suggestionItem.setOnClickListener {
clickCallback.invoke(SearchSuggestionCallback(item, SEARCH_SUGGESTION_CLICK))
}

// Click on the arrow to fill the search box without searching
suggestionFill.setOnClickListener {
clickCallback.invoke(SearchSuggestionCallback(item, SEARCH_SUGGESTION_FILL))
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.lagradost.cloudstream3.ui.search

import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.nicehttp.NiceResponse

/**
* API for fetching search suggestions from external sources.
* Uses Google's suggestion API which provides movie/show related suggestions.
*/
object SearchSuggestionApi {
private const val GOOGLE_SUGGESTION_URL = "https://suggestqueries.google.com/complete/search"

/**
* Fetches search suggestions from Google's autocomplete API.
*
* @param query The search query to get suggestions for
* @return List of suggestion strings, empty list on failure
*/
suspend fun getSuggestions(query: String): List<String> {
if (query.isBlank() || query.length < 2) return emptyList()

return try {
val response = app.get(
GOOGLE_SUGGESTION_URL,
params = mapOf(
"client" to "firefox", // Returns JSON format
"q" to query,
"hl" to "en" // Language hint
),
cacheTime = 60 * 24 // Cache for 1 day (cacheUnit default is Minutes)
)

// Response format: ["query",["suggestion1","suggestion2",...]]
parseSuggestions(response)
} catch (e: Exception) {
logError(e)
emptyList()
}
}

/**
* Parses the Google suggestion JSON response.
* Format: ["query",["suggestion1","suggestion2",...]]
*/
private fun parseSuggestions(response: NiceResponse): List<String> {
return try {
val parsed = response.parsed<Array<Any>>()
val suggestions = parsed.getOrNull(1)
when (suggestions) {
is List<*> -> suggestions.filterIsInstance<String>().take(10)
is Array<*> -> suggestions.filterIsInstance<String>().take(10)
else -> emptyList()
}
} catch (e: Exception) {
logError(e)
emptyList()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

Expand All @@ -43,6 +44,11 @@ class SearchViewModel : ViewModel() {
private val _currentHistory: MutableLiveData<List<SearchHistoryItem>> = MutableLiveData()
val currentHistory: LiveData<List<SearchHistoryItem>> get() = _currentHistory

private val _searchSuggestions: MutableLiveData<List<String>> = MutableLiveData()
val searchSuggestions: LiveData<List<String>> get() = _searchSuggestions

private var suggestionJob: Job? = null

private var repos = synchronized(apis) { apis.map { APIRepository(it) } }

fun clearSearch() {
Expand Down Expand Up @@ -83,6 +89,35 @@ class SearchViewModel : ViewModel() {
_currentHistory.postValue(items)
}

/**
* Fetches search suggestions with debouncing.
* Waits 300ms before making the API call to avoid too many requests.
*
* @param query The search query to get suggestions for
*/
fun fetchSuggestions(query: String) {
suggestionJob?.cancel()

if (query.isBlank() || query.length < 2) {
_searchSuggestions.postValue(emptyList())
return
}

suggestionJob = ioSafe {
delay(300) // Debounce
val suggestions = SearchSuggestionApi.getSuggestions(query)
_searchSuggestions.postValue(suggestions)
}
}

/**
* Clears the current search suggestions.
*/
fun clearSuggestions() {
suggestionJob?.cancel()
_searchSuggestions.postValue(emptyList())
}

private val lock: MutableSet<String> = mutableSetOf()

// ExpandableHomepageList because the home adapter is reused in the search fragment
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/res/drawable/ic_baseline_north_west_24.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M5,15h2v-4.586l7.293,7.293l1.414,-1.414L8.414,9H13V7H5V15z"/>
</vector>
23 changes: 20 additions & 3 deletions app/src/main/res/layout/fragment_search.xml
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/searchRoot"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="@dimen/navbar_height"
android:background="?attr/primaryGrayBackground"
android:orientation="vertical"
tools:context=".ui.search.SearchFragment">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
Expand Down Expand Up @@ -179,4 +183,17 @@
app:cornerRadius="0dp"
app:icon="@drawable/delete_all" />
</FrameLayout>
</LinearLayout>
</LinearLayout>

<!-- Suggestions overlay - appears on top of content -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/search_suggestions_recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="100dp"
android:background="?attr/primaryGrayBackground"
android:elevation="8dp"
android:visibility="gone"
tools:listitem="@layout/search_suggestion_item" />

</FrameLayout>
24 changes: 21 additions & 3 deletions app/src/main/res/layout/fragment_search_tv.xml
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/searchRoot"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="@dimen/navbar_height"
android:background="?attr/primaryGrayBackground"
android:orientation="vertical"
tools:context=".ui.search.SearchFragment">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
Expand Down Expand Up @@ -181,4 +185,18 @@
app:cornerRadius="0dp"
app:icon="@drawable/delete_all" />
</FrameLayout>
</LinearLayout>
</LinearLayout>

<!-- Suggestions overlay - appears on top of content -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/search_suggestions_recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="100dp"
android:layout_marginStart="@dimen/navbar_width"
android:background="?attr/primaryGrayBackground"
android:elevation="8dp"
android:visibility="gone"
tools:listitem="@layout/search_suggestion_item" />

</FrameLayout>
Loading