Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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