Compare commits

...

2 commits

Author SHA1 Message Date
firelight
13a2b447a2
Fix: Old cached values, and search filter 2026-06-27 15:53:57 +02:00
firelight
d1a5d0a39f
add repo search 2026-06-27 04:00:44 +02:00
6 changed files with 180 additions and 48 deletions

View file

@ -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<Repository>()
app.get(convertRawGitUrl(url), cacheTime = 5, cacheUnit = TimeUnit.MINUTES).parsedSafe<Repository>()
}
}
private suspend fun parsePlugins(pluginUrls: String): List<SitePlugin> {
// Take manifestVersion and such into account later
return try {
val response = app.get(convertRawGitUrl(pluginUrls))
// Normal parsed function not working?
// return response.parsedSafe()
tryParseJson<Array<SitePlugin>>(response.text)?.toList() ?: emptyList()
app.get(convertRawGitUrl(pluginUrls), cacheTime = 5, cacheUnit = TimeUnit.MINUTES)
.parsed<Array<SitePlugin>>().toList()
} catch (t: Throwable) {
logError(t)
emptyList()

View file

@ -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<FragmentExtensionsBinding>(
) {
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<FragmentExtensionsBinding>(
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<FragmentExtensionsBinding>(
})
}
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<FragmentExtensionsBinding>(
)
}
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<FragmentExtensionsBinding>(
)?.text?.toString()?.let { copiedText ->
if (copiedText.contains(RepoAdapter.SHAREABLE_REPO_SEPARATOR)) {
// text is of format <repository name> : <repository url>
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<FragmentExtensionsBinding>(
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()

View file

@ -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<FragmentPluginsBinding>(
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<FragmentPluginsBinding>(
}
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()
@ -130,9 +131,6 @@ class PluginsFragment : BaseFragment<FragmentPluginsBinding>(
dispatchBackPressed()
}
}
searchView?.setOnQueryTextFocusChangeListener { _, hasFocus ->
if (!hasFocus) pluginViewModel.search(null)
}
searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
@ -161,7 +159,7 @@ class PluginsFragment : BaseFragment<FragmentPluginsBinding>(
setRecycledViewPool(PluginAdapter.sharedPool)
adapter =
PluginAdapter {
pluginViewModel.handlePluginAction(activity, url, it, isLocal)
pluginViewModel.handlePluginAction(activity, listOf(url), it, isLocal)
}
}
@ -185,7 +183,7 @@ class PluginsFragment : BaseFragment<FragmentPluginsBinding>(
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

View file

@ -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<String>,
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<String>) {
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<PluginViewData>.sortByQuery(query: String?): List<PluginViewData> {
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<String>) =
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.filterTvTypes().filterLang().sortByQuery(query)
)
}

View file

@ -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">
<include layout="@layout/standard_toolbar" />
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/settings_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/primaryGrayBackground"
android:paddingTop="@dimen/navbar_height"
app:layout_scrollFlags="scroll|enterAlways"
app:menu="@menu/repository_search"
app:navigationIconTint="?attr/iconColor"
app:titleTextColor="?attr/textColor"
tools:title="Overlord" />
</com.google.android.material.appbar.AppBarLayout>
<!-- <include layout="@layout/standard_toolbar" />-->
<androidx.recyclerview.widget.RecyclerView
android:clipToPadding="false"
android:id="@+id/repo_recycler_view"
android:background="?attr/primaryBlackBackground"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginBottom="80dp"
android:background="?attr/primaryBlackBackground"
android:clipToPadding="false"
android:nextFocusUp="@id/settings_toolbar"
android:nextFocusDown="@id/plugin_storage_appbar"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
@ -27,11 +46,25 @@
tools:listitem="@layout/repository_item"
tools:visibility="visible" />
<LinearLayout
android:id="@+id/blank_repo_screen"
android:background="?attr/primaryBlackBackground"
<androidx.recyclerview.widget.RecyclerView
android:visibility="gone"
android:id="@+id/plugin_recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/primaryBlackBackground"
android:clipToPadding="false"
android:nextFocusLeft="@id/nav_rail_view"
android:nextFocusUp="@id/tvtypes_chips"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:listitem="@layout/repository_item" />
<LinearLayout
android:id="@+id/blank_repo_screen"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/primaryBlackBackground"
android:gravity="center"
android:orientation="vertical"
tools:visibility="gone">
@ -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">
<LinearLayout
android:layout_width="0dp"

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/search_button"
android:title="@string/title_search"
android:icon="@drawable/search_icon"
android:searchIcon="@drawable/search_icon"
app:actionViewClass="androidx.appcompat.widget.SearchView"
app:showAsAction="always|collapseActionView"
tools:ignore="AppCompatResource" />
</menu>