Skip to content
147 changes: 110 additions & 37 deletions app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt
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 @@ -56,6 +57,7 @@ import com.lagradost.cloudstream3.ui.home.ParentItemAdapter
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
Expand All @@ -71,6 +73,8 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
import com.lagradost.cloudstream3.utils.SubtitleHelper
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
Expand Down Expand Up @@ -137,6 +141,7 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
override fun onDestroyView() {
hideKeyboard()
bottomSheetDialog?.ownHide()
activity?.detachBackPressedCallback("SearchFragment")
super.onDestroyView()
}

Expand Down Expand Up @@ -400,17 +405,29 @@ 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()

if (isLayout(TV)) {
if (!isLayout(PHONE)) {
binding.searchFilter.isFocusable = true
binding.searchFilter.isFocusableInTouchMode = true
}

// Hide suggestions when search view loses focus (phone only)
if (isLayout(PHONE)) {
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,51 +442,25 @@ 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
searchHistoryRecycler.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
}
})

binding.searchClearCallHistory.setOnClickListener {
activity?.let { ctx ->
val builder: AlertDialog.Builder = AlertDialog.Builder(ctx)
val dialogClickListener =
DialogInterface.OnClickListener { _, which ->
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
removeKeys("$currentAccount/$SEARCH_HISTORY_KEY")
searchViewModel.updateHistory()
}

DialogInterface.BUTTON_NEGATIVE -> {
}
}
}

try {
builder.setTitle(R.string.clear_history).setMessage(
ctx.getString(R.string.delete_message).format(
ctx.getString(R.string.history)
)
)
.setPositiveButton(R.string.sort_clear, dialogClickListener)
.setNegativeButton(R.string.cancel, dialogClickListener)
.show().setDefaultFocus()
} catch (e: Exception) {
logError(e)
// ye you somehow fucked up formatting did you?
}
}


}

observe(searchViewModel.searchResponse) {
when (it) {
is Resource.Success -> {
Expand Down Expand Up @@ -559,6 +550,7 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
val searchItem = click.item
when (click.clickAction) {
SEARCH_HISTORY_OPEN -> {
if (searchItem == null) return@SearchHistoryAdaptor
searchViewModel.clearSearch()
if (searchItem.type.isNotEmpty())
updateChips(
Expand All @@ -569,21 +561,76 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
}

SEARCH_HISTORY_REMOVE -> {
if (searchItem == null) return@SearchHistoryAdaptor
removeKey("$currentAccount/$SEARCH_HISTORY_KEY", searchItem.key)
searchViewModel.updateHistory()
}

SEARCH_HISTORY_CLEAR -> {
// Show confirmation dialog (from footer button)
activity?.let { ctx ->
val builder: AlertDialog.Builder = AlertDialog.Builder(ctx)
val dialogClickListener =
DialogInterface.OnClickListener { _, which ->
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
removeKeys("$currentAccount/$SEARCH_HISTORY_KEY")
searchViewModel.updateHistory()
}

DialogInterface.BUTTON_NEGATIVE -> {
}
}
}

try {
builder.setTitle(R.string.clear_history).setMessage(
ctx.getString(R.string.delete_message).format(
ctx.getString(R.string.history)
)
)
.setPositiveButton(R.string.sort_clear, dialogClickListener)
.setNegativeButton(R.string.cancel, dialogClickListener)
.show().setDefaultFocus()
} catch (e: Exception) {
logError(e)
}
}
}

else -> {
// wth are you doing???
}
}
}

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)
}
SEARCH_SUGGESTION_CLEAR -> {
// Clear suggestions (from footer button)
searchViewModel.clearSuggestions()
}
}
}

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 All @@ -608,8 +655,34 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
}

observe(searchViewModel.currentHistory) { list ->
binding.searchClearCallHistory.isVisible = list.isNotEmpty()
(binding.searchHistoryRecycler.adapter as? SearchHistoryAdaptor?)?.submitList(list)
// Scroll to top to show newest items (list is sorted by newest first)
if (list.isNotEmpty()) {
binding.searchHistoryRecycler.scrollToPosition(0)
}
}

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

// On non-phone layouts, redirect focus and handle back button
if (!isLayout(PHONE)) {
if (hasSuggestions) {
binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_suggestions_recycler
// Attach back button callback to clear suggestions
activity?.attachBackPressedCallback("SearchFragment") {
searchViewModel.clearSuggestions()
}
} else {
// Reset to default focus target (history)
binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_history_recycler
// Detach back button callback when no suggestions
activity?.detachBackPressedCallback("SearchFragment")
}
}
}

searchViewModel.updateHistory()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@ package com.lagradost.cloudstream3.ui.search

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isGone
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.databinding.SearchHistoryFooterBinding
import com.lagradost.cloudstream3.databinding.SearchHistoryItemBinding
import com.lagradost.cloudstream3.ui.BaseDiffCallback
import com.lagradost.cloudstream3.ui.NoStateAdapter
import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout

data class SearchHistoryItem(
@JsonProperty("searchedAt") val searchedAt: Long,
Expand All @@ -17,18 +22,31 @@ data class SearchHistoryItem(
)

data class SearchHistoryCallback(
val item: SearchHistoryItem,
val item: SearchHistoryItem?,
val clickAction: Int,
)

const val SEARCH_HISTORY_OPEN = 0
const val SEARCH_HISTORY_REMOVE = 1
const val SEARCH_HISTORY_CLEAR = 2

class SearchHistoryAdaptor(
private val clickCallback: (SearchHistoryCallback) -> Unit,
) : NoStateAdapter<SearchHistoryItem>(diffCallback = BaseDiffCallback(itemSame = { a,b ->
a.searchedAt == b.searchedAt && a.searchText == b.searchText
})) {

// Add footer for all layouts
override val footers = 1

override fun submitList(list: Collection<SearchHistoryItem>?, commitCallback: Runnable?) {
super.submitList(list, commitCallback)
// Notify footer to rebind when list changes to update visibility
if (footers > 0) {
notifyItemChanged(itemCount - 1)
}
}

override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> {
return ViewHolderState(
SearchHistoryItemBinding.inflate(LayoutInflater.from(parent.context), parent, false),
Expand All @@ -52,4 +70,25 @@ class SearchHistoryAdaptor(
}
}
}

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

override fun onBindFooter(holder: ViewHolderState<Any>) {
val binding = holder.view as? SearchHistoryFooterBinding ?: return
// Hide footer when list is empty
binding.searchClearCallHistory.apply {
isGone = immutableCurrentList.isEmpty()
if (isLayout(TV or EMULATOR)) {
isFocusable = true
isFocusableInTouchMode = true
}
setOnClickListener {
clickCallback.invoke(SearchHistoryCallback(null, SEARCH_HISTORY_CLEAR))
}
}
}
}
Loading