From d1a5d0a39ff658d2057fdda7caece3e1858e3d9f Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Sat, 27 Jun 2026 04:00:44 +0200 Subject: [PATCH 1/2] add repo search --- .../cloudstream3/plugins/RepositoryManager.kt | 11 +-- .../settings/extensions/ExtensionsFragment.kt | 89 +++++++++++++++++-- .../ui/settings/extensions/PluginsFragment.kt | 7 +- .../settings/extensions/PluginsViewModel.kt | 50 +++++++---- .../main/res/layout/fragment_extensions.xml | 51 +++++++++-- app/src/main/res/menu/repository_search.xml | 13 +++ 6 files changed, 176 insertions(+), 45 deletions(-) create mode 100644 app/src/main/res/menu/repository_search.xml diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt index 4455a1d5e..c83c694f7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -16,7 +16,6 @@ import com.lagradost.cloudstream3.plugins.PluginManager.getPluginSanitizedFileNa import com.lagradost.cloudstream3.plugins.PluginManager.unloadPlugin import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.SerialName @@ -26,7 +25,7 @@ import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files import java.nio.file.StandardCopyOption import java.security.MessageDigest -import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.TimeUnit /** * Comes with the app, always available in the app, non removable. @@ -136,17 +135,15 @@ object RepositoryManager { suspend fun parseRepository(url: String): Repository? { return safeAsync { // Take manifestVersion and such into account later - app.get(convertRawGitUrl(url)).parsedSafe() + app.get(convertRawGitUrl(url), cacheTime = 5, cacheUnit = TimeUnit.MINUTES).parsedSafe() } } private suspend fun parsePlugins(pluginUrls: String): List { // Take manifestVersion and such into account later return try { - val response = app.get(convertRawGitUrl(pluginUrls)) - // Normal parsed function not working? - // return response.parsedSafe() - tryParseJson>(response.text)?.toList() ?: emptyList() + app.get(convertRawGitUrl(pluginUrls), cacheTime = 5, cacheUnit = TimeUnit.MINUTES) + .parsed>().toList() } catch (t: Throwable) { logError(t) emptyList() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt index af0d3dfe7..8decb0662 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt @@ -5,10 +5,12 @@ import android.content.Context import android.content.DialogInterface import android.os.Build import android.view.LayoutInflater +import android.view.MenuItem import android.view.View import android.widget.LinearLayout import android.widget.Toast import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.SearchView import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.marginBottom @@ -26,6 +28,7 @@ import com.lagradost.cloudstream3.plugins.RepositoryManager import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding @@ -43,6 +46,7 @@ class ExtensionsFragment : BaseFragment( ) { private val extensionViewModel: ExtensionsViewModel by activityViewModels() + private val pluginViewModel: PluginsViewModel by activityViewModels() private fun View.setLayoutWidth(weight: Int) { val param = LinearLayout.LayoutParams( @@ -126,7 +130,10 @@ class ExtensionsFragment : BaseFragment( when (which) { DialogInterface.BUTTON_POSITIVE -> { ioSafe { - RepositoryManager.removeRepository(uiContext.applicationContext, repo) + RepositoryManager.removeRepository( + uiContext.applicationContext, + repo + ) extensionViewModel.loadStats() extensionViewModel.loadRepositories() } @@ -145,10 +152,11 @@ class ExtensionsFragment : BaseFragment( }) } - observe(extensionViewModel.repositories) { - binding.repoRecyclerView.isVisible = it.isNotEmpty() - binding.blankRepoScreen.isVisible = it.isEmpty() - (binding.repoRecyclerView.adapter as? RepoAdapter)?.submitList(it.toList()) + observe(extensionViewModel.repositories) { repos -> + binding.repoRecyclerView.isVisible = repos.isNotEmpty() + binding.blankRepoScreen.isVisible = repos.isEmpty() + (binding.repoRecyclerView.adapter as? RepoAdapter)?.submitList(repos.toList()) + pluginViewModel.updatePluginList(binding.root.context, repos.map { it.url }) } observeNullable(extensionViewModel.pluginStats) { value -> @@ -185,6 +193,70 @@ class ExtensionsFragment : BaseFragment( ) } + binding.pluginRecyclerView.apply { + setLinearListLayout( + isHorizontal = false, + nextDown = FOCUS_SELF, + nextRight = FOCUS_SELF, + ) + setRecycledViewPool(PluginAdapter.sharedPool) + adapter = + PluginAdapter { + val urls = extensionViewModel.repositories.value?.map { repo -> repo.url } + ?: emptyList() + pluginViewModel.handlePluginAction(activity, urls, it, false) + } + } + + observe(pluginViewModel.filteredPlugins) { (scrollToTop, list) -> + (binding.pluginRecyclerView.adapter as? PluginAdapter)?.submitList(list) + if (scrollToTop) { + binding.pluginRecyclerView.scrollToPosition(0) + } + } + + binding.settingsToolbar.apply { + val searchItem = menu?.findItem(R.id.search_button) + val searchView = searchItem?.actionView as? SearchView + + searchItem?.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionCollapse(p0: MenuItem): Boolean { + binding.pluginRecyclerView.isVisible = false + binding.repoRecyclerView.isVisible = true + return true + + } + + override fun onMenuItemActionExpand(p0: MenuItem): Boolean { + binding.pluginRecyclerView.isVisible = true + binding.repoRecyclerView.isVisible = false + return true + } + }) + + // Don't go back if active query + setNavigationOnClickListener { + if (searchView?.isIconified == false) { + searchView.isIconified = true + } else { + dispatchBackPressed() + } + } + + searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + pluginViewModel.search(query) + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + pluginViewModel.search(newText) + return true + } + }) + } + + val addRepositoryClick = View.OnClickListener { val ctx = context ?: return@OnClickListener val binding = AddRepoInputBinding.inflate(LayoutInflater.from(ctx), null, false) @@ -199,7 +271,10 @@ class ExtensionsFragment : BaseFragment( )?.text?.toString()?.let { copiedText -> if (copiedText.contains(RepoAdapter.SHAREABLE_REPO_SEPARATOR)) { // text is of format : - val (name, url) = copiedText.split(RepoAdapter.SHAREABLE_REPO_SEPARATOR, limit = 2) + val (name, url) = copiedText.split( + RepoAdapter.SHAREABLE_REPO_SEPARATOR, + limit = 2 + ) binding.repoUrlInput.setText(url.trim()) binding.repoNameInput.setText(name.trim()) } else { @@ -227,7 +302,7 @@ class ExtensionsFragment : BaseFragment( val fixedName = if (!name.isNullOrBlank()) name else repository.name - val newRepo = RepositoryData(repository.iconUrl,fixedName, url) + val newRepo = RepositoryData(repository.iconUrl, fixedName, url) RepositoryManager.addRepository(newRepo) extensionViewModel.loadStats() extensionViewModel.loadRepositories() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt index 534ffa62a..75d8089f7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt @@ -130,9 +130,6 @@ class PluginsFragment : BaseFragment( dispatchBackPressed() } } - searchView?.setOnQueryTextFocusChangeListener { _, hasFocus -> - if (!hasFocus) pluginViewModel.search(null) - } searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { @@ -161,7 +158,7 @@ class PluginsFragment : BaseFragment( setRecycledViewPool(PluginAdapter.sharedPool) adapter = PluginAdapter { - pluginViewModel.handlePluginAction(activity, url, it, isLocal) + pluginViewModel.handlePluginAction(activity, listOf(url), it, isLocal) } } @@ -185,7 +182,7 @@ class PluginsFragment : BaseFragment( binding.tvtypesChipsScroll.root.isVisible = false } else { - pluginViewModel.updatePluginList(context, url) + pluginViewModel.updatePluginList(context, listOf(url)) binding.tvtypesChipsScroll.root.isVisible = true // not needed for users but may be useful for devs downloadAllButton?.isVisible = BuildConfig.DEBUG diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt index 0cbef9cf2..c60859c32 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt @@ -143,7 +143,7 @@ class PluginsViewModel : ViewModel() { ), Toast.LENGTH_SHORT ) - viewModel?.updatePluginListPrivate(activity, repositoryUrl) + viewModel?.updatePluginListPrivate(activity, listOf(repositoryUrl)) } else if (list.isNotEmpty()) { showToast(R.string.download_failed, Toast.LENGTH_SHORT) } @@ -157,11 +157,11 @@ class PluginsViewModel : ViewModel() { * */ fun handlePluginAction( activity: Activity?, - repositoryUrl: String, + repositoryUrls: List, plugin: Plugin, isLocal: Boolean ) = ioSafe { - Log.i(TAG, "handlePluginAction = $repositoryUrl, $plugin, $isLocal") + Log.i(TAG, "handlePluginAction = ${repositoryUrls}, $plugin, $isLocal") if (activity == null) return@ioSafe val (repo, metadata) = plugin @@ -198,15 +198,18 @@ class PluginsViewModel : ViewModel() { if (isLocal) updatePluginListLocal() else - updatePluginListPrivate(activity, repositoryUrl) + updatePluginListPrivate(activity, repositoryUrls) } - private suspend fun updatePluginListPrivate(context: Context, repositoryUrl: String) { + private suspend fun updatePluginListPrivate(context: Context, repositoryUrls: List) { val isAdult = PreferenceManager.getDefaultSharedPreferences(context) .getStringSet(context.getString(R.string.prefer_media_type_key), emptySet()) ?.contains(TvType.NSFW.ordinal.toString()) == true - val plugins = getPlugins(repositoryUrl) + val plugins = repositoryUrls.flatMap { repositoryUrl -> + getPlugins(repositoryUrl) + } + val list = plugins.filter { // Show all non-nsfw plugins or all if nsfw is enabled it.second.tvTypes?.contains(TvType.NSFW.name) != true || isAdult @@ -241,16 +244,28 @@ class PluginsViewModel : ViewModel() { } private fun List.sortByQuery(query: String?): List { - return if (query == null) { + return if (query.isNullOrBlank()) { // Return list to base state if no query this.sortedBy { it.plugin.second.name } } else { - this.sortedBy { - -Levenshtein.partialRatio( + this.mapNotNull { + // Try matching name + val score = Levenshtein.partialRatio( it.plugin.second.name.lowercase(), query.lowercase() - ) - } + ).takeIf { score -> score > 80 } ?: + // Fallback to description, but limit characters to reduce lag + it.plugin.second.description?.lowercase()?.take(64) + ?.let { description -> + Levenshtein.partialRatio( + description, + query.lowercase() + ) + }?.takeIf { score -> score > 80 } ?: return@mapNotNull null + it to score + }.sortedBy { + -it.second + }.map { it.first } } } @@ -267,16 +282,17 @@ class PluginsViewModel : ViewModel() { ) } - fun updatePluginList(context: Context?, repositoryUrl: String) = viewModelScope.launchSafe { - if (context == null) return@launchSafe - Log.i(TAG, "updatePluginList = $repositoryUrl") - updatePluginListPrivate(context, repositoryUrl) - } + fun updatePluginList(context: Context?, repositoryUrls: List) = + viewModelScope.launchSafe { + if (context == null) return@launchSafe + Log.i(TAG, "updatePluginList = $repositoryUrls") + updatePluginListPrivate(context, repositoryUrls) + } fun search(query: String?) { currentQuery = query _filteredPlugins.postValue( - true to (filteredPlugins.value?.second?.sortByQuery(query) ?: emptyList()) + true to plugins.sortByQuery(query) ) } diff --git a/app/src/main/res/layout/fragment_extensions.xml b/app/src/main/res/layout/fragment_extensions.xml index b7cf4b6cd..965b13569 100644 --- a/app/src/main/res/layout/fragment_extensions.xml +++ b/app/src/main/res/layout/fragment_extensions.xml @@ -4,20 +4,39 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/extensions_root" - android:background="?attr/primaryGrayBackground" android:layout_width="match_parent" android:layout_height="match_parent" + android:background="?attr/primaryGrayBackground" android:orientation="vertical"> - + + + + + + + - + + @@ -64,14 +97,14 @@ android:layout_height="80dp" android:layout_gravity="bottom" android:background="?attr/primaryGrayBackground" + android:baselineAligned="false" android:focusable="true" android:foreground="@drawable/outline_drawable" android:nextFocusRight="@id/add_repo_button_imageview" android:nextFocusUp="@id/repo_recycler_view" android:orientation="horizontal" android:padding="10dp" - android:visibility="visible" - android:baselineAligned="false"> + android:visibility="visible"> + + + \ No newline at end of file From 13a2b447a2b4eaa9509c34fff2b2942ff214d226 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:53:57 +0200 Subject: [PATCH 2/2] Fix: Old cached values, and search filter --- .../ui/settings/extensions/PluginsFragment.kt | 11 ++++++----- .../ui/settings/extensions/PluginsViewModel.kt | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt index 75d8089f7..2e8549a41 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt @@ -4,13 +4,13 @@ import android.os.Bundle import android.view.View import androidx.appcompat.widget.SearchView import androidx.core.view.isVisible -import androidx.fragment.app.activityViewModels +import androidx.lifecycle.ViewModelProvider import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.BuildConfig -import com.lagradost.cloudstream3.databinding.FragmentPluginsBinding -import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.databinding.FragmentPluginsBinding +import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.bindChips import com.lagradost.cloudstream3.ui.result.FOCUS_SELF @@ -34,8 +34,7 @@ const val PLUGINS_BUNDLE_LOCAL = "isLocal" class PluginsFragment : BaseFragment( BaseFragment.BindingCreator.Inflate(FragmentPluginsBinding::inflate) ) { - - private val pluginViewModel: PluginsViewModel by activityViewModels() + private lateinit var pluginViewModel: PluginsViewModel override fun onDestroyView() { pluginViewModel.clear() // clear for the next observe @@ -47,6 +46,8 @@ class PluginsFragment : BaseFragment( } override fun onBindingCreated(binding: FragmentPluginsBinding) { + pluginViewModel = ViewModelProvider(this)[PluginsViewModel::class.java] + // Since the ViewModel is getting reused the tvTypes must be cleared between uses pluginViewModel.tvTypes.clear() pluginViewModel.selectedLanguages = listOf() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt index c60859c32..059e24b12 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt @@ -292,7 +292,7 @@ class PluginsViewModel : ViewModel() { fun search(query: String?) { currentQuery = query _filteredPlugins.postValue( - true to plugins.sortByQuery(query) + true to plugins.filterTvTypes().filterLang().sortByQuery(query) ) }