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.plugins.PluginManager.unloadPlugin
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
@ -26,7 +25,7 @@ import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.StandardCopyOption import java.nio.file.StandardCopyOption
import java.security.MessageDigest 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. * Comes with the app, always available in the app, non removable.
@ -136,17 +135,15 @@ object RepositoryManager {
suspend fun parseRepository(url: String): Repository? { suspend fun parseRepository(url: String): Repository? {
return safeAsync { return safeAsync {
// Take manifestVersion and such into account later // 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> { private suspend fun parsePlugins(pluginUrls: String): List<SitePlugin> {
// Take manifestVersion and such into account later // Take manifestVersion and such into account later
return try { return try {
val response = app.get(convertRawGitUrl(pluginUrls)) app.get(convertRawGitUrl(pluginUrls), cacheTime = 5, cacheUnit = TimeUnit.MINUTES)
// Normal parsed function not working? .parsed<Array<SitePlugin>>().toList()
// return response.parsedSafe()
tryParseJson<Array<SitePlugin>>(response.text)?.toList() ?: emptyList()
} catch (t: Throwable) { } catch (t: Throwable) {
logError(t) logError(t)
emptyList() emptyList()

View file

@ -5,10 +5,12 @@ import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.os.Build import android.os.Build
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.SearchView
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.marginBottom 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.BaseFragment
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.setLinearListLayout 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.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding 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 extensionViewModel: ExtensionsViewModel by activityViewModels()
private val pluginViewModel: PluginsViewModel by activityViewModels()
private fun View.setLayoutWidth(weight: Int) { private fun View.setLayoutWidth(weight: Int) {
val param = LinearLayout.LayoutParams( val param = LinearLayout.LayoutParams(
@ -126,7 +130,10 @@ class ExtensionsFragment : BaseFragment<FragmentExtensionsBinding>(
when (which) { when (which) {
DialogInterface.BUTTON_POSITIVE -> { DialogInterface.BUTTON_POSITIVE -> {
ioSafe { ioSafe {
RepositoryManager.removeRepository(uiContext.applicationContext, repo) RepositoryManager.removeRepository(
uiContext.applicationContext,
repo
)
extensionViewModel.loadStats() extensionViewModel.loadStats()
extensionViewModel.loadRepositories() extensionViewModel.loadRepositories()
} }
@ -145,10 +152,11 @@ class ExtensionsFragment : BaseFragment<FragmentExtensionsBinding>(
}) })
} }
observe(extensionViewModel.repositories) { observe(extensionViewModel.repositories) { repos ->
binding.repoRecyclerView.isVisible = it.isNotEmpty() binding.repoRecyclerView.isVisible = repos.isNotEmpty()
binding.blankRepoScreen.isVisible = it.isEmpty() binding.blankRepoScreen.isVisible = repos.isEmpty()
(binding.repoRecyclerView.adapter as? RepoAdapter)?.submitList(it.toList()) (binding.repoRecyclerView.adapter as? RepoAdapter)?.submitList(repos.toList())
pluginViewModel.updatePluginList(binding.root.context, repos.map { it.url })
} }
observeNullable(extensionViewModel.pluginStats) { value -> 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 addRepositoryClick = View.OnClickListener {
val ctx = context ?: return@OnClickListener val ctx = context ?: return@OnClickListener
val binding = AddRepoInputBinding.inflate(LayoutInflater.from(ctx), null, false) val binding = AddRepoInputBinding.inflate(LayoutInflater.from(ctx), null, false)
@ -199,7 +271,10 @@ class ExtensionsFragment : BaseFragment<FragmentExtensionsBinding>(
)?.text?.toString()?.let { copiedText -> )?.text?.toString()?.let { copiedText ->
if (copiedText.contains(RepoAdapter.SHAREABLE_REPO_SEPARATOR)) { if (copiedText.contains(RepoAdapter.SHAREABLE_REPO_SEPARATOR)) {
// text is of format <repository name> : <repository url> // 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.repoUrlInput.setText(url.trim())
binding.repoNameInput.setText(name.trim()) binding.repoNameInput.setText(name.trim())
} else { } else {
@ -227,7 +302,7 @@ class ExtensionsFragment : BaseFragment<FragmentExtensionsBinding>(
val fixedName = if (!name.isNullOrBlank()) name val fixedName = if (!name.isNullOrBlank()) name
else repository.name else repository.name
val newRepo = RepositoryData(repository.iconUrl,fixedName, url) val newRepo = RepositoryData(repository.iconUrl, fixedName, url)
RepositoryManager.addRepository(newRepo) RepositoryManager.addRepository(newRepo)
extensionViewModel.loadStats() extensionViewModel.loadStats()
extensionViewModel.loadRepositories() extensionViewModel.loadRepositories()

View file

@ -4,13 +4,13 @@ import android.os.Bundle
import android.view.View import android.view.View
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.lifecycle.ViewModelProvider
import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.AllLanguagesName
import com.lagradost.cloudstream3.BuildConfig 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.R
import com.lagradost.cloudstream3.TvType 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.BaseFragment
import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.bindChips import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.bindChips
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
@ -34,8 +34,7 @@ const val PLUGINS_BUNDLE_LOCAL = "isLocal"
class PluginsFragment : BaseFragment<FragmentPluginsBinding>( class PluginsFragment : BaseFragment<FragmentPluginsBinding>(
BaseFragment.BindingCreator.Inflate(FragmentPluginsBinding::inflate) BaseFragment.BindingCreator.Inflate(FragmentPluginsBinding::inflate)
) { ) {
private lateinit var pluginViewModel: PluginsViewModel
private val pluginViewModel: PluginsViewModel by activityViewModels()
override fun onDestroyView() { override fun onDestroyView() {
pluginViewModel.clear() // clear for the next observe pluginViewModel.clear() // clear for the next observe
@ -47,6 +46,8 @@ class PluginsFragment : BaseFragment<FragmentPluginsBinding>(
} }
override fun onBindingCreated(binding: 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 // Since the ViewModel is getting reused the tvTypes must be cleared between uses
pluginViewModel.tvTypes.clear() pluginViewModel.tvTypes.clear()
pluginViewModel.selectedLanguages = listOf() pluginViewModel.selectedLanguages = listOf()
@ -130,9 +131,6 @@ class PluginsFragment : BaseFragment<FragmentPluginsBinding>(
dispatchBackPressed() dispatchBackPressed()
} }
} }
searchView?.setOnQueryTextFocusChangeListener { _, hasFocus ->
if (!hasFocus) pluginViewModel.search(null)
}
searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean { override fun onQueryTextSubmit(query: String?): Boolean {
@ -161,7 +159,7 @@ class PluginsFragment : BaseFragment<FragmentPluginsBinding>(
setRecycledViewPool(PluginAdapter.sharedPool) setRecycledViewPool(PluginAdapter.sharedPool)
adapter = adapter =
PluginAdapter { 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 binding.tvtypesChipsScroll.root.isVisible = false
} else { } else {
pluginViewModel.updatePluginList(context, url) pluginViewModel.updatePluginList(context, listOf(url))
binding.tvtypesChipsScroll.root.isVisible = true binding.tvtypesChipsScroll.root.isVisible = true
// not needed for users but may be useful for devs // not needed for users but may be useful for devs
downloadAllButton?.isVisible = BuildConfig.DEBUG downloadAllButton?.isVisible = BuildConfig.DEBUG

View file

@ -143,7 +143,7 @@ class PluginsViewModel : ViewModel() {
), ),
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
) )
viewModel?.updatePluginListPrivate(activity, repositoryUrl) viewModel?.updatePluginListPrivate(activity, listOf(repositoryUrl))
} else if (list.isNotEmpty()) { } else if (list.isNotEmpty()) {
showToast(R.string.download_failed, Toast.LENGTH_SHORT) showToast(R.string.download_failed, Toast.LENGTH_SHORT)
} }
@ -157,11 +157,11 @@ class PluginsViewModel : ViewModel() {
* */ * */
fun handlePluginAction( fun handlePluginAction(
activity: Activity?, activity: Activity?,
repositoryUrl: String, repositoryUrls: List<String>,
plugin: Plugin, plugin: Plugin,
isLocal: Boolean isLocal: Boolean
) = ioSafe { ) = ioSafe {
Log.i(TAG, "handlePluginAction = $repositoryUrl, $plugin, $isLocal") Log.i(TAG, "handlePluginAction = ${repositoryUrls}, $plugin, $isLocal")
if (activity == null) return@ioSafe if (activity == null) return@ioSafe
val (repo, metadata) = plugin val (repo, metadata) = plugin
@ -198,15 +198,18 @@ class PluginsViewModel : ViewModel() {
if (isLocal) if (isLocal)
updatePluginListLocal() updatePluginListLocal()
else 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) val isAdult = PreferenceManager.getDefaultSharedPreferences(context)
.getStringSet(context.getString(R.string.prefer_media_type_key), emptySet()) .getStringSet(context.getString(R.string.prefer_media_type_key), emptySet())
?.contains(TvType.NSFW.ordinal.toString()) == true ?.contains(TvType.NSFW.ordinal.toString()) == true
val plugins = getPlugins(repositoryUrl) val plugins = repositoryUrls.flatMap { repositoryUrl ->
getPlugins(repositoryUrl)
}
val list = plugins.filter { val list = plugins.filter {
// Show all non-nsfw plugins or all if nsfw is enabled // Show all non-nsfw plugins or all if nsfw is enabled
it.second.tvTypes?.contains(TvType.NSFW.name) != true || isAdult 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> { 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 // Return list to base state if no query
this.sortedBy { it.plugin.second.name } this.sortedBy { it.plugin.second.name }
} else { } else {
this.sortedBy { this.mapNotNull {
-Levenshtein.partialRatio( // Try matching name
val score = Levenshtein.partialRatio(
it.plugin.second.name.lowercase(), it.plugin.second.name.lowercase(),
query.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 { fun updatePluginList(context: Context?, repositoryUrls: List<String>) =
if (context == null) return@launchSafe viewModelScope.launchSafe {
Log.i(TAG, "updatePluginList = $repositoryUrl") if (context == null) return@launchSafe
updatePluginListPrivate(context, repositoryUrl) Log.i(TAG, "updatePluginList = $repositoryUrls")
} updatePluginListPrivate(context, repositoryUrls)
}
fun search(query: String?) { fun search(query: String?) {
currentQuery = query currentQuery = query
_filteredPlugins.postValue( _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:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/extensions_root" android:id="@+id/extensions_root"
android:background="?attr/primaryGrayBackground"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/primaryGrayBackground"
android:orientation="vertical"> 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 <androidx.recyclerview.widget.RecyclerView
android:clipToPadding="false"
android:id="@+id/repo_recycler_view" android:id="@+id/repo_recycler_view"
android:background="?attr/primaryBlackBackground"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_marginBottom="80dp" android:layout_marginBottom="80dp"
android:background="?attr/primaryBlackBackground"
android:clipToPadding="false"
android:nextFocusUp="@id/settings_toolbar" android:nextFocusUp="@id/settings_toolbar"
android:nextFocusDown="@id/plugin_storage_appbar" android:nextFocusDown="@id/plugin_storage_appbar"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
@ -27,11 +46,25 @@
tools:listitem="@layout/repository_item" tools:listitem="@layout/repository_item"
tools:visibility="visible" /> tools:visibility="visible" />
<LinearLayout <androidx.recyclerview.widget.RecyclerView
android:id="@+id/blank_repo_screen" android:visibility="gone"
android:background="?attr/primaryBlackBackground" android:id="@+id/plugin_recycler_view"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="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:gravity="center"
android:orientation="vertical" android:orientation="vertical"
tools:visibility="gone"> tools:visibility="gone">
@ -64,14 +97,14 @@
android:layout_height="80dp" android:layout_height="80dp"
android:layout_gravity="bottom" android:layout_gravity="bottom"
android:background="?attr/primaryGrayBackground" android:background="?attr/primaryGrayBackground"
android:baselineAligned="false"
android:focusable="true" android:focusable="true"
android:foreground="@drawable/outline_drawable" android:foreground="@drawable/outline_drawable"
android:nextFocusRight="@id/add_repo_button_imageview" android:nextFocusRight="@id/add_repo_button_imageview"
android:nextFocusUp="@id/repo_recycler_view" android:nextFocusUp="@id/repo_recycler_view"
android:orientation="horizontal" android:orientation="horizontal"
android:padding="10dp" android:padding="10dp"
android:visibility="visible" android:visibility="visible">
android:baselineAligned="false">
<LinearLayout <LinearLayout
android:layout_width="0dp" 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>