From cef466e76844790fa2ae6da7b4069872e508763e Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:22:06 +0200 Subject: [PATCH 01/19] Moved the updated to shared --- .../lagradost/cloudstream3/MainActivity.kt | 6 +- .../cloudstream3/MainActivityScreen.kt | 62 ++++++------ .../cloudstream3/ui/settings/ApkUpdater.kt | 2 + .../ui/settings/GithubReleases.kt | 35 +++---- .../ui/settings/SettingsUpdatesScreen.kt | 1 + .../viewmodels}/GithubViewModel.kt | 96 ++++++++++++------- 6 files changed, 112 insertions(+), 90 deletions(-) rename {app/src/main/java/com/lagradost/cloudstream3/ui/settings => shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels}/GithubViewModel.kt (84%) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 4fbcc913d8f..00e18158b0f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -1989,9 +1989,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa handleAppIntent(intent) - ioSafe { - runAutoUpdate() // TODO remove this when we update the updater autosearch - } + //ioSafe { + // runAutoUpdate() + //} FcastManager().init(this, false) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivityScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivityScreen.kt index 28553b3200e..a2bbf84a8f1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivityScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivityScreen.kt @@ -22,28 +22,32 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel import com.lagradost.cloudstream3.mvvm.getStackTracePretty -import com.lagradost.cloudstream3.ui.settings.APK_CONTENT_TYPE -import com.lagradost.cloudstream3.ui.settings.APK_PRERELEASE -import com.lagradost.cloudstream3.ui.settings.APK_REPOSITORY -import com.lagradost.cloudstream3.ui.settings.APK_USERNAME +import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.ui.settings.ApkUpdater -import com.lagradost.cloudstream3.ui.settings.GithubAction -import com.lagradost.cloudstream3.ui.settings.GithubAction.AutoSearchForUpdate -import com.lagradost.cloudstream3.ui.settings.GithubAction.Dismiss -import com.lagradost.cloudstream3.ui.settings.GithubAction.SearchForUpdate -import com.lagradost.cloudstream3.ui.settings.GithubAction.SkipUpdate -import com.lagradost.cloudstream3.ui.settings.GithubAction.Update -import com.lagradost.cloudstream3.ui.settings.GithubState -import com.lagradost.cloudstream3.ui.settings.GithubUpdateDialogState -import com.lagradost.cloudstream3.ui.settings.GithubViewModel +import com.lagradost.cloudstream3.ui.settings.GithubReleases +import com.lagradost.cloudstream3.ui.settings.GithubReleases.APK_CONTENT_TYPE +import com.lagradost.cloudstream3.ui.settings.GithubReleases.APK_PRERELEASE +import com.lagradost.cloudstream3.ui.settings.GithubReleases.APK_REPOSITORY +import com.lagradost.cloudstream3.ui.settings.GithubReleases.APK_USERNAME import com.lagradost.cloudstream3.utils.GitInfo.currentCommitHash import com.lagradost.cloudstream4.compose.BlackButton import com.lagradost.cloudstream4.compose.Screen import com.lagradost.cloudstream4.compose.WhiteButton import com.lagradost.cloudstream4.rememberAppSettings +import com.lagradost.cloudstream4.viewmodels.GithubAction +import com.lagradost.cloudstream4.viewmodels.GithubAction.AutoSearchForUpdate +import com.lagradost.cloudstream4.viewmodels.GithubAction.Dismiss +import com.lagradost.cloudstream4.viewmodels.GithubAction.SearchForUpdate +import com.lagradost.cloudstream4.viewmodels.GithubAction.SkipUpdate +import com.lagradost.cloudstream4.viewmodels.GithubAction.Update +import com.lagradost.cloudstream4.viewmodels.GithubState +import com.lagradost.cloudstream4.viewmodels.GithubUpdateDialogState +import com.lagradost.cloudstream4.viewmodels.GithubViewModel import com.mihon.material.padding object MainActivityScreen : Screen { + private var hasSearchedForUpdate : Boolean = false + @Composable fun githubViewModel(): GithubViewModel? { // By having the ComponentActivity as the owner we can have a singleton viewmodel @@ -60,10 +64,15 @@ object MainActivityScreen : Screen { isDebug = BuildConfig.DEBUG, buildSha = activity.currentCommitHash(), settings = settings, - updater = ApkUpdater - )/*.apply { - onAction(AutoSearchForUpdate) // Remove this for now - }*/ + updater = ApkUpdater, + repository = GithubReleases, + ).apply { + /** Only search once per session when not in safe mode */ + if(!hasSearchedForUpdate && !PluginManager.isSafeMode()) { + hasSearchedForUpdate = true + onAction(AutoSearchForUpdate) + } + } } } @@ -81,9 +90,7 @@ object MainActivityScreen : Screen { @Composable fun GithubState(updaterState: GithubState, onAction: (GithubAction) -> Unit) { - if (updaterState.dialog == null) { - return - } + val dialog = updaterState.dialog ?: return val title: String val body: @Composable () -> Unit @@ -91,10 +98,10 @@ object MainActivityScreen : Screen { val dismissButton: @Composable () -> Unit val cancelable: Boolean - when (val state = updaterState.dialog.state) { + when (val state = dialog.state) { is GithubUpdateDialogState.Error -> { // Only show error if it is initialized by the user - if (!updaterState.dialog.isFromUser) return + if (!dialog.isFromUser) return cancelable = true title = stringResource(R.string.download_failed) @@ -113,7 +120,7 @@ object MainActivityScreen : Screen { GithubUpdateDialogState.Loading -> { // Only show loading if it is initialized by the user - if (!updaterState.dialog.isFromUser) return + if (!dialog.isFromUser) return cancelable = true title = stringResource(R.string.loading) @@ -134,7 +141,7 @@ object MainActivityScreen : Screen { GithubUpdateDialogState.NoUpdateFound -> { // Only show error if it is initialized by the user - if (!updaterState.dialog.isFromUser) return + if (!dialog.isFromUser) return cancelable = true title = stringResource(R.string.no_update_found) @@ -175,9 +182,10 @@ object MainActivityScreen : Screen { cancelable = false title = stringResource(R.string.update_notification_downloading) body = { - if (state.total != null) { + val total = state.total + if (total != null) { val progress = - (state.progress.toFloat() / state.total.toFloat()).coerceIn(0.0f, 1.0f) + (state.progress.toFloat() / total.toFloat()).coerceIn(0.0f, 1.0f) Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( color = MaterialTheme.colorScheme.onBackground, @@ -192,7 +200,7 @@ object MainActivityScreen : Screen { } / ${ formatFileSize( LocalContext.current, - state.total + total ) })" ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt index 08e279b49a0..89280f2b09b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt @@ -16,6 +16,8 @@ import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.receivers.PackageInstallerStatusReceiver import com.lagradost.cloudstream4.AppSettings +import com.lagradost.cloudstream4.viewmodels.AppUpdater +import com.lagradost.cloudstream4.viewmodels.DigestPair import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubReleases.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubReleases.kt index 94e0ebf4a60..b03bda8ced8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubReleases.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubReleases.kt @@ -1,13 +1,14 @@ package com.lagradost.cloudstream3.ui.settings -import androidx.compose.runtime.Immutable import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream4.viewmodels.GithubFile +import com.lagradost.cloudstream4.viewmodels.GithubRepository import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlin.jvm.Throws +import java.io.FileNotFoundException -object GithubReleases { +object GithubReleases : GithubRepository { @Serializable private data class GithubAsset( @JsonProperty("name") @SerialName("name") val name: String, @@ -41,27 +42,15 @@ object GithubReleases { @JsonProperty("object") @SerialName("object") val githubObject: GithubObject, ) - /** GitHub file update package */ - @Immutable - data class GithubFile( - /** File digest, sha:xxx */ - val digest: String?, - /** File url for download */ - val downloadUrl: String, - /** Filename without the extension */ - val displayName: String, - /** Changelog, aka the commit message */ - val changeLog: String, - /** Name of the tag, aka unique release name like vX.X.X or pre-release */ - val tagName: String, - /** Unique node id */ - val nodeId: String, - ) + const val APK_USERNAME = "recloudstream" + const val APK_REPOSITORY = "cloudstream" + const val APK_PRERELEASE = "pre-release" + const val APK_CONTENT_TYPE = "application/vnd.android.package-archive" private val defaultHeaders = mapOf("Accept" to "application/vnd.github.v3+json") @Throws - suspend fun getShaFromTag( + override suspend fun getSha( userName: String, repository: String, tag: String, @@ -73,13 +62,13 @@ object GithubReleases { } @Throws - suspend fun getLatestReleaseFile( + override suspend fun getRelease( userName: String, repository: String, prerelease: Boolean, prereleaseTag: String, contentType: String, - ): GithubFile? { + ): GithubFile { val latestReleaseUrl = if (prerelease) { // Find the release object // https://docs.github.com/en/rest/releases/releases?apiVersion=2026-03-10#get-a-release-by-tag-name @@ -102,7 +91,7 @@ object GithubReleases { } if (foundAsset == null) { - return null + throw FileNotFoundException() } return GithubFile( diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt index 091a645d6d1..3336b6127e8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt @@ -28,6 +28,7 @@ import com.lagradost.cloudstream3.utils.InAppUpdater.installPreReleaseIfNeeded import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream4.AppSettings import com.lagradost.cloudstream4.rememberAppSettings +import com.lagradost.cloudstream4.viewmodels.GithubAction import com.lagradost.safefile.SafeFile import com.mihon.presentation.settings.Preference import com.mihon.presentation.settings.SearchableSettings diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/GithubViewModel.kt similarity index 84% rename from app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt rename to shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/GithubViewModel.kt index 9d67d1c0765..74364758946 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/GithubViewModel.kt @@ -1,4 +1,4 @@ -package com.lagradost.cloudstream3.ui.settings +package com.lagradost.cloudstream4.viewmodels import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel @@ -13,7 +13,6 @@ import com.lagradost.cloudstream4.state.StateContainer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import java.io.FileNotFoundException @Immutable data class GithubState( @@ -27,7 +26,7 @@ sealed class GithubUpdateDialogState { object Loading : GithubUpdateDialogState() object NoUpdateFound : GithubUpdateDialogState() data class UpdateFound( - val file: GithubReleases.GithubFile, + val file: GithubFile, val newSha: String?, val oldSha: String?, ) : GithubUpdateDialogState() @@ -40,21 +39,33 @@ data class GithubDialog( val state: GithubUpdateDialogState, ) +/** GitHub file update package */ +@Immutable +data class GithubFile( + /** File digest, sha:xxx */ + val digest: String?, + /** File url for download */ + val downloadUrl: String, + /** Filename without the extension */ + val displayName: String, + /** Changelog, aka the commit message */ + val changeLog: String, + /** Name of the tag, aka unique release name like vX.X.X or pre-release */ + val tagName: String, + /** Unique node id */ + val nodeId: String, +) + @Immutable sealed class GithubAction { object AutoSearchForUpdate : GithubAction() object SearchForUpdate : GithubAction() object Dismiss : GithubAction() - data class SkipThisUpdate(val file: GithubReleases.GithubFile) : GithubAction() - data class Update(val file: GithubReleases.GithubFile) : GithubAction() - data class SkipUpdate(val file: GithubReleases.GithubFile) : GithubAction() + data class SkipThisUpdate(val file: GithubFile) : GithubAction() + data class Update(val file: GithubFile) : GithubAction() + data class SkipUpdate(val file: GithubFile) : GithubAction() } -const val APK_USERNAME = "recloudstream" -const val APK_REPOSITORY = "cloudstream" -const val APK_PRERELEASE = "pre-release" -const val APK_CONTENT_TYPE = "application/vnd.android.package-archive" - interface AppUpdater { @Throws suspend fun update( @@ -65,21 +76,39 @@ interface AppUpdater { ) } +interface GithubRepository { + @Throws + suspend fun getRelease( + userName: String, + repository: String, + prerelease: Boolean, + prereleaseTag: String, + contentType: String, + ): GithubFile + + @Throws + suspend fun getSha( + userName: String, + repository: String, + tag: String, + ): String +} + /** The digest pair to verify that a file is correctly downloaded */ data class DigestPair( - val algorithm : String, - val digest : ByteArray, + val algorithm: String, + val digest: ByteArray, ) { companion object { // "sha256:XXXX" or "sha256-XXXX" -> "sha256", bytearray(XXXX) - fun parse(digestPair: String?) : DigestPair? { - if(digestPair == null) return null + fun parse(digestPair: String?): DigestPair? { + if (digestPair == null) return null var split = digestPair.split(":", limit = 2) - if(split.size != 2) { + if (split.size != 2) { split = digestPair.split("-", limit = 2) } - if(split.size != 2) { + if (split.size != 2) { return null } @@ -132,6 +161,7 @@ class GithubViewModel( val buildSha: String, val settings: AppSettings, val updater: AppUpdater, + val repository: GithubRepository, ) : ViewModel(), StateContainer by DefaultStateContainer(GithubState()), ActionHandler { private val updateDispatcher = SingleActiveQuery(Dispatchers.IO) @@ -235,7 +265,11 @@ class GithubViewModel( var oldSha: String? = null var newSha: String? = null if (prerelease) { - val sha = getSha(remotePrereleaseTag) + val sha = repository.getSha( + tag = remotePrereleaseTag, + userName = remoteUserName, + repository = remoteRepository, + ) oldSha = buildSha.take(7) newSha = sha.take(7) @@ -248,7 +282,13 @@ class GithubViewModel( } } - val release = getRelease(prerelease) + val release = repository.getRelease( + prerelease = prerelease, + userName = remoteUserName, + repository = remoteRepository, + prereleaseTag = remotePrereleaseTag, + contentType = remoteContentType + ) // If on stable, only check that the display name matches if (!prerelease && release.displayName == versionName) { @@ -278,22 +318,4 @@ class GithubViewModel( ) } } - - @Throws - private suspend fun getRelease(prerelease: Boolean) = - GithubReleases.getLatestReleaseFile( - prerelease = prerelease, - userName = remoteUserName, - repository = remoteRepository, - prereleaseTag = remotePrereleaseTag, - contentType = remoteContentType - ) ?: throw FileNotFoundException() - - @Throws - private suspend fun getSha(tag: String) = - GithubReleases.getShaFromTag( - tag = tag, - userName = remoteUserName, - repository = remoteRepository, - ) } \ No newline at end of file From 999771a9111759501785ee8fd472259a255a4e23 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:46:30 +0200 Subject: [PATCH 02/19] Fix markdown --- .../cloudstream3/MainActivityScreen.kt | 14 +- .../lagradost/cloudstream3/utils/Markdown.kt | 233 ++++++++++++++++++ 2 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/Markdown.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivityScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivityScreen.kt index a2bbf84a8f1..9bab1156233 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivityScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivityScreen.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme @@ -30,6 +32,7 @@ import com.lagradost.cloudstream3.ui.settings.GithubReleases.APK_PRERELEASE import com.lagradost.cloudstream3.ui.settings.GithubReleases.APK_REPOSITORY import com.lagradost.cloudstream3.ui.settings.GithubReleases.APK_USERNAME import com.lagradost.cloudstream3.utils.GitInfo.currentCommitHash +import com.lagradost.cloudstream3.utils.parseMarkdown import com.lagradost.cloudstream4.compose.BlackButton import com.lagradost.cloudstream4.compose.Screen import com.lagradost.cloudstream4.compose.WhiteButton @@ -46,7 +49,7 @@ import com.lagradost.cloudstream4.viewmodels.GithubViewModel import com.mihon.material.padding object MainActivityScreen : Screen { - private var hasSearchedForUpdate : Boolean = false + private var hasSearchedForUpdate: Boolean = false @Composable fun githubViewModel(): GithubViewModel? { @@ -68,7 +71,7 @@ object MainActivityScreen : Screen { repository = GithubReleases, ).apply { /** Only search once per session when not in safe mode */ - if(!hasSearchedForUpdate && !PluginManager.isSafeMode()) { + if (!hasSearchedForUpdate && !PluginManager.isSafeMode()) { hasSearchedForUpdate = true onAction(AutoSearchForUpdate) } @@ -161,7 +164,12 @@ object MainActivityScreen : Screen { (state.oldSha ?: BuildConfig.VERSION_NAME), (state.newSha ?: state.file.displayName) ) - body = { Text(text = state.file.changeLog) } + body = { + Text( + text = parseMarkdown(state.file.changeLog), + modifier = Modifier.verticalScroll(rememberScrollState()) + ) + } confirmButton = { WhiteButton(text = stringResource(R.string.update), onClick = { onAction(Update(state.file)) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/Markdown.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/Markdown.kt new file mode 100644 index 00000000000..fb0b86e36c4 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Markdown.kt @@ -0,0 +1,233 @@ +/** + * Modified ver of a basic Markdown parser: + * https://github.com/DAKSHSEMWAL/mdparserkit + * **/ +package com.lagradost.cloudstream3.utils + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp + +//private val lineNumberRegex: Regex = Regex("^\\d+\\.\\s.*$") +private val boldItalicPattern = Regex("\\*\\*[*_](.*?)[*_]\\*\\*") +private val boldPattern = Regex("\\*\\*(.*?)\\*\\*") +private val italicPattern = Regex("[*_](.*?)[*_]") +private val strikethroughPattern = Regex("~~(.+?)~~") +private val linkPattern = Regex("\\[(.*?)]\\((.*?)\\)") + +/** + * Parses a given Markdown text and converts it into an [AnnotatedString] with appropriate styles. + * + * @param markdownText The input Markdown text to parse. + * @return An [AnnotatedString] with styles applied according to the Markdown syntax. + */ +fun parseMarkdown(markdownText: String): AnnotatedString { + try { + val lines = markdownText.split("\n") + val resultBuilder = AnnotatedString.Builder() + + lines.forEach { line -> + when { + // Heading 1: Extracting content, applying bold style, and appending to resultBuilder + line.startsWith("# ") -> { + textMarkDown( + line.removePrefix("# ").trim(), + resultBuilder, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + } + // Similar processing for Heading 2 to Heading 6 + // Heading 2 + line.startsWith("## ") -> { + textMarkDown( + line.removePrefix("## ").trim(), + resultBuilder, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + } + // Heading 3 + line.startsWith("### ") -> { + textMarkDown( + line.removePrefix("### ").trim(), + resultBuilder, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + } + // Heading 4 + line.startsWith("#### ") -> { + textMarkDown( + line.removePrefix("#### ").trim(), + resultBuilder, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + // Heading 5 + line.startsWith("##### ") -> { + textMarkDown( + line.removePrefix("##### ").trim(), + resultBuilder, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + } + // Heading 6 + line.startsWith("###### ") -> { + textMarkDown( + line.removePrefix("###### ").trim(), + resultBuilder, + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + } + + // Unordered list item: Extracting content, applying bold style, appending bullet point symbol, and appending to resultBuilder + line.startsWith("* ") || line.startsWith("- ") -> { + val content = line.removePrefix("* ").removePrefix("- ").trim() + val currentStyle = SpanStyle( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + resultBuilder.append( + AnnotatedString("• ", currentStyle) + ) + textMarkDown(content, resultBuilder, fontSize = 14.sp) + } + // Ordered list item: Extracting content, applying bold style, appending number and period, and appending to resultBuilder + /*line.matches(lineNumberRegex) -> { + val startIndex = lineNumberRegex.find(line)?.range?.first ?: 0 + val currentStyle = SpanStyle( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + val annotatedString = buildAnnotatedString { + if (startIndex > 0) { + append(line.substring(0, startIndex)) + } + withStyle(currentStyle) { + append(line.substring(startIndex, startIndex + 2)) + } + } + resultBuilder.append(annotatedString) + textMarkDown( + inputText = line.substring(startIndex + 2, line.length), + resultBuilder = resultBuilder, + fontSize = 14.sp + ) + }*/ + // Remaining Text + else -> { + textMarkDown(line, resultBuilder, fontSize = 14.sp) + } + } // Appending new line + resultBuilder.append("\n") + } + return resultBuilder.toAnnotatedString().trim() as AnnotatedString + } catch (_: Throwable) { + // Just in case! We never want the UI to crash! + return AnnotatedString(text = markdownText) + } +} + +/** + * Converts markdown-style text formatting to [AnnotatedString] with appropriate [SpanStyle]s. + * + * @param inputText The input text to be converted. + * @param resultBuilder The [AnnotatedString.Builder] to append the converted text to. + * @param fontSize The desired font size for the text. + * @return The converted text with Markdown formatting replaced by appropriate [SpanStyle]s. + */ +private fun textMarkDown( + inputText: String, + resultBuilder: AnnotatedString.Builder, + fontSize: TextUnit, + fontWeight: FontWeight = FontWeight.Normal +) { + var currentIndex = 0 + + while (currentIndex < inputText.length) { + val nextBoldItalic = boldItalicPattern.find(inputText, startIndex = currentIndex) + val nextBold = boldPattern.find(inputText, startIndex = currentIndex) + val nextItalic = italicPattern.find(inputText, startIndex = currentIndex) + val nextStrikethrough = strikethroughPattern.find(inputText, startIndex = currentIndex) + val nextLink = linkPattern.find(inputText, startIndex = currentIndex) + + val nextMarkDown = listOfNotNull( + nextBoldItalic, + nextBold, + nextItalic, + nextStrikethrough, + nextLink + ).minByOrNull { it.range.first } + + if (nextMarkDown == null) { + // Append any remaining text if no more markdown found + val normalText = inputText.substring(currentIndex) + val style = SpanStyle(fontWeight = fontWeight, fontSize = fontSize) + resultBuilder.append(AnnotatedString(normalText, style)) + break + } + + if (nextMarkDown.range.first > currentIndex) { + // Append any normal text before the markdown + val normalText = inputText.substring(currentIndex, nextMarkDown.range.first) + val style = SpanStyle(fontWeight = fontWeight, fontSize = fontSize) + resultBuilder.append(AnnotatedString(normalText, style)) + } + + val matchText = nextMarkDown.groupValues.getOrNull(1) ?: "" + + val style = when (nextMarkDown) { + nextBoldItalic -> SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Italic, + fontSize = fontSize + ) + + nextBold -> SpanStyle( + fontWeight = FontWeight.Bold, + fontSize = fontSize + ) + + nextItalic -> SpanStyle( + fontStyle = FontStyle.Italic, + fontSize = fontSize + ) + + nextStrikethrough -> SpanStyle( + textDecoration = TextDecoration.LineThrough, + fontSize = fontSize + ) + + nextLink -> { + resultBuilder.append(buildAnnotatedString { + withLink( + LinkAnnotation.Url( + nextMarkDown.groupValues.getOrNull(2) ?: "", + ) + ) { + append(matchText) + } + }) + currentIndex = nextMarkDown.range.last + 1 + continue + } + + else -> throw IllegalStateException("Unhandled markdown type") + } + + resultBuilder.append(AnnotatedString(matchText, style)) + + currentIndex = nextMarkDown.range.last + 1 + } +} \ No newline at end of file From 52e1fe83774a2e172038fa8388e68428642c1be5 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:11:52 +0200 Subject: [PATCH 03/19] WIP --- .../cloudstream3/plugins/PluginManager.kt | 2 +- .../cloudstream3/plugins/RepositoryManager.kt | 6 +- .../extensions/AndroidRepositoryManager.kt | 245 ++++ .../settings/extensions/ExtensionsFragment.kt | 2 +- .../extensions/ExtensionsFragment2.kt | 6 + .../settings/extensions/ExtensionsScreen.kt | 1072 +++++++++++++++++ .../extensions/ExtensionsViewModel.kt | 15 +- .../cloudstream3/utils/AppContextUtils.kt | 3 +- app/src/main/res/drawable/codeberg.xml | 51 + app/src/main/res/drawable/error_24px.xml | 10 + app/src/main/res/drawable/pastebin.xml | 5 + .../res/drawable/volunteer_activism_24px.xml | 10 + app/src/main/res/drawable/warning_24px.xml | 10 + .../main/res/navigation/mobile_navigation.xml | 2 +- .../cloudstream3/mvvm/ArchComponentExt.kt | 2 +- .../cloudstream4/ClipboardHelper.android.kt | 23 + .../lagradost/cloudstream4/ClipboardHelper.kt | 6 + .../lagradost/cloudstream4/compose/Dialog.kt | 8 +- .../cloudstream4/compose/LazyList.kt | 25 + .../cloudstream4/compose/TextField.kt | 2 + .../cloudstream4/state/ImmutableState.kt | 10 +- .../viewmodels/ExtensionsViewModel2.kt | 655 ++++++++++ .../settings/widget/BasePreferenceWidget.kt | 12 +- .../cloudstream4/ClipboardHelper.jvm.kt | 26 + 24 files changed, 2192 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment2.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt create mode 100644 app/src/main/res/drawable/codeberg.xml create mode 100644 app/src/main/res/drawable/error_24px.xml create mode 100644 app/src/main/res/drawable/pastebin.xml create mode 100644 app/src/main/res/drawable/volunteer_activism_24px.xml create mode 100644 app/src/main/res/drawable/warning_24px.xml create mode 100644 shared/src/androidMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.android.kt create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.kt create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/LazyList.kt create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt create mode 100644 shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.jvm.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index 6054bbfafa8..7ea73641601 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -590,7 +590,7 @@ object PluginManager { /** * @return True if successful, false if not * */ - private suspend fun loadPlugin(context: Context, file: File, data: PluginData): Boolean { + internal suspend fun loadPlugin(context: Context, file: File, data: PluginData): Boolean { val fileName = file.nameWithoutExtension val filePath = file.absolutePath currentlyLoading = fileName 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 3879ddca413..e899cb2182b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -247,9 +247,9 @@ object RepositoryManager { private val repoLock = Mutex() suspend fun addRepository(repository: RepositoryData) { repoLock.withLock { - val currentRepos = getRepositories() - // No duplicates - setKey(REPOSITORIES_KEY, (currentRepos + repository).distinctBy { it.url }) + // No duplicates, but we want the new repository instead of the old one + val currentRepos = getRepositories().associateBy { it.url } + (repository.url to repository) + setKey(REPOSITORIES_KEY, currentRepos.values.toTypedArray()) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt new file mode 100644 index 00000000000..0849e708fe2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt @@ -0,0 +1,245 @@ +package com.lagradost.cloudstream3.ui.settings.extensions + +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.cmap +import com.lagradost.cloudstream3.plugins.PLUGIN_VERSION_NOT_SET +import com.lagradost.cloudstream3.plugins.PluginData +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.plugins.PluginManager.deletePlugin +import com.lagradost.cloudstream3.plugins.PluginManager.getPluginPath +import com.lagradost.cloudstream3.plugins.PluginManager.unloadPlugin +import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES +import com.lagradost.cloudstream3.plugins.RepositoryManager.addRepository +import com.lagradost.cloudstream3.plugins.RepositoryManager.convertRawGitUrl +import com.lagradost.cloudstream3.plugins.RepositoryManager.parseRepoUrl +import com.lagradost.cloudstream3.plugins.RepositoryManager.removeRepository +import com.lagradost.cloudstream3.plugins.SitePlugin +import com.lagradost.cloudstream3.ui.settings.ApkUpdater +import com.lagradost.cloudstream4.viewmodels.DigestPair +import com.lagradost.cloudstream4.viewmodels.Plugin +import com.lagradost.cloudstream4.viewmodels.PluginDownloadSource +import com.lagradost.cloudstream4.viewmodels.PluginInfo +import com.lagradost.cloudstream4.viewmodels.PluginStatus +import com.lagradost.cloudstream4.viewmodels.RepositoryInfo +import com.lagradost.cloudstream4.viewmodels.RepositoryManager +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import java.io.File +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.milliseconds +import kotlin.uuid.Uuid + +object AndroidRepositoryManager : RepositoryManager { + fun RepositoryInfo.toData() = + RepositoryData( + name = name, + url = url, + iconUrl = iconUrl, + description = description + ) + + fun RepositoryData.toInfo() = + RepositoryInfo( + name = name, + url = url, + iconUrl = iconUrl, + description = description + ) + + override fun getRepositories(): List { + return ((getKey>(REPOSITORIES_KEY) + ?: emptyArray()) + PREBUILT_REPOSITORIES).map { info -> + info.toInfo() + } + } + + override suspend fun getPlugins( + repository: RepositoryInfo, + repositoryUuid: Uuid + ): Pair> = + withContext(Dispatchers.IO) { + val activity = + CommonActivity.activity ?: throw ErrorLoadingException("No activity found") + + val url = parseRepoUrl(repository.url) + if (url.isNullOrBlank()) { + throw ErrorLoadingException(activity.getString(R.string.error_invalid_url)) + } + + val parsedRepository = app.get( + url = convertRawGitUrl(url), + cacheTime = 5, + cacheUnit = TimeUnit.MINUTES + ).parsed() + + /** + * No sane repository should be able to DDoS the app when navigating to settings, so + * we limit this to a maximum of 10 + * + * Note that this list is on all verified repos = 1, as pluginLists is a list of lists + * */ + require(parsedRepository.pluginLists.size <= 10) + + /** Add this as a new repository -> replaces the old data with new icon,name and description */ + addRepository( + RepositoryData( + iconUrl = parsedRepository.iconUrl, + url = url, + name = parsedRepository.name, + description = parsedRepository.description + ) + ) + + val plugins = parsedRepository.pluginLists.cmap { url -> + /** This should really be a list of size ~ 1 */ + app.get( + url = convertRawGitUrl(url), + cacheTime = 5, + cacheUnit = TimeUnit.MINUTES + ).parsed>() + }.flatMap { plugins -> + plugins.map { plugin -> + val file = getPluginPath( + context = activity, + internalName = plugin.internalName, + repositoryUrl = url + ) + Plugin( + info = PluginInfo( + name = plugin.name, + status = plugin.status, + internalName = plugin.internalName, + version = plugin.version, + tvTypes = plugin.tvTypes?.mapNotNull { runCatching { TvType.valueOf(it) }.getOrNull() } + ?.toPersistentList() + ?: persistentListOf(), + authors = plugin.authors.toPersistentList(), + iconUrl = plugin.iconUrl, + language = plugin.language, + description = plugin.description + ), + source = PluginDownloadSource( + size = plugin.fileSize, + repositoryUrl = url, + pluginUrl = plugin.url, + internalName = plugin.internalName, + digest = DigestPair.parse(plugin.fileHash) + ), + status = PluginStatus.Idle, + downloadLocation = if (file.exists()) { + file.absolutePath + } else { + null + }, + repository = repositoryUuid, + ) + } + } + + val info = RepositoryInfo( + iconUrl = parsedRepository.iconUrl, + url = url, + name = parsedRepository.name, + description = parsedRepository.description, + ) + info to plugins + } + + override suspend fun downloadPlugin( + source: PluginDownloadSource, + downloadProgress: (Long, Long?) -> Unit + ): String = withContext(Dispatchers.IO) { + val context = CommonActivity.activity ?: throw ErrorLoadingException("No activity found") + val file = getPluginPath( + context = context, + internalName = source.internalName, + repositoryUrl = source.repositoryUrl + ) + + val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir) + + tempFile.outputStream().use { writeStream -> + app.get(convertRawGitUrl(source.pluginUrl)).body.use { body -> + val readStream = body.byteStream() + ApkUpdater.transfer( + writeStream = writeStream, + readStream = readStream, + length = source.size, + downloadProgress = downloadProgress, + digest = source.digest + ) + } + } + + // Always load the plugin + unloadPlugin(file.absolutePath) + + file.delete() // delete if we somehow already have this downloaded? + file.parentFile?.mkdirs() // make the directories as Files.move needs this to work + + try { + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } + + val result = PluginManager.loadPlugin( + context = context, + file = file, + data = PluginData( + internalName = source.internalName, + url = source.pluginUrl, + isOnline = true, + filePath = file.absolutePath, + version = PLUGIN_VERSION_NOT_SET + ) + ) + if (!result) { + throw ErrorLoadingException("Unable to load plugin ${source.internalName}") + } + + return@withContext file.absolutePath + } + + override fun getLocalPlugins(localUuid: Uuid): List { + return listOf() // TODO + } + + override suspend fun deletePlugin(location: String) { + deletePlugin(File(location)) + } + + override suspend fun deleteRepository(repository: RepositoryInfo) { + val context = CommonActivity.activity ?: throw ErrorLoadingException("No activity found") + removeRepository( + context, + repository.toData() + ) + } + + override suspend fun updateRepositoryInfo(repository: RepositoryInfo) { + addRepository( + repository.toData() + ) + } +} \ No newline at end of file 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 107227497c3..4d65678568c 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 @@ -302,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, repository.description) RepositoryManager.addRepository(newRepo) extensionViewModel.loadStats() extensionViewModel.loadRepositories() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment2.kt new file mode 100644 index 00000000000..7041887df8c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment2.kt @@ -0,0 +1,6 @@ +package com.lagradost.cloudstream3.ui.settings.extensions + +import com.lagradost.cloudstream3.utils.BaseComposeFragment +import com.lagradost.cloudstream4.compose.Screen + +class ExtensionsFragment2 : BaseComposeFragment(), Screen by ExtensionsScreen \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt new file mode 100644 index 00000000000..83ccb70f980 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt @@ -0,0 +1,1072 @@ +package com.lagradost.cloudstream3.ui.settings.extensions + +import android.annotation.SuppressLint +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1 +import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2 +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import coil3.compose.AsyncImage +import com.fasterxml.jackson.core.JacksonException +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.ui.settings.SettingsFragmentScreen +import com.lagradost.cloudstream3.ui.settings.extensions.RepoAdapter.Companion.SHAREABLE_REPO_SEPARATOR +import com.lagradost.cloudstream4.compose.ActionDialog +import com.lagradost.cloudstream4.compose.BlackButton +import com.lagradost.cloudstream4.compose.BlackTextField +import com.lagradost.cloudstream4.compose.LogBoxBlack +import com.lagradost.cloudstream4.compose.LogBoxWhite +import com.lagradost.cloudstream4.compose.LogText +import com.lagradost.cloudstream4.compose.PHONE +import com.lagradost.cloudstream4.compose.Screen +import com.lagradost.cloudstream4.compose.WhiteButton +import com.lagradost.cloudstream4.compose.isLayout +import com.lagradost.cloudstream4.getPlainText +import com.lagradost.cloudstream4.setPlainText +import com.lagradost.cloudstream4.state.LogLevel +import com.lagradost.cloudstream4.state.ObserveEffect +import com.lagradost.cloudstream4.state.SearchableData +import com.lagradost.cloudstream4.state.SortByName +import com.lagradost.cloudstream4.theme.CloudStreamPreviewTheme +import com.lagradost.cloudstream4.viewmodels.AddRepositoryDialog +import com.lagradost.cloudstream4.viewmodels.ExtensionsAction +import com.lagradost.cloudstream4.viewmodels.ExtensionsEffect +import com.lagradost.cloudstream4.viewmodels.ExtensionsState +import com.lagradost.cloudstream4.viewmodels.ExtensionsViewModel2 +import com.lagradost.cloudstream4.viewmodels.Repository +import com.lagradost.cloudstream4.viewmodels.RepositoryInfo +import com.lagradost.cloudstream4.viewmodels.RepositoryStatus +import com.mihon.material.AppBar +import com.mihon.material.padding +import com.mihon.presentation.LocalBackPress +import com.mihon.presentation.secondaryItemAlpha +import com.mihon.presentation.settings.LocalPreferenceHighlighted +import com.mihon.presentation.settings.widget.BasePreferenceWidget +import com.mihon.presentation.settings.widget.PrefsHorizontalPadding +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.SerializationException +import kotlin.time.Duration.Companion.seconds +import kotlin.uuid.Uuid +import com.lagradost.cloudstream4.compose.IsScrolling +import com.lagradost.cloudstream4.compose.WhiteFilterChip +import com.lagradost.cloudstream4.compose.rounded +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.no_results_found +import com.lagradost.cloudstream4.viewmodels.DerivedRepositoryState +import com.lagradost.cloudstream4.viewmodels.FilterPlugin +import com.lagradost.cloudstream4.viewmodels.Plugin +import com.lagradost.cloudstream4.viewmodels.PluginStatus +import com.mihon.presentation.settings.widget.TextPreferenceWidget +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlin.math.roundToInt +import kotlin.stackTraceToString +import kotlin.text.replace + +@Composable +@PreviewLightDark +fun Preview() { + CloudStreamPreviewTheme { + val uuid1 = Uuid.random() + val uuid2 = Uuid.random() + val uuid3 = Uuid.random() + ExtensionsScreen.Content( + state = ExtensionsState( + addDialog = AddRepositoryDialog(error = Throwable()), + repositories = SearchableData.from( + data = persistentMapOf( + uuid1 to Repository( + uuid = uuid1, + info = RepositoryInfo( + name = "Cloudstream providers repository", + description = "hello", + url = "https://raw.githubusercontent.com/recloudstream/extensions/refs/heads/master/repo.json", + iconUrl = null + ), + status = RepositoryStatus.Loading + ), + uuid2 to Repository( + uuid = uuid2, + info = RepositoryInfo( + name = "codeberg test", + url = "https://codeberg.org/", + iconUrl = null + ), + status = RepositoryStatus.Loading + ), + uuid3 to Repository( + uuid = uuid3, + info = RepositoryInfo( + name = "parsebin test", + url = "https://pastebin.com/", + iconUrl = null + ), + status = RepositoryStatus.Loading + ) + ), + sortedBy = SortByName { it.info.name } + ) + ), + onAction = {} + ) + } +} + + +object ExtensionsScreen : Screen { + @Composable + override fun Content() { + val viewModel = + viewModel { ExtensionsViewModel2(manager = AndroidRepositoryManager) } + LaunchedEffect(Unit) { + viewModel.onAction(ExtensionsAction.Init) + } + val state by viewModel.state.collectAsState() + val highLight = remember { mutableStateOf(null) } + ObserveEffect(viewModel.effect) { effect -> + when (effect) { + is ExtensionsEffect.RepositoryAdded -> { + highLight.value = effect.uuid + } + } + } + Content(state = state, onAction = viewModel::onAction, highLightState = highLight) + } + + @Composable + fun Content( + state: ExtensionsState, + onAction: (ExtensionsAction) -> Unit, + highLightState: MutableState = remember { mutableStateOf(null) }, + ) { + val (top, bottom) = remember { FocusRequester.createRefs() } + val fabExpanded = remember { mutableStateOf(true) } + + + + com.mihon.material.Scaffold( + // TODO @WindowInsets remove this when we have converted everything to compose + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + AppBar( + modifier = Modifier + .focusRequester(top) + .focusProperties { + down = bottom + }, + title = if (state.pluginFilter.repository != null) { + state.repositories.data[state.pluginFilter.repository]?.info?.name + /** Should never happen */ + ?: stringResource(R.string.extensions) + } else if (state.searchPlugins) { + stringResource(R.string.search) + } else { + stringResource(R.string.extensions) + }, + navigateUp = LocalBackPress.current, + actions = { }, + scrollBehavior = it, + ) + }, + bottomBar = { + BottomBar(state = state, onAction = onAction) + }, + floatingActionButton = { + if (isLayout(PHONE) && !state.searchPlugins) { + FloatingActionButton(state = state, onAction = onAction, expanded = fabExpanded) + } + }, + content = { contentPadding -> + Content( + contentPadding = contentPadding, + state = state, + onAction = onAction, + highLightState = highLightState, + fabExpanded = fabExpanded + ) + }, + ) + } + + val otherTypes: Pair> = R.string.others to persistentListOf( + TvType.CustomMedia, + TvType.Video, + TvType.Others + ) + + /** To make the UI even remotely usable, we batch the types */ + val contentTypes: PersistentList>> = persistentListOf( + R.string.movies_singular to persistentListOf(TvType.Movie), + R.string.tv_series_singular to persistentListOf(TvType.TvSeries), + R.string.anime_singular to persistentListOf(TvType.Anime, TvType.AnimeMovie, TvType.OVA), + R.string.cartoons_singular to persistentListOf(TvType.Cartoon), + R.string.asian_drama_singular to persistentListOf(TvType.AsianDrama), + R.string.live_singular to persistentListOf(TvType.Live), + R.string.documentaries_singular to persistentListOf(TvType.Documentary), + R.string.torrent_singular to persistentListOf(TvType.Torrent), + R.string.audio_singular to persistentListOf( + TvType.Audio, + TvType.AudioBook, + TvType.Podcast, + TvType.Music + ), + R.string.nsfw_singular to persistentListOf(TvType.NSFW), + otherTypes, + ) + + @SuppressLint("UnrememberedMutableState") + @Composable + fun Content( + contentPadding: PaddingValues, + state: ExtensionsState, + onAction: (ExtensionsAction) -> Unit, + highLightState: MutableState, + fabExpanded: MutableState, + ) { + AddDialog(dialog = state.addDialog, onAction = onAction) + + val lazyRepoState = rememberLazyListState() + val textState = rememberTextFieldState() + val lazyState = if (state.searchPlugins) { + rememberLazyListState() + } else { + lazyRepoState + } + + BackHandler(enabled = state.searchPlugins) { + textState.clearText() + onAction(ExtensionsAction.ClearPluginSearch) + } + + /** Fab scrolling, this works but is currently a bit too fast IMPO */ + lazyRepoState.IsScrolling(up = { + fabExpanded.value = true + }, down = { + fabExpanded.value = false + }) + + /** + * We are using mihons highlight here, meaning that we scroll for a few sec + * and then remove the highlight to prevent backpresses from rehighlighting it. + * The highlight is never in the viewmodel as it is a view issue. + * */ + var highLight by highLightState + LaunchedEffect(highLight) { + if (highLight == null) return@LaunchedEffect + val index = state.repositories.sorted.indexOfFirst { uuid -> uuid == highLight } + if (index != -1) { + lazyRepoState.animateScrollToItem(index) + } + delay(5.0.seconds) + highLight = null + } + + /** + * This may look a bit weird, but it is very jarring to snap the UI like this + * (because we want to have 2 different scrolls) + * + * The user expects a transition, so we fade it using animateTo + remember(repository), + * NOT snapTo(0.0f) as snapTo is 1 recomposition too slow! + * */ + val itemAlpha = remember(state.pluginFilter.repository) { Animatable(0f) } + LaunchedEffect(state.pluginFilter.repository) { + itemAlpha.snapTo(0.0f) + itemAlpha.animateTo(1f) + } + + LaunchedEffect(textState.text) { + onAction(ExtensionsAction.Search(textState.text.toString())) + } + + LazyColumn( + state = lazyState, + contentPadding = contentPadding, + modifier = Modifier.fillMaxSize(), + ) { + item(key = "header") { + Spacer(modifier = Modifier.height(MaterialTheme.padding.medium)) + SettingsFragmentScreen.SettingsSearch(textState) + } + if (state.searchPlugins) { + item(key = "filter") { + Spacer(modifier = Modifier.height(MaterialTheme.padding.small)) + + /** + * In the case of a filter applied to the repository, + * we want to use the associated derived state to calculate what types this + * repository has + * */ + val derivedState = + state.pluginFilter.repository?.let(state.plugins.state.repositories::get) + ?: state.plugins.state.sumOfRepositories + + FilterRow( + filter = state.pluginFilter, + derivedState = derivedState, + onAction = onAction + ) + } + items(items = state.plugins.sorted, key = { uuid -> uuid }) { uuid -> + val plugin = state.plugins.data[uuid]!! + Plugin( + plugin = plugin, + onAction = onAction, + modifier = Modifier + .animateItem() + .graphicsLayer { alpha = itemAlpha.value } + ) + } + if (state.plugins.sorted.isEmpty()) { + item { + Text( + text = org.jetbrains.compose.resources.stringResource(Res.string.no_results_found), + modifier = Modifier + .secondaryItemAlpha() + .fillMaxWidth() + .padding(20.dp), + textAlign = TextAlign.Center + ) + } + } + } else { + items(items = state.repositories.sorted, key = { uuid -> uuid }) { uuid -> + val repository = state.repositories.data[uuid]!! + val isHighlighted = uuid == highLight + CompositionLocalProvider( + LocalPreferenceHighlighted provides isHighlighted, + ) { + Repository( + repository = repository, + onAction = onAction, + modifier = Modifier + .animateItem() + .graphicsLayer { alpha = itemAlpha.value } + ) + } + } + if (state.repositories.sorted.isEmpty()) { + item { + Text( + text = stringResource(R.string.blank_repo_message), + modifier = Modifier + .secondaryItemAlpha() + .fillMaxWidth() + .padding(20.dp), + textAlign = TextAlign.Center + ) + } + } + } + } + } + + @Composable + fun FilterRow( + filter: FilterPlugin, + derivedState: DerivedRepositoryState, + onAction: (ExtensionsAction) -> Unit, + ) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), + contentPadding = PaddingValues(horizontal = MaterialTheme.padding.small) + ) { + var any = false + for ((text, types) in contentTypes) { + if (types.none(derivedState::containsType)) { + continue + } + any = true + item(key = text) { + FilterSelection( + filter = filter, + text = text, + types = types, + onAction = onAction + ) + } + } + + /** If none TV types exists we always show the "other" filter */ + if (!any) { + val (text, types) = otherTypes + item(key = text) { + FilterSelection( + filter = filter, + text = text, + types = types, + onAction = onAction + ) + } + } + } + } + + @Composable + fun FilterSelection( + filter: FilterPlugin, + text: Int, + types: PersistentList, + onAction: (ExtensionsAction) -> Unit, + ) { + val selected = + filter.tvTypes.contains(types[0]) + + WhiteFilterChip( + selected = selected, + label = stringResource(text), + ) { + onAction( + ExtensionsAction.FilterByTypes( + if (selected) { + filter.tvTypes.removingAll(types) + } else { + filter.tvTypes.addingAll(types) + } + ) + ) + } + } + + @Composable + fun ErrorDialog( + error: Throwable, + tag: String, + confirmButton: @Composable (() -> Unit), + onDismissRequest: () -> Unit + ) { + val message = error.toString() + "\n\n" + error.stackTraceToString() + .lines() + .mapNotNull { line -> + val trimmed = line.trim() + if (!trimmed.startsWith("at ")) return@mapNotNull null + // pull "File.kt:42" out of "at com.pkg.Class.method(File.kt:42)" + Regex("""\(([^)]+)\)$""").find(trimmed)?.groupValues?.get(1) + ?.replace(":", " ") + } + .joinToString("\n") + AlertDialog( + containerColor = MaterialTheme.colorScheme.background, + onDismissRequest = onDismissRequest, + confirmButton = confirmButton, + dismissButton = { + BlackButton(text = stringResource(R.string.dismiss), onClick = onDismissRequest) + }, + text = { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + Row(modifier = Modifier.fillMaxWidth()) { + LogBoxWhite(LogLevel.Error.identifier) + LogBoxBlack(tag) + } + LogText( + level = LogLevel.Error, + message = message, + onClick = { + scope.launch { + clipboard.setPlainText("Logcat", message) + } + }) + } + }, + title = { + Text(text = stringResource(R.string.error)) + }) + } + + @Composable + fun Plugin( + plugin: Plugin, + onAction: (ExtensionsAction) -> Unit, + modifier: Modifier, + ) { + val description = plugin.info.description + + BasePreferenceWidget( + modifier = modifier, + title = plugin.info.name, + subcomponent = if (!description.isNullOrBlank()) { + { + Text( + text = description, + modifier = Modifier + .padding(horizontal = PrefsHorizontalPadding) + .secondaryItemAlpha(), + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + ) + } + } else null, + icon = { + RepositoryIcon( + iconUrl = plugin.info.iconUrl, + defaultIcon = painterResource(R.drawable.extension_24px) + ) + }, + onClick = { + }, + onLongClick = { + }, + widget = { + when (val status = plugin.status) { + is PluginStatus.Downloading -> { + val total = status.total + if (total != null) { + CircularProgressIndicator( + progress = { + status.progress.toFloat() / total.toFloat() + }, + modifier = Modifier + .size(IconButtonDefaults.smallContainerSize()) + .padding(MaterialTheme.padding.small), + color = MaterialTheme.colorScheme.onBackground, + ) + } else { + CircularProgressIndicator( + modifier = Modifier + .size(IconButtonDefaults.smallContainerSize()) + .padding(MaterialTheme.padding.small), + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + + is PluginStatus.Failed -> { + var errorDialog by remember { mutableStateOf(false) } + IconButton(onClick = { + errorDialog = true + }) { + Icon( + painter = painterResource(R.drawable.warning_24px), + contentDescription = null + ) + } + if (errorDialog) { + ErrorDialog( + error = status.cause, + tag = stringResource( + R.string.plugin_load_fail, + plugin.info.name + ), + confirmButton = { + WhiteButton( + text = stringResource(R.string.reload_error), + onClick = { + errorDialog = false + onAction(ExtensionsAction.DownloadPlugin(plugin.uuid)) + }) + }, + onDismissRequest = { + errorDialog = false + } + ) + } + } + + PluginStatus.Idle -> { + if (plugin.downloadLocation != null) { + IconButton(onClick = { + onAction(ExtensionsAction.DeletePlugin(plugin.uuid)) + }) { + Icon( + painter = painterResource(R.drawable.delete_24px), + contentDescription = null + ) + } + } else { + IconButton(onClick = { + onAction(ExtensionsAction.DownloadPlugin(plugin.uuid)) + }) { + Icon( + painter = painterResource(R.drawable.netflix_download), + contentDescription = null + ) + } + } + } + } + }, + ) + } + + @Composable + fun AddDialog(dialog: AddRepositoryDialog?, onAction: (ExtensionsAction) -> Unit) { + if (dialog == null) { + return + } + + AlertDialog( + containerColor = MaterialTheme.colorScheme.background, + onDismissRequest = { + onAction(ExtensionsAction.SetRepositoryDialog(null)) + }, + confirmButton = { + WhiteButton(text = stringResource(R.string.add_repository), onClick = { + onAction(ExtensionsAction.DownloadRepositoryWithDialog(url = dialog.url)) + }) + }, + dismissButton = { + BlackButton(text = stringResource(R.string.cancel), onClick = { + onAction(ExtensionsAction.SetRepositoryDialog(null)) + }) + }, + text = { + Column { + BlackTextField( + value = dialog.url, + placeHolder = stringResource(R.string.repository_url_hint), + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri), + singleLine = true + ) { newValue -> + onAction( + ExtensionsAction.SetRepositoryDialog( + dialog.copy( + url = newValue, + error = null + ) + ) + ) + } + Crossfade(dialog.loading) { loading -> + if (loading) { + Column { + Spacer(modifier = Modifier.height(MaterialTheme.padding.medium)) + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onBackground, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } else { + ErrorButton(dialog.error) + } + } + } + }, + title = { + Text(text = stringResource(R.string.add_repository)) + }) + } + + @Composable + fun ErrorButton(error: Throwable?) { + var shownError by remember { mutableStateOf(false) } + + if (shownError && error != null) { + ErrorDialog( + error = error, + tag = stringResource(R.string.error_invalid_url), + confirmButton = {}, + onDismissRequest = { + shownError = false + }) + } + + AnimatedVisibility(error != null, enter = fadeIn(), exit = fadeOut()) { + if (error == null) { + return@AnimatedVisibility + } + + val message = error.message + TextPreferenceWidget( + modifier = Modifier.rounded(), + title = when { + error is JacksonException -> { + stringResource(R.string.no_repository_found_error) + } + + error is SerializationException -> { + stringResource(R.string.no_repository_found_error) + } + + message != null -> { + message + } + + else -> stringResource(R.string.error_invalid_data) + }, + icon = painterResource(R.drawable.warning_24px), + iconTint = LocalContentColor.current + ) { + shownError = true + } + } + } + + @Composable + fun RepositoryIcon(iconUrl: String?, defaultIcon: Painter) { + if (iconUrl != null) { + AsyncImage( + model = remapUrl(url = iconUrl, size = 24.dp), + contentDescription = null, + modifier = Modifier.size(24.dp), + error = defaultIcon + ) + } else { + Icon( + painter = defaultIcon, + tint = MaterialTheme.colorScheme.onBackground, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + } + + @Composable + fun FloatingActionButton( + state: ExtensionsState, + expanded: MutableState, + onAction: (ExtensionsAction) -> Unit + ) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + + ExtendedFloatingActionButton( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onBackground, + elevation = FloatingActionButtonDefaults.elevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + focusedElevation = 0.dp, + hoveredElevation = 0.dp + ), + expanded = expanded.value, + text = { + Text(stringResource(R.string.add_repository)) + }, + icon = { + Icon( + painter = painterResource(R.drawable.add_24px), + contentDescription = stringResource(R.string.add_repository) + ) + }, + onClick = { + scope.launch { + val copiedText = clipboard.getPlainText()?.trim() ?: "" + + /** text is of format : */ + val url = if (copiedText.contains(SHAREABLE_REPO_SEPARATOR)) { + val (_, url) = copiedText.split( + SHAREABLE_REPO_SEPARATOR, + limit = 2 + ) + url.trim() + } + /** Can this realistically be an url? */ + else if (copiedText.length < 200 && !copiedText.contains("\n")) { + copiedText + } + /** Otherwise we just do a clear */ + else { + "" + } + onAction( + ExtensionsAction.SetRepositoryDialog( + AddRepositoryDialog(url = url) + ) + ) + } + }) + } + + @Composable + fun BottomBar( + state: ExtensionsState, + onAction: (ExtensionsAction) -> Unit + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(MaterialTheme.padding.small) + ) { + Text(stringResource(R.string.extensions)) + Spacer(modifier = Modifier.height(MaterialTheme.padding.small)) + val shape = RoundedCornerShape(3.dp) + val size = 10.dp + Row(modifier = Modifier.clip(shape)) { + Box( + modifier = Modifier + .height(size) + .background(color = MaterialTheme.colorScheme.onBackground) + .weight( + maxOf( + state.plugins.state.downloadedAndEnabled.toFloat(), + 0.1f + ) + ) + ) + Box( + modifier = Modifier + .height(size) + .background(color = MaterialTheme.colorScheme.primary) + .weight( + maxOf( + state.plugins.state.downloadedAndDisabled.toFloat(), + 0.1f + ) + ) + ) + Box( + modifier = Modifier + .height(size) + .background(color = MaterialTheme.colorScheme.onSurfaceVariant) + .weight(maxOf(state.plugins.state.notDownloaded.toFloat(), 0.1f)) + ) + } + Spacer(modifier = Modifier.height(MaterialTheme.padding.small)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.padding.extraSmall + ) + ) { + Box( + modifier = Modifier + .size(size) + .background( + color = MaterialTheme.colorScheme.onBackground, + shape = shape + ) + ) + Text( + text = stringResource( + R.string.plugins_downloaded, + state.plugins.state.downloadedAndEnabled + ), + style = MaterialTheme.typography.labelMedium + ) + Box( + modifier = Modifier + .size(size) + .background( + color = MaterialTheme.colorScheme.primary, + shape = shape + ) + ) + Text( + text = stringResource( + R.string.plugins_disabled, + state.plugins.state.downloadedAndDisabled + ), + style = MaterialTheme.typography.labelMedium + ) + Box( + modifier = Modifier + .size(size) + .background( + color = MaterialTheme.colorScheme.onSurfaceVariant, + shape = shape + ) + ) + Text( + text = stringResource( + R.string.plugins_not_downloaded, + state.plugins.state.notDownloaded + ), + style = MaterialTheme.typography.labelMedium + ) + } + } + } + + @Composable + fun Repository( + modifier: Modifier, + repository: Repository, + onAction: (ExtensionsAction) -> Unit + ) { + var deleteDialog by remember { mutableStateOf(false) } + + if (deleteDialog) { + ActionDialog( + title = stringResource(R.string.delete_repository), + text = stringResource(R.string.delete_repository_plugins), + dismiss = { + deleteDialog = false + }, + confirm = { + onAction(ExtensionsAction.DeleteRepository(repository.uuid)) + }, + confirmText = stringResource(R.string.delete), + dismissText = stringResource(R.string.cancel), + ) + } + + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + val copyTextLabel = stringResource(R.string.repo_copy_label) + + BasePreferenceWidget( + modifier = modifier, + title = repository.info.name, + subcomponent = { + Text( + text = repository.info.description ?: repository.info.url, + modifier = Modifier + .padding(horizontal = PrefsHorizontalPadding) + .secondaryItemAlpha(), + style = MaterialTheme.typography.bodyMedium, + maxLines = 10, + ) + }, + icon = { + RepositoryIcon( + iconUrl = repository.info.iconUrl, + painterResource( + when { + repository.info.url.contains("codeberg") -> R.drawable.codeberg + repository.info.url.contains("pastebin") -> R.drawable.pastebin + else -> R.drawable.ic_github_logo + } + ) + ) + }, + onClick = { + onAction(ExtensionsAction.SelectRepository(repository.uuid)) + }, + onLongClick = { + val shareableRepoData = + "${repository.info.name}$SHAREABLE_REPO_SEPARATOR\n ${repository.info.url}" + + scope.launch { + clipboard.setPlainText( + label = copyTextLabel, + text = shareableRepoData + ) + } + }, + widget = { + when (val reason = repository.status) { + is RepositoryStatus.Failure -> { + var errorDialog by remember { mutableStateOf(false) } + IconButton(onClick = { + errorDialog = true + }) { + Icon( + painter = painterResource(R.drawable.warning_24px), + contentDescription = null + ) + } + if (errorDialog) { + ErrorDialog( + error = reason.cause, + tag = stringResource(R.string.download_failed), + confirmButton = { + WhiteButton(text = stringResource(R.string.delete_repository)) { + deleteDialog = true + } + }, + onDismissRequest = { + errorDialog = false + }) + } + } + + RepositoryStatus.Loading, RepositoryStatus.Success -> { + IconButton(onClick = { + deleteDialog = true + }) { + Icon( + painter = painterResource(R.drawable.delete_24px), + contentDescription = null + ) + /*CircularProgressIndicator( + modifier = Modifier + .size(IconButtonDefaults.smallContainerSize()) + .padding(MaterialTheme.padding.small), + color = MaterialTheme.colorScheme.onBackground, + )*/ + } + } + } + }, + ) + } + + @Composable + fun remapUrl(url: String, size: Dp): String { + tailrec fun findClosestBase2(target: Int, current: Int = 16, max: Int = 512): Int { + if (current >= max) return max + if (current >= target) return current + return findClosestBase2(target, current * 2, max) + } + + val pxValue = with(LocalDensity.current) { maxOf(1, size.toPx().roundToInt()) } + return url.replace( + "%size%", + "${findClosestBase2(pxValue, 16, 512)}" + ).replace( + "%exact_size%", + "$pxValue" + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt index d3096259833..2fe63a59b89 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt @@ -23,8 +23,21 @@ data class RepositoryData( @JsonProperty("iconUrl") @SerialName("iconUrl") val iconUrl: String?, @JsonProperty("name") @SerialName("name") val name: String, @JsonProperty("url") @SerialName("url") val url: String, + @JsonProperty("description") @SerialName("description") val description: String? = null, ) { - constructor(name: String, url: String): this(null, name, url) + constructor(name: String, url: String) : this( + iconUrl = null, + name = name, + url = url, + description = null + ) + + constructor(iconUrl: String?, name: String, url: String) : this( + iconUrl = iconUrl, + name = name, + url = url, + description = null + ) } const val REPOSITORIES_KEY = "REPOSITORIES_KEY" diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt index e0661721ac3..2e382912032 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -527,7 +527,8 @@ object AppContextUtils { val data = RepositoryData( repo.iconUrl ?: "", repo.name, - url + url, + repo.description ) RepositoryManager.addRepository(data) main { diff --git a/app/src/main/res/drawable/codeberg.xml b/app/src/main/res/drawable/codeberg.xml new file mode 100644 index 00000000000..9ac01ce95df --- /dev/null +++ b/app/src/main/res/drawable/codeberg.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/error_24px.xml b/app/src/main/res/drawable/error_24px.xml new file mode 100644 index 00000000000..31feeeca19d --- /dev/null +++ b/app/src/main/res/drawable/error_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/pastebin.xml b/app/src/main/res/drawable/pastebin.xml new file mode 100644 index 00000000000..9a2d5cd978d --- /dev/null +++ b/app/src/main/res/drawable/pastebin.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/volunteer_activism_24px.xml b/app/src/main/res/drawable/volunteer_activism_24px.xml new file mode 100644 index 00000000000..f2e719d3a34 --- /dev/null +++ b/app/src/main/res/drawable/volunteer_activism_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/warning_24px.xml b/app/src/main/res/drawable/warning_24px.xml new file mode 100644 index 00000000000..ab1606dc990 --- /dev/null +++ b/app/src/main/res/drawable/warning_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index d60abc203dd..b29fc512712 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -196,7 +196,7 @@ diff --git a/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.android.kt b/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.android.kt new file mode 100644 index 00000000000..0160199180a --- /dev/null +++ b/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.android.kt @@ -0,0 +1,23 @@ +package com.lagradost.cloudstream4 + +import android.content.ClipData +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard + +actual suspend fun Clipboard.getPlainText(): String? = + getClipEntry()?.clipData?.getItemAt(0)?.text?.toString() + + +actual suspend fun Clipboard.setPlainText( + label: String, + text: String +) { + setClipEntry( + ClipEntry( + ClipData.newPlainText( + label, + text + ) + ) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.kt new file mode 100644 index 00000000000..4b432877e47 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.kt @@ -0,0 +1,6 @@ +package com.lagradost.cloudstream4 + +import androidx.compose.ui.platform.Clipboard + +expect suspend fun Clipboard.setPlainText(label: String, text: String) +expect suspend fun Clipboard.getPlainText(): String? diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Dialog.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Dialog.kt index a944889a512..357cc26d054 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Dialog.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Dialog.kt @@ -1,12 +1,16 @@ package com.lagradost.cloudstream4.compose import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -63,7 +67,9 @@ fun ActionDialog( } } }, - text = { Text(text = text) }, + text = { + Text(text = text, modifier = Modifier.verticalScroll(rememberScrollState())) + }, confirmButton = { WhiteButton(text = confirmText, onClick = confirm) }, diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/LazyList.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/LazyList.kt new file mode 100644 index 00000000000..d41ef01ce3e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/LazyList.kt @@ -0,0 +1,25 @@ +package com.lagradost.cloudstream4.compose + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect + +@Composable +fun LazyGridState.IsScrolling(up : Runnable, down : Runnable) { + LaunchedEffect(lastScrolledBackward) { + if(lastScrolledBackward) up.run() + } + LaunchedEffect(lastScrolledForward) { + if(lastScrolledForward) down.run() + } +} +@Composable +fun LazyListState.IsScrolling(up : Runnable, down : Runnable) { + LaunchedEffect(lastScrolledBackward) { + if(lastScrolledBackward) up.run() + } + LaunchedEffect(lastScrolledForward) { + if(lastScrolledForward) down.run() + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/TextField.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/TextField.kt index 81f42167196..be96bf5e4e8 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/TextField.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/TextField.kt @@ -12,6 +12,7 @@ fun BlackTextField( placeHolder : String, modifier: Modifier = Modifier, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + singleLine: Boolean = false, onValueChange: (String) -> Unit, ) { TextField( @@ -22,6 +23,7 @@ fun BlackTextField( placeholder = { Text(text = placeHolder) }, + singleLine = singleLine, shape = RoundedShape(), colors = Colors.blackTextField, ) diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt index c20f2a16a97..9da620f5d83 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt @@ -35,9 +35,10 @@ data class FilterByQuery( override fun invoke(value: Value): Boolean { if (query.isEmpty()) return onEmpty val transformed = transform(value) - return Levenshtein.ratio( + val matchAgainst = if (ignoreCase) transformed.lowercase() else transformed + return matchAgainst.startsWith(query) || Levenshtein.ratio( query, - if (ignoreCase) transformed.lowercase() else transformed + matchAgainst ) > ratio } @@ -122,6 +123,8 @@ fun PersistentMap.edit(key: K, updater: V.() -> V): PersistentMap, Value> { /** State = {} */ @@ -137,6 +140,9 @@ interface DerivedState, Value> { /** * For state that updated a lot we might need to calculate some accumulation, then * this is used to more ergonomically and efficiently do a `.filter {}` or `.count {}`. + * + * Note that the derived state is calculated *before* the filtering, and as such the filterBy has + * no effect on the state. */ @Immutable data class SearchableDataState>( diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt new file mode 100644 index 00000000000..28377b70661 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt @@ -0,0 +1,655 @@ +package com.lagradost.cloudstream4.viewmodels + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream4.state.ActionHandler +import com.lagradost.cloudstream4.state.DefaultEffectContainer +import com.lagradost.cloudstream4.state.DefaultStateContainer +import com.lagradost.cloudstream4.state.DerivedState +import com.lagradost.cloudstream4.state.EffectContainer +import com.lagradost.cloudstream4.state.FilterByQuery +import com.lagradost.cloudstream4.state.MultiActiveQuery +import com.lagradost.cloudstream4.state.SearchableData +import com.lagradost.cloudstream4.state.SearchableDataState +import com.lagradost.cloudstream4.state.SingleActiveQuery +import com.lagradost.cloudstream4.state.SortByName +import com.lagradost.cloudstream4.state.StateContainer +import com.lagradost.cloudstream4.state.UniqueItem +import com.lagradost.cloudstream4.viewmodels.ExtensionsEffect.RepositoryAdded +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.PersistentSet +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toPersistentMap +import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.uuid.Uuid + +@Immutable +data class RepositoryInfo( + val iconUrl: String?, + val name: String, + val url: String, + val description: String? = null, +) + +@Immutable +data class Repository( + val info: RepositoryInfo, + val status: RepositoryStatus, + override val uuid: Uuid +) : UniqueItem + +@Immutable +sealed class RepositoryStatus { + object Loading : RepositoryStatus() + object Success : RepositoryStatus() + data class Failure(val cause: Throwable) : RepositoryStatus() +} + +@Immutable +data class Plugin( + val info: PluginInfo, + val source: PluginDownloadSource?, + val status: PluginStatus, + val downloadLocation: String?, + val repository: Uuid, + override val uuid: Uuid = Uuid.random() +) : UniqueItem + +@Immutable +data class PluginInfo( + val name: String, + val internalName: String, + val version: Int, + val tvTypes: PersistentList, + val authors: PersistentList, + val status: Int, + val iconUrl: String?, + val language: String?, + val description: String?, +) + +@Immutable +sealed class PluginStatus { + object Idle : PluginStatus() + data class Downloading(val progress: Long, val total: Long?) : PluginStatus() + data class Failed(val cause: Throwable) : PluginStatus() +} + +@Immutable +data class PluginDownloadSource( + val size: Long?, + val internalName: String, + val pluginUrl: String, + val repositoryUrl: String, + val digest: DigestPair?, +) + +@Immutable +data class FilterPlugin( + val tvTypes: PersistentSet = TvType.entries.toSet().toPersistentSet(), + val repository: Uuid? = null, + val query: FilterByQuery = FilterByQuery(query = "") { it.info.name }, +) : Function1 { + override fun invoke(plugin: Plugin): Boolean = + /** Filter by repository */ + (repository == null || (plugin.repository == repository)) && + /** Filter by query */ + query(plugin) && + /** Filter by type */ + (if (plugin.info.tvTypes.isEmpty()) { + /** + * This is a strange edge-case, but if no tv-types are recognized they + * automatically should be treated as "Other" to avoid always filtering them + */ + tvTypes.contains(TvType.Others) + } else { + plugin.info.tvTypes.any(tvTypes::contains) + }) +} + +@Immutable +data class DerivedRepositoryState( + /** + * This is ***readonly***! + * + * TvType.ordinal -> Int, we use an IntArray to prevent boxing and such. + * + * We do not win very much having a persistantList/persitantMap here even if it is a bit more + * "correct". + * */ + private val countOfTypes: IntArray = IntArray(TvType.entries.size), + val downloadedAndEnabled: Int = 0, + val downloadedAndDisabled: Int = 0, + val notDownloaded: Int = 0, +) : DerivedState { + fun containsType(type : TvType) : Boolean = countOfTypes[type.ordinal] > 0 + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DerivedRepositoryState) return false + if (downloadedAndEnabled != other.downloadedAndEnabled) return false + if (downloadedAndDisabled != other.downloadedAndDisabled) return false + if (notDownloaded != other.notDownloaded) return false + if (!countOfTypes.contentEquals(other.countOfTypes)) return false + return true + } + + override fun hashCode(): Int { + var result = downloadedAndEnabled + result = 31 * result + downloadedAndDisabled + result = 31 * result + notDownloaded + result = 31 * result + countOfTypes.contentHashCode() + return result + } + + override fun clearing(): DerivedRepositoryState = DerivedRepositoryState() + + override fun adding(value: Plugin): DerivedRepositoryState { + val isDownloaded = value.downloadLocation != null + val isDisabled = value.info.status == 0 + val isEnabled = !isDisabled + val isNotDownloaded = !isDownloaded + + val newCountOfTypes = countOfTypes.copyOf() + for (type in value.info.tvTypes) { + newCountOfTypes[type.ordinal] = newCountOfTypes[type.ordinal] + 1 + } + return copy( + downloadedAndEnabled = downloadedAndEnabled + if (isDownloaded && isEnabled) 1 else 0, + downloadedAndDisabled = downloadedAndDisabled + if (isDownloaded && isDisabled) 1 else 0, + notDownloaded = notDownloaded + if (isNotDownloaded) 1 else 0, + countOfTypes = newCountOfTypes + ) + } + + override fun removing(value: Plugin): DerivedRepositoryState { + val isDownloaded = value.downloadLocation != null + val isDisabled = value.info.status == 0 + val isEnabled = !isDisabled + val isNotDownloaded = !isDownloaded + + val newCountOfTypes = countOfTypes.copyOf() + for (type in value.info.tvTypes) { + newCountOfTypes[type.ordinal] = newCountOfTypes[type.ordinal] + 1 + } + return copy( + downloadedAndEnabled = downloadedAndEnabled - if (isDownloaded && isEnabled) 1 else 0, + downloadedAndDisabled = downloadedAndDisabled - if (isDownloaded && isDisabled) 1 else 0, + notDownloaded = notDownloaded - if (isNotDownloaded) 1 else 0, + countOfTypes = newCountOfTypes + ) + } +} + +@Immutable +data class DerivedPluginsState( + val sumOfRepositories: DerivedRepositoryState = DerivedRepositoryState(), + val repositories: PersistentMap = persistentMapOf(), +) : DerivedState { + /** This is ***readonly***! Do not mutate */ + val downloadedAndEnabled: Int get() = sumOfRepositories.downloadedAndEnabled + val downloadedAndDisabled: Int get() = sumOfRepositories.downloadedAndDisabled + val notDownloaded: Int get() = sumOfRepositories.notDownloaded + + companion object { + val zero = DerivedRepositoryState() + } + + override fun clearing(): DerivedPluginsState = DerivedPluginsState() + + override fun adding(value: Plugin): DerivedPluginsState { + val current = repositories[value.repository] ?: zero + val newRepository = current.adding(value) + + return copy( + sumOfRepositories = sumOfRepositories.adding(value), + repositories = repositories.putting(value.repository, newRepository) + ) + } + + override fun removing(value: Plugin): DerivedPluginsState { + val current = repositories[value.repository] ?: zero + val newRepository = current.removing(value) + + return copy( + sumOfRepositories = sumOfRepositories.removing(value), + repositories = repositories.putting(value.repository, newRepository) + ) + } +} + +@Immutable +data class ExtensionsState( + val repositories: SearchableData = SearchableData.from( + data = persistentMapOf(), + sortedBy = SortByName { it.info.name } + ), + val plugins: SearchableDataState = SearchableDataState.from( + data = persistentMapOf(), + sortedBy = SortByName { it.info.name }, + state = DerivedPluginsState() + ), + val pluginFilter: FilterPlugin = FilterPlugin(), + //val repoFilter: FilterByQuery = FilterByQuery(query = "") { it.info.name }, + val addDialog: AddRepositoryDialog? = null, + val searchPlugins: Boolean = false +) + +@Immutable +data class AddRepositoryDialog( + val url: String = "", + val loading: Boolean = false, + val error: Throwable? = null, +) + +sealed class ExtensionsEffect { + data class RepositoryAdded(val repository: RepositoryInfo, val uuid: Uuid) : ExtensionsEffect() +} + +interface RepositoryManager { + fun getRepositories(): List + + @Throws + suspend fun getPlugins( + repository: RepositoryInfo, + repositoryUuid: Uuid + ): Pair> + + fun getLocalPlugins(localUuid: Uuid): List + + @Throws + suspend fun deletePlugin(location: String) + + @Throws + suspend fun deleteRepository(repository: RepositoryInfo) + + @Throws + suspend fun downloadPlugin( + source: PluginDownloadSource, + downloadProgress: (Long, Long?) -> Unit, + ): String + + suspend fun updateRepositoryInfo(repository: RepositoryInfo) +} + +@Immutable +sealed class ExtensionsAction { + object Init : ExtensionsAction() + data class DeleteRepository(val uuid: Uuid) : ExtensionsAction() + data class DeletePlugin(val uuid: Uuid) : ExtensionsAction() + data class DownloadPlugin(val uuid: Uuid) : ExtensionsAction() + + //data class DownloadRepository(val repository: RepositoryInfo) : ExtensionsAction() + data class DownloadRepositoryWithDialog(val url: String) : ExtensionsAction() + data class SelectRepository(val uuid: Uuid?) : ExtensionsAction() + data class Search(val query: String) : ExtensionsAction() + data class FilterByTypes(val types: PersistentSet) : ExtensionsAction() + data class SetRepositoryDialog(val dialog: AddRepositoryDialog?) : ExtensionsAction() + object ClearPluginSearch : ExtensionsAction() +} + +class ExtensionsViewModel2( + private val manager: RepositoryManager, + private val localUuid: Uuid = Uuid.random(), +) : ViewModel(), StateContainer by DefaultStateContainer(ExtensionsState()), + EffectContainer by DefaultEffectContainer(), + ActionHandler { + private val repositoryDispatcher = MultiActiveQuery(dispatcher = Dispatchers.IO) + private val downloadDispatcher = MultiActiveQuery(dispatcher = Dispatchers.IO) + private val addRepositoryDispatcher = SingleActiveQuery(dispatcher = Dispatchers.IO) + + override fun onAction(action: ExtensionsAction) { + when (action) { + ExtensionsAction.ClearPluginSearch -> { + updateState { + val newPluginFilter = pluginFilter.copy(repository = null) + copy( + searchPlugins = false, + pluginFilter = newPluginFilter, + plugins = plugins.filteringBy(newPluginFilter), + ) + } + } + + ExtensionsAction.Init -> { + viewModelScope.launch { + init() + } + } + + is ExtensionsAction.SetRepositoryDialog -> { + updateState { + copy(addDialog = action.dialog) + } + } + + is ExtensionsAction.DeleteRepository -> { + val repository = state.value.repositories.data[action.uuid] ?: return + viewModelScope.launch { + deleteRepository(repository) + } + } + + is ExtensionsAction.SelectRepository -> { + updateState { + val newPluginFilter = pluginFilter.copy(repository = action.uuid) + copy( + plugins = plugins.filteringBy(newPluginFilter), + pluginFilter = newPluginFilter, + searchPlugins = true, + ) + } + } + + is ExtensionsAction.Search -> { + updateState { + val newPluginFilter = + pluginFilter.copy(query = pluginFilter.query.update(action.query)) + //val newRepoFilter = repoFilter.update(action.query) + copy( + plugins = plugins.filteringBy(newPluginFilter), + //repositories = repositories.filteringBy(newRepoFilter), + pluginFilter = newPluginFilter, + searchPlugins = pluginFilter.repository != null || action.query.isNotEmpty(), + //repoFilter = newRepoFilter, + ) + } + } + + is ExtensionsAction.DeletePlugin -> { + val plugin = state.value.plugins.data[action.uuid] ?: return + viewModelScope.launch { + deletePlugin(plugin) + } + } + + is ExtensionsAction.DownloadPlugin -> { + val plugin = state.value.plugins.data[action.uuid] ?: return + viewModelScope.launch { + downloadPlugin(plugin) + } + } + + is ExtensionsAction.DownloadRepositoryWithDialog -> { + val uuid = Uuid.random() + val url = action.url.trim() + + viewModelScope.launch { + val found = + state.value.repositories.data.values.firstOrNull { it.info.url == url } + if (found != null) { + postEffect { RepositoryAdded(found.info, found.uuid) } + updateState { + copy(addDialog = null) + } + return@launch + } + updateState { + copy( + addDialog = AddRepositoryDialog( + url = action.url, + loading = true, + error = null + ) + ) + } + val repository = RepositoryInfo( + iconUrl = null, + name = "Pending", + url = url, + description = null + ) + addRepositoryDispatcher.launch { + runCatching { + manager.getPlugins( + repository, + uuid + ) + }.onFailure { t -> + /** Cancellation means that we restarted this, so the caller updates the status */ + if (!isActive) { + return@launch + } + updateState { + copy( + addDialog = AddRepositoryDialog( + url = action.url, + loading = false, + error = t + ) + ) + } + }.onSuccess { (newRepositoryInfo, pluginsFetched) -> + val found = + state.value.repositories.data.values.firstOrNull { it.info.url == newRepositoryInfo.url } + if (found != null) { + // Shortcodes can have a different url from the input + updateState { + copy(addDialog = null) + } + postEffect { + RepositoryAdded( + found.info, + found.uuid + ) + } + return@launch + } + onRepositoryAdded( + uuid = uuid, + newRepositoryInfo = newRepositoryInfo, + pluginsFetched = pluginsFetched + ) + updateState { + copy(addDialog = null) + } + postEffect { RepositoryAdded(newRepositoryInfo, uuid) } + } + } + } + } + + /*is ExtensionsAction.DownloadRepository -> { + viewModelScope.launch { + if (state.value.repositories.data.any { it.value.info.url == action.repository.url }) { + return@launch + } + val uuid = Uuid.random() + downloadRepository(uuid = uuid, repository = action.repository) + } + }*/ + is ExtensionsAction.FilterByTypes -> { + updateState { + val newPluginFilter = pluginFilter.copy(tvTypes = action.types) + copy( + plugins = plugins.filteringBy(newPluginFilter), + pluginFilter = newPluginFilter + ) + } + } + } + } + + /*private suspend fun downloadRepository( + uuid: Uuid, + repository: RepositoryInfo + ) { + updateState { + copy( + repositories = repositories.adding( + uuid, + Repository( + info = repository, + status = RepositoryStatus.Loading, + uuid = uuid + ) + ) + ) + } + loadRepository(uuid, repository) + }*/ + + private suspend fun onRepositoryAdded( + uuid: Uuid, + newRepositoryInfo: RepositoryInfo, + pluginsFetched: List + ) { + manager.updateRepositoryInfo(newRepositoryInfo) + val newPlugins = pluginsFetched.associateBy { it.uuid } + updateState { + copy( + plugins = plugins.setTo(plugins.data.puttingAll(newPlugins)), + repositories = repositories.adding( + uuid, Repository( + status = RepositoryStatus.Success, + info = newRepositoryInfo, + uuid = uuid + ) + ) + ) + } + } + + private suspend fun deleteRepository(repository: Repository) { + /** Cancel repositoryDispatcher loading to prevent race */ + repositoryDispatcher.launch(repository.uuid) { + updateState { + copy(repositories = repositories.updating(repository.uuid) { + copy(status = RepositoryStatus.Loading) + }) + } + runCatching { manager.deleteRepository(repository.info) }.onSuccess { + /** -> Remove all visual state of this plugin */ + updateState { + copy( + repositories = repositories.removing(repository.uuid), + plugins = plugins.setTo(plugins.data.filter { it.value.repository != repository.uuid } + .toPersistentMap()) + ) + } + }.onFailure { e -> + /** -> Unable to remove, so we just show the error */ + updateState { + copy(repositories = repositories.updating(repository.uuid) { + copy(status = RepositoryStatus.Failure(e)) + }) + } + } + } + } + + private suspend fun cancelPlugin(uuid: Uuid) { + downloadDispatcher.cancelOne(uuid) + } + + private suspend fun deletePlugin(plugin: Plugin) { + val downloadLocation = plugin.downloadLocation ?: return + downloadDispatcher.launch(plugin.uuid) { + runCatching { manager.deletePlugin(downloadLocation) }.onFailure { t -> + updateState { + copy(plugins = plugins.updating(plugin.uuid) { + copy(status = PluginStatus.Failed(t)) + }) + } + }.onSuccess { + updateState { + copy(plugins = plugins.updating(plugin.uuid) { + copy(status = PluginStatus.Idle, downloadLocation = null) + }) + } + } + } + } + + private suspend fun downloadPlugin(plugin: Plugin) { + val source = plugin.source ?: return + downloadDispatcher.launch(plugin.uuid) { + runCatching { + manager.downloadPlugin( + source = source, + downloadProgress = { downloadedBytes, totalBytes -> + updateState { + copy(plugins = plugins.updating(plugin.uuid) { + copy( + status = PluginStatus.Downloading( + total = totalBytes, + progress = downloadedBytes + ) + ) + }) + } + } + ) + }.onFailure { t -> + updateState { + copy(plugins = plugins.updating(plugin.uuid) { + copy(status = PluginStatus.Failed(t)) + }) + } + }.onSuccess { downloadLocation -> + updateState { + copy(plugins = plugins.updating(plugin.uuid) { + copy(status = PluginStatus.Idle, downloadLocation = downloadLocation) + }) + } + } + } + } + + private suspend fun loadRepository(uuid: Uuid, repository: RepositoryInfo) { + repositoryDispatcher.launch(uuid) { + runCatching { + manager.getPlugins( + repository, + uuid + ) + }.onFailure { t -> + /** Cancellation means that we restarted this, so the caller updates the status */ + if (!isActive) { + return@launch + } + updateState { + copy(repositories = repositories.updating(uuid) { + copy(status = RepositoryStatus.Failure(t)) + }) + } + }.onSuccess { (newRepositoryInfo, pluginsFetched) -> + onRepositoryAdded( + uuid = uuid, + newRepositoryInfo = newRepositoryInfo, + pluginsFetched = pluginsFetched + ) + } + } + } + + private suspend fun init() { + val repos = manager.getRepositories() + val reposMap = repos.associate { info -> + val uuid = Uuid.random() + uuid to Repository(info = info, status = RepositoryStatus.Loading, uuid = uuid) + }.toPersistentMap() + + updateState { + copy(repositories = repositories.setTo(newData = reposMap)) + } + + repositoryDispatcher.launch(localUuid) { + val pluginsFetched = manager.getLocalPlugins(localUuid) + val newPlugins = pluginsFetched.associateBy { it.uuid } + updateState { + copy(plugins = plugins.setTo(plugins.data.puttingAll(newPlugins))) + } + } + + for ((uuid, repository) in reposMap) { + loadRepository(uuid, repository.info) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/BasePreferenceWidget.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/BasePreferenceWidget.kt index e103a144096..467e0b3e5f6 100644 --- a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/BasePreferenceWidget.kt +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/BasePreferenceWidget.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.core.repeatable import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -36,12 +37,13 @@ import kotlinx.coroutines.delay import kotlin.time.Duration.Companion.seconds @Composable -internal fun BasePreferenceWidget( +fun BasePreferenceWidget( modifier: Modifier = Modifier, title: String? = null, subcomponent: @Composable (ColumnScope.() -> Unit)? = null, icon: @Composable (() -> Unit)? = null, onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, widget: @Composable (() -> Unit)? = null, ) { val highlighted = LocalPreferenceHighlighted.current @@ -51,7 +53,7 @@ internal fun BasePreferenceWidget( .highlightBackground(highlighted) .focusOutline() .sizeIn(minHeight = minHeight) - .clickable(enabled = onClick != null, onClick = { onClick?.invoke() }) + .combinedClickable(enabled = onClick != null, onLongClick = onLongClick, onClick = { onClick?.invoke() }) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { @@ -90,11 +92,13 @@ internal fun BasePreferenceWidget( @Composable internal fun Modifier.highlightBackground(highlighted: Boolean): Modifier { var highlightFlag by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { + LaunchedEffect(highlighted) { if (highlighted) { highlightFlag = true delay(3.seconds) highlightFlag = false + } else { + highlightFlag = false } } val highlight by animateColorAsState( @@ -122,6 +126,6 @@ internal fun Modifier.highlightBackground(highlighted: Boolean): Modifier { } internal val TrailingWidgetBuffer = 16.dp -internal val PrefsHorizontalPadding = 16.dp +val PrefsHorizontalPadding = 16.dp internal val PrefsVerticalPadding = 16.dp internal val TitleFontSize = 16.sp diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.jvm.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.jvm.kt new file mode 100644 index 00000000000..5e5e3d24092 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.jvm.kt @@ -0,0 +1,26 @@ +package com.lagradost.cloudstream4 + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.asAwtTransferable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.StringSelection + +@OptIn(ExperimentalComposeUiApi::class) +actual suspend fun Clipboard.getPlainText(): String? = withContext(Dispatchers.IO) { + val transferable = this@getPlainText.getClipEntry()?.asAwtTransferable + if (transferable != null && transferable.isDataFlavorSupported(DataFlavor.stringFlavor)) + transferable.getTransferData(DataFlavor.stringFlavor) as? String + else null +} + +@OptIn(ExperimentalComposeUiApi::class) +actual suspend fun Clipboard.setPlainText( + label: String, + text: String +) { + this.setClipEntry(ClipEntry(StringSelection(text))) +} \ No newline at end of file From cf36b464ed6302048426c701f5e8129fb055ca36 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:15:39 +0200 Subject: [PATCH 04/19] Language fixes --- .../settings/extensions/ExtensionsScreen.kt | 305 ++++++++++++++---- .../cloudstream4/state/ImmutableState.kt | 56 +++- .../viewmodels/ExtensionsViewModel2.kt | 91 +++++- 3 files changed, 383 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt index 83ccb70f980..ead4a782f79 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt @@ -1,6 +1,8 @@ package com.lagradost.cloudstream3.ui.settings.extensions import android.annotation.SuppressLint +import android.text.format.Formatter.formatShortFileSize +import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade @@ -11,6 +13,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -62,21 +65,28 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage import com.fasterxml.jackson.core.JacksonException import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.ui.settings.SettingsFragmentScreen import com.lagradost.cloudstream3.ui.settings.extensions.RepoAdapter.Companion.SHAREABLE_REPO_SEPARATOR +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName +import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream4.compose.ActionDialog import com.lagradost.cloudstream4.compose.BlackButton import com.lagradost.cloudstream4.compose.BlackTextField @@ -116,10 +126,13 @@ import kotlinx.serialization.SerializationException import kotlin.time.Duration.Companion.seconds import kotlin.uuid.Uuid import com.lagradost.cloudstream4.compose.IsScrolling +import com.lagradost.cloudstream4.compose.MultiSelectDialog import com.lagradost.cloudstream4.compose.WhiteFilterChip import com.lagradost.cloudstream4.compose.rounded import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.cancel import com.lagradost.cloudstream4.generated.resources.no_results_found +import com.lagradost.cloudstream4.generated.resources.ok import com.lagradost.cloudstream4.viewmodels.DerivedRepositoryState import com.lagradost.cloudstream4.viewmodels.FilterPlugin import com.lagradost.cloudstream4.viewmodels.Plugin @@ -127,6 +140,7 @@ import com.lagradost.cloudstream4.viewmodels.PluginStatus import com.mihon.presentation.settings.widget.TextPreferenceWidget import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentSet import kotlin.math.roundToInt import kotlin.stackTraceToString import kotlin.text.replace @@ -232,7 +246,63 @@ object ExtensionsScreen : Screen { stringResource(R.string.extensions) }, navigateUp = LocalBackPress.current, - actions = { }, + actions = { + var languageDialog by remember { mutableStateOf(false) } + val derivedRepositoryState = state.derivedRepositoryState + + /** We only show the langauge filter button if we are searching plugins and there is languages to pick from*/ + if (state.searchPlugins && derivedRepositoryState.languageCount.isNotEmpty()) { + IconButton(onClick = { + languageDialog = true + }) { + Icon( + tint = MaterialTheme.colorScheme.onBackground, + painter = painterResource(R.drawable.language_korean_latin_24px), + contentDescription = stringResource(R.string.extension_language) + ) + } + } + if (languageDialog) { + val entries = + derivedRepositoryState.languageCount.keys.sortedBy { lang -> + fromTagToLanguageName(lang) ?: lang + } + + MultiSelectDialog( + title = stringResource(R.string.extension_language), + entries = entries.associateWith { lang -> + if (lang == "") { + stringResource(R.string.no_data) + } else { + (getNameNextToFlagEmoji( + lang + ) ?: lang) + } + }, + properties = DialogProperties( + usePlatformDefaultWidth = true, + ), + confirmText = stringResource(R.string.ok), + confirm = { selection -> + val removed = + derivedRepositoryState.languageCount.keys.subtract(selection) + onAction( + ExtensionsAction.FilterByInverseLanguages( + removed.toPersistentSet() + ) + ) + languageDialog = false + }, + dismissText = stringResource(R.string.cancel), + dismiss = { + languageDialog = false + }, + selectedKeys = derivedRepositoryState.languageCount.keys.subtract( + state.pluginFilter.removeLanguages + ), + ) + } + }, scrollBehavior = it, ) }, @@ -256,12 +326,6 @@ object ExtensionsScreen : Screen { ) } - val otherTypes: Pair> = R.string.others to persistentListOf( - TvType.CustomMedia, - TvType.Video, - TvType.Others - ) - /** To make the UI even remotely usable, we batch the types */ val contentTypes: PersistentList>> = persistentListOf( R.string.movies_singular to persistentListOf(TvType.Movie), @@ -279,7 +343,11 @@ object ExtensionsScreen : Screen { TvType.Music ), R.string.nsfw_singular to persistentListOf(TvType.NSFW), - otherTypes, + R.string.others to persistentListOf( + TvType.CustomMedia, + TvType.Video, + TvType.Others + ), ) @SuppressLint("UnrememberedMutableState") @@ -321,9 +389,18 @@ object ExtensionsScreen : Screen { var highLight by highLightState LaunchedEffect(highLight) { if (highLight == null) return@LaunchedEffect - val index = state.repositories.sorted.indexOfFirst { uuid -> uuid == highLight } + val list = if (state.searchPlugins) { + state.plugins.sorted + } else { + state.repositories.sorted + } + val index = list.indexOfFirst { uuid -> uuid == highLight } if (index != -1) { - lazyRepoState.animateScrollToItem(index) + lazyRepoState.animateScrollToItem( + index + + /** We have some extra items here */ + if (state.searchPlugins) 2 else 1 + ) } delay(5.0.seconds) highLight = null @@ -374,28 +451,78 @@ object ExtensionsScreen : Screen { onAction = onAction ) } + items(items = state.plugins.sorted, key = { uuid -> uuid }) { uuid -> val plugin = state.plugins.data[uuid]!! - Plugin( - plugin = plugin, - onAction = onAction, - modifier = Modifier - .animateItem() - .graphicsLayer { alpha = itemAlpha.value } - ) - } - if (state.plugins.sorted.isEmpty()) { - item { - Text( - text = org.jetbrains.compose.resources.stringResource(Res.string.no_results_found), + val isHighlighted = uuid == highLight + CompositionLocalProvider( + LocalPreferenceHighlighted provides isHighlighted, + ) { + Plugin( + plugin = plugin, + onAction = onAction, modifier = Modifier - .secondaryItemAlpha() - .fillMaxWidth() - .padding(20.dp), - textAlign = TextAlign.Center + .animateItem() + .graphicsLayer { alpha = itemAlpha.value }, + onClick = { + if (state.pluginFilter.repository == null) { + onAction(ExtensionsAction.SelectPlugin(plugin.uuid)) + highLightState.value = plugin.uuid + } + } ) } } + + val repository = state.pluginFilter.repository + val status = repository?.let(state.repositories.data::get)?.status + if (state.plugins.sorted.isEmpty()) { + when (status) { + is RepositoryStatus.Failure -> { + item { + ErrorButton(status.cause) + } + } + + RepositoryStatus.Loading -> { + item { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onBackground, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + + RepositoryStatus.Success, null -> { + val derivedState = + repository?.let(state.plugins.state.repositories::get) + + item { + Text( + text = + if (repository != null && (derivedState == null || derivedState.total == 0)) { + /** + * If we have a repository filter on, + * but it does not contain anything then show this + * instead as we do not really filter it but + * the repo is simply empty. This should mostly be used + * for plugin authors + * */ + stringResource(R.string.no_plugins_found_error) + } else { + org.jetbrains.compose.resources.stringResource(Res.string.no_results_found) + }, + modifier = Modifier + .secondaryItemAlpha() + .fillMaxWidth() + .padding(20.dp), + textAlign = TextAlign.Center + ) + } + } + } + } } else { items(items = state.repositories.sorted, key = { uuid -> uuid }) { uuid -> val repository = state.repositories.data[uuid]!! @@ -438,25 +565,10 @@ object ExtensionsScreen : Screen { horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), contentPadding = PaddingValues(horizontal = MaterialTheme.padding.small) ) { - var any = false for ((text, types) in contentTypes) { if (types.none(derivedState::containsType)) { continue } - any = true - item(key = text) { - FilterSelection( - filter = filter, - text = text, - types = types, - onAction = onAction - ) - } - } - - /** If none TV types exists we always show the "other" filter */ - if (!any) { - val (text, types) = otherTypes item(key = text) { FilterSelection( filter = filter, @@ -547,32 +659,71 @@ object ExtensionsScreen : Screen { plugin: Plugin, onAction: (ExtensionsAction) -> Unit, modifier: Modifier, + onClick: () -> Unit, ) { val description = plugin.info.description BasePreferenceWidget( modifier = modifier, title = plugin.info.name, - subcomponent = if (!description.isNullOrBlank()) { + subcomponent = { - Text( - text = description, - modifier = Modifier - .padding(horizontal = PrefsHorizontalPadding) - .secondaryItemAlpha(), - style = MaterialTheme.typography.bodyMedium, - maxLines = 3, - ) - } - } else null, + Column(modifier = Modifier.padding(horizontal = PrefsHorizontalPadding)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small) + ) { + val language = plugin.info.language + if (!language.isNullOrBlank()) { + Text( + text = getNameNextToFlagEmoji(language) ?: language, + modifier = Modifier + .secondaryItemAlpha(), + style = MaterialTheme.typography.bodySmall, + ) + } + Text( + text = "v${plugin.info.version}", + modifier = Modifier + .secondaryItemAlpha(), + style = MaterialTheme.typography.bodySmall, + ) + val sizeBytes = plugin.source?.size + if (sizeBytes != null) { + Text( + text = formatShortFileSize(LocalContext.current, sizeBytes), + modifier = Modifier + .secondaryItemAlpha(), + style = MaterialTheme.typography.bodySmall, + ) + } + if (plugin.info.tvTypes.contains(TvType.NSFW)) { + Text( + text = "18+", + style = MaterialTheme.typography.bodySmall, + color = colorResource(R.color.adultColor) + ) + } + } + if (!description.isNullOrBlank()) { + Text( + text = description, + modifier = Modifier + .secondaryItemAlpha(), + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + } + }, icon = { RepositoryIcon( iconUrl = plugin.info.iconUrl, defaultIcon = painterResource(R.drawable.extension_24px) ) }, - onClick = { - }, + onClick = onClick, onLongClick = { }, widget = { @@ -633,13 +784,40 @@ object ExtensionsScreen : Screen { PluginStatus.Idle -> { if (plugin.downloadLocation != null) { - IconButton(onClick = { - onAction(ExtensionsAction.DeletePlugin(plugin.uuid)) - }) { - Icon( - painter = painterResource(R.drawable.delete_24px), - contentDescription = null - ) + val cs3Plugin = + (plugin.source?.pluginUrl?.let(PluginManager.urlPlugins::get) + ?: (PluginManager.plugins[plugin.downloadLocation])) as? com.lagradost.cloudstream3.plugins.Plugin + + Row { + val settings = cs3Plugin?.openSettings + if (settings != null) { + val context = LocalContext.current + IconButton(onClick = { + try { + settings.invoke(context) + } catch (e: Throwable) { + Log.e( + "PluginAdapter", + "Failed to open ${plugin.info.name} settings: ${ + Log.getStackTraceString(e) + }" + ) + } + }) { + Icon( + painter = painterResource(R.drawable.ic_baseline_tune_24), + contentDescription = null + ) + } + } + IconButton(onClick = { + onAction(ExtensionsAction.DeletePlugin(plugin.uuid)) + }) { + Icon( + painter = painterResource(R.drawable.delete_24px), + contentDescription = null + ) + } } } else { IconButton(onClick = { @@ -1019,8 +1197,11 @@ object ExtensionsScreen : Screen { if (errorDialog) { ErrorDialog( error = reason.cause, - tag = stringResource(R.string.download_failed), + tag = stringResource(R.string.error_invalid_data), confirmButton = { + WhiteButton(text = stringResource(R.string.reload_error)) { + onAction(ExtensionsAction.ReloadRepository(repository.uuid)) + } WhiteButton(text = stringResource(R.string.delete_repository)) { deleteDialog = true } diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt index 9da620f5d83..41e1bfad17e 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt @@ -69,6 +69,30 @@ data class FilterByQuery( } } +/** Sorting by closest match first */ +@Immutable +data class SortByMatching( + val query: String, + val ignoreCase: Boolean = true, + val transform: (Value) -> String, +) : Comparator { + override fun compare(p0: Value?, p1: Value?): Int { + if (p0 == p1) return 0 + if (p0 == null) return -1 + if (p1 == null) return 1 + return score(p1).compareTo(score(p0)) + } + + fun score(p0: Value) : Int { + val transformed = transform(p0) + val matchAgainst = if (ignoreCase) transformed.lowercase() else transformed + return Levenshtein.ratio( + query, + matchAgainst + ) + } +} + /** Default sorting by name (Ascending A->Z), use `rev()` if Z->A is desired */ @Immutable data class SortByName( @@ -163,6 +187,13 @@ data class SearchableDataState>( fun filteringBy(newFilter: ((Value) -> Boolean)?): SearchableDataState = copy(internalData = internalData.filteringBy(newFilter)) + @CheckResult + fun searchBy( + newSorting: Comparator, + newFilter: ((Value) -> Boolean)? + ): SearchableDataState = + copy(internalData = internalData.searchBy(newSorting = newSorting, newFilter = newFilter)) + @CheckResult fun updating( key: Key, @@ -300,6 +331,28 @@ data class SearchableData( return copy(filteredBy = newFilter, filtered = newFiltered, sorted = newSorted) } + @CheckResult + fun searchBy( + newSorting: Comparator, + newFilter: ((Value) -> Boolean)? + ): SearchableData { + if (newFilter == filteredBy && newSorting == sortedBy) { + return this + } + val newFiltered = if (newFilter == filteredBy) { + filtered + } else { + filter(data = data, filteredBy = newFilter) + } + val newSorted = sort(data = data, filtered = newFiltered, sortedBy = newSorting) + return copy( + filteredBy = newFilter, + sortedBy = newSorting, + filtered = newFiltered, + sorted = newSorted + ) + } + @CheckResult fun updating( key: Key, @@ -347,8 +400,7 @@ data class SearchableData( } else if (newFiltered.size < filtered.size) { /** Shown -> Hidden, If the update filtered out the item then just remove it from the list */ return copy(data = newData, filtered = newFiltered, sorted = sorted.removing(key)) - } else - { + } else { /** Hidden -> Shown, we only need to insert it to the sorted list */ return copy( data = newData, filtered = newFiltered, sorted = addItemAt( diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt index 28377b70661..5d1237fd3ae 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt @@ -14,6 +14,7 @@ import com.lagradost.cloudstream4.state.MultiActiveQuery import com.lagradost.cloudstream4.state.SearchableData import com.lagradost.cloudstream4.state.SearchableDataState import com.lagradost.cloudstream4.state.SingleActiveQuery +import com.lagradost.cloudstream4.state.SortByMatching import com.lagradost.cloudstream4.state.SortByName import com.lagradost.cloudstream4.state.StateContainer import com.lagradost.cloudstream4.state.UniqueItem @@ -22,6 +23,7 @@ import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.PersistentSet import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toPersistentMap import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.Dispatchers @@ -95,6 +97,8 @@ data class FilterPlugin( val tvTypes: PersistentSet = TvType.entries.toSet().toPersistentSet(), val repository: Uuid? = null, val query: FilterByQuery = FilterByQuery(query = "") { it.info.name }, + /** We filter by the inverse, as that makes is easy to initalize it as all = {} */ + val removeLanguages: PersistentSet = persistentSetOf(), ) : Function1 { override fun invoke(plugin: Plugin): Boolean = /** Filter by repository */ @@ -110,7 +114,9 @@ data class FilterPlugin( tvTypes.contains(TvType.Others) } else { plugin.info.tvTypes.any(tvTypes::contains) - }) + }) && + /** Filter by language */ + (plugin.info.language?.let(removeLanguages::contains)?.not() ?: true) } @Immutable @@ -127,8 +133,17 @@ data class DerivedRepositoryState( val downloadedAndEnabled: Int = 0, val downloadedAndDisabled: Int = 0, val notDownloaded: Int = 0, + /** + * Map of strictly positive values corresponding with the count of each language + * where null/blank -> "" + * + * This is for the language filter, to only showcase relevant languages + * */ + val languageCount: PersistentMap = persistentMapOf() ) : DerivedState { - fun containsType(type : TvType) : Boolean = countOfTypes[type.ordinal] > 0 + val total get() = downloadedAndDisabled + downloadedAndEnabled + notDownloaded + + fun containsType(type: TvType): Boolean = countOfTypes[type.ordinal] > 0 override fun equals(other: Any?): Boolean { if (this === other) return true @@ -160,7 +175,14 @@ data class DerivedRepositoryState( for (type in value.info.tvTypes) { newCountOfTypes[type.ordinal] = newCountOfTypes[type.ordinal] + 1 } + if (value.info.tvTypes.isEmpty()) { + newCountOfTypes[TvType.Others.ordinal] += 1 + } + val language = if (value.info.language.isNullOrBlank()) "" else value.info.language + val count = languageCount[language]?.plus(1) ?: 1 + val newLanguageCount = languageCount.putting(language, count) return copy( + languageCount = newLanguageCount, downloadedAndEnabled = downloadedAndEnabled + if (isDownloaded && isEnabled) 1 else 0, downloadedAndDisabled = downloadedAndDisabled + if (isDownloaded && isDisabled) 1 else 0, notDownloaded = notDownloaded + if (isNotDownloaded) 1 else 0, @@ -178,7 +200,15 @@ data class DerivedRepositoryState( for (type in value.info.tvTypes) { newCountOfTypes[type.ordinal] = newCountOfTypes[type.ordinal] + 1 } + val language = if (value.info.language.isNullOrBlank()) "" else value.info.language + val count = languageCount[language]?.minus(1) ?: 0 + val newLanguageCount = if (count <= 0) { + languageCount.removing(language) + } else { + languageCount.putting(language, count) + } return copy( + languageCount = newLanguageCount, downloadedAndEnabled = downloadedAndEnabled - if (isDownloaded && isEnabled) 1 else 0, downloadedAndDisabled = downloadedAndDisabled - if (isDownloaded && isDisabled) 1 else 0, notDownloaded = notDownloaded - if (isNotDownloaded) 1 else 0, @@ -239,7 +269,11 @@ data class ExtensionsState( //val repoFilter: FilterByQuery = FilterByQuery(query = "") { it.info.name }, val addDialog: AddRepositoryDialog? = null, val searchPlugins: Boolean = false -) +) { + val derivedRepositoryState: DerivedRepositoryState = + pluginFilter.repository?.let(plugins.state.repositories::get) + ?: plugins.state.sumOfRepositories +} @Immutable data class AddRepositoryDialog( @@ -288,8 +322,13 @@ sealed class ExtensionsAction { //data class DownloadRepository(val repository: RepositoryInfo) : ExtensionsAction() data class DownloadRepositoryWithDialog(val url: String) : ExtensionsAction() data class SelectRepository(val uuid: Uuid?) : ExtensionsAction() + data class SelectPlugin(val uuid: Uuid?) : ExtensionsAction() + data class ReloadRepository(val uuid: Uuid) : ExtensionsAction() data class Search(val query: String) : ExtensionsAction() data class FilterByTypes(val types: PersistentSet) : ExtensionsAction() + data class FilterByInverseLanguages(val removeLanguages: PersistentSet) : + ExtensionsAction() + data class SetRepositoryDialog(val dialog: AddRepositoryDialog?) : ExtensionsAction() object ClearPluginSearch : ExtensionsAction() } @@ -323,6 +362,17 @@ class ExtensionsViewModel2( } } + is ExtensionsAction.ReloadRepository -> { + val uuid = action.uuid + val current = state.value.repositories.data[uuid] ?: return + viewModelScope.launch { + updateState { + copy(repositories = repositories.updating(uuid) { copy(status = RepositoryStatus.Loading) }) + } + loadRepository(uuid = uuid, repository = current.info) + } + } + is ExtensionsAction.SetRepositoryDialog -> { updateState { copy(addDialog = action.dialog) @@ -347,16 +397,47 @@ class ExtensionsViewModel2( } } + is ExtensionsAction.SelectPlugin -> { + updateState { + val plugin = plugins.data[action.uuid] ?: return@updateState this + val newPluginFilter = pluginFilter.copy(repository = plugin.repository) + copy( + plugins = plugins.filteringBy(newPluginFilter), + pluginFilter = newPluginFilter, + searchPlugins = true, + ) + } + } + + is ExtensionsAction.FilterByInverseLanguages -> { + updateState { + val newPluginFilter = + pluginFilter.copy(removeLanguages = action.removeLanguages) + copy( + plugins = plugins.filteringBy(newPluginFilter), + pluginFilter = newPluginFilter + ) + } + } + is ExtensionsAction.Search -> { updateState { val newPluginFilter = pluginFilter.copy(query = pluginFilter.query.update(action.query)) + val activeQuery = action.query.isNotEmpty() //val newRepoFilter = repoFilter.update(action.query) copy( - plugins = plugins.filteringBy(newPluginFilter), + plugins = plugins.searchBy( + newSorting = if (activeQuery) { + SortByMatching(query = action.query.lowercase()) { it.info.name } + } else { + SortByName { it.info.name } + }, + newFilter = newPluginFilter + ), //repositories = repositories.filteringBy(newRepoFilter), pluginFilter = newPluginFilter, - searchPlugins = pluginFilter.repository != null || action.query.isNotEmpty(), + searchPlugins = pluginFilter.repository != null || activeQuery, //repoFilter = newRepoFilter, ) } From 2c4e57f5a183e382caaf624a06289b86b945375c Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:44:33 +0200 Subject: [PATCH 05/19] Duplicate fixes --- .../settings/extensions/ExtensionsScreen.kt | 2 +- .../viewmodels/ExtensionsViewModel2.kt | 46 +++++++++++++++---- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt index ead4a782f79..f50b6af0c49 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt @@ -200,7 +200,7 @@ object ExtensionsScreen : Screen { override fun Content() { val viewModel = viewModel { ExtensionsViewModel2(manager = AndroidRepositoryManager) } - LaunchedEffect(Unit) { + LaunchedEffect(Unit) { // TODO fix this to only do if necessary viewModel.onAction(ExtensionsAction.Init) } val state by viewModel.state.collectAsState() diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt index 5d1237fd3ae..4ee49664ab9 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt @@ -322,7 +322,7 @@ sealed class ExtensionsAction { //data class DownloadRepository(val repository: RepositoryInfo) : ExtensionsAction() data class DownloadRepositoryWithDialog(val url: String) : ExtensionsAction() data class SelectRepository(val uuid: Uuid?) : ExtensionsAction() - data class SelectPlugin(val uuid: Uuid?) : ExtensionsAction() + data class SelectPlugin(val uuid: Uuid) : ExtensionsAction() data class ReloadRepository(val uuid: Uuid) : ExtensionsAction() data class Search(val query: String) : ExtensionsAction() data class FilterByTypes(val types: PersistentSet) : ExtensionsAction() @@ -474,6 +474,7 @@ class ExtensionsViewModel2( updateState { copy( addDialog = AddRepositoryDialog( + /** Not a bug, the dialog should not get trimmed */ url = action.url, loading = true, error = null @@ -500,6 +501,7 @@ class ExtensionsViewModel2( updateState { copy( addDialog = AddRepositoryDialog( + /** Not a bug, the dialog should not get trimmed */ url = action.url, loading = false, error = t @@ -582,8 +584,30 @@ class ExtensionsViewModel2( pluginsFetched: List ) { manager.updateRepositoryInfo(newRepositoryInfo) - val newPlugins = pluginsFetched.associateBy { it.uuid } updateState { + /** + * The unique id of a plugin is the repository + source, + * if source does not exist because it is an internal plugin, + * then use the download location + * */ + fun uniqueId(plugin: Plugin) = + plugin.repository to (plugin.source?.pluginUrl ?: plugin.downloadLocation) + + val oldPlugins = plugins.data.values.associate { plugin -> + uniqueId(plugin) to plugin.uuid + } + + /** If we refresh plugins that already exist by assigning them the old uuid */ + val newPlugins = pluginsFetched.associate { plugin -> + val newUuid = oldPlugins[uniqueId(plugin)] + ?: plugin.uuid + newUuid to if (newUuid != plugin.uuid) { + plugin.copy(uuid = newUuid) + } else { + plugin + } + } + copy( plugins = plugins.setTo(plugins.data.puttingAll(newPlugins)), repositories = repositories.adding( @@ -712,13 +736,18 @@ class ExtensionsViewModel2( private suspend fun init() { val repos = manager.getRepositories() - val reposMap = repos.associate { info -> - val uuid = Uuid.random() - uuid to Repository(info = info, status = RepositoryStatus.Loading, uuid = uuid) - }.toPersistentMap() updateState { - copy(repositories = repositories.setTo(newData = reposMap)) + val oldRepositories = repositories.data.values.associate { repository -> + repository.info.url to repository.uuid + } + + val newRepositories = repos.associate { info -> + val uuid = oldRepositories[info.url] ?: Uuid.random() + uuid to Repository(info = info, status = RepositoryStatus.Loading, uuid = uuid) + }.toPersistentMap() + + copy(repositories = repositories.setTo(newData = newRepositories)) } repositoryDispatcher.launch(localUuid) { @@ -729,7 +758,8 @@ class ExtensionsViewModel2( } } - for ((uuid, repository) in reposMap) { + val repositories = state.value.repositories.data + for ((uuid, repository) in repositories) { loadRepository(uuid, repository.info) } } From f9c19778ac2ce4e6865a28d880b537cd3fd4f714 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:40:53 +0200 Subject: [PATCH 06/19] Added 2 dialogs --- .../extensions/AndroidRepositoryManager.kt | 7 +- .../settings/extensions/ExtensionsScreen.kt | 487 ++++++++++++++---- app/src/main/res/values/strings.xml | 1 + .../cloudstream4/state/ImmutableState.kt | 6 + .../viewmodels/ExtensionsViewModel2.kt | 359 ++++++++----- 5 files changed, 607 insertions(+), 253 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt index 0849e708fe2..135f88ecd44 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.ui.settings.extensions import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.InternalAPI import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app @@ -30,16 +31,15 @@ import com.lagradost.cloudstream4.viewmodels.RepositoryManager import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import java.io.File import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files import java.nio.file.StandardCopyOption import java.util.concurrent.TimeUnit -import kotlin.time.Duration.Companion.milliseconds import kotlin.uuid.Uuid +@InternalAPI object AndroidRepositoryManager : RepositoryManager { fun RepositoryInfo.toData() = RepositoryData( @@ -127,7 +127,8 @@ object AndroidRepositoryManager : RepositoryManager { authors = plugin.authors.toPersistentList(), iconUrl = plugin.iconUrl, language = plugin.language, - description = plugin.description + description = plugin.description, + repositoryUrl = plugin.repositoryUrl ), source = PluginDownloadSource( size = plugin.fileSize, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt index f50b6af0c49..8a256e276e8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt @@ -35,6 +35,8 @@ import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon @@ -42,7 +44,9 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -64,11 +68,13 @@ import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.Clipboard import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign @@ -78,25 +84,37 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.findNavController import coil3.compose.AsyncImage import com.fasterxml.jackson.core.JacksonException +import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.ui.settings.SettingsFragmentScreen +import com.lagradost.cloudstream3.ui.settings.SettingsProvidersScreen.toStringRes import com.lagradost.cloudstream3.ui.settings.extensions.RepoAdapter.Companion.SHAREABLE_REPO_SEPARATOR import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji +import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream4.compose.ActionDialog import com.lagradost.cloudstream4.compose.BlackButton import com.lagradost.cloudstream4.compose.BlackTextField +import com.lagradost.cloudstream4.compose.IsScrolling import com.lagradost.cloudstream4.compose.LogBoxBlack import com.lagradost.cloudstream4.compose.LogBoxWhite import com.lagradost.cloudstream4.compose.LogText +import com.lagradost.cloudstream4.compose.MultiSelectDialog import com.lagradost.cloudstream4.compose.PHONE +import com.lagradost.cloudstream4.compose.RoundedShape import com.lagradost.cloudstream4.compose.Screen import com.lagradost.cloudstream4.compose.WhiteButton +import com.lagradost.cloudstream4.compose.WhiteFilterChip import com.lagradost.cloudstream4.compose.isLayout +import com.lagradost.cloudstream4.compose.rounded +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.no_results_found import com.lagradost.cloudstream4.getPlainText import com.lagradost.cloudstream4.setPlainText import com.lagradost.cloudstream4.state.LogLevel @@ -105,10 +123,15 @@ import com.lagradost.cloudstream4.state.SearchableData import com.lagradost.cloudstream4.state.SortByName import com.lagradost.cloudstream4.theme.CloudStreamPreviewTheme import com.lagradost.cloudstream4.viewmodels.AddRepositoryDialog +import com.lagradost.cloudstream4.viewmodels.DerivedRepositoryState import com.lagradost.cloudstream4.viewmodels.ExtensionsAction import com.lagradost.cloudstream4.viewmodels.ExtensionsEffect import com.lagradost.cloudstream4.viewmodels.ExtensionsState import com.lagradost.cloudstream4.viewmodels.ExtensionsViewModel2 +import com.lagradost.cloudstream4.viewmodels.FilterPlugin +import com.lagradost.cloudstream4.viewmodels.Plugin +import com.lagradost.cloudstream4.viewmodels.PluginStatus +import com.lagradost.cloudstream4.viewmodels.PluginsUpdatingDialog import com.lagradost.cloudstream4.viewmodels.Repository import com.lagradost.cloudstream4.viewmodels.RepositoryInfo import com.lagradost.cloudstream4.viewmodels.RepositoryStatus @@ -119,31 +142,17 @@ import com.mihon.presentation.secondaryItemAlpha import com.mihon.presentation.settings.LocalPreferenceHighlighted import com.mihon.presentation.settings.widget.BasePreferenceWidget import com.mihon.presentation.settings.widget.PrefsHorizontalPadding +import com.mihon.presentation.settings.widget.TextPreferenceWidget +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.SerializationException +import kotlin.math.roundToInt import kotlin.time.Duration.Companion.seconds import kotlin.uuid.Uuid -import com.lagradost.cloudstream4.compose.IsScrolling -import com.lagradost.cloudstream4.compose.MultiSelectDialog -import com.lagradost.cloudstream4.compose.WhiteFilterChip -import com.lagradost.cloudstream4.compose.rounded -import com.lagradost.cloudstream4.generated.resources.Res -import com.lagradost.cloudstream4.generated.resources.cancel -import com.lagradost.cloudstream4.generated.resources.no_results_found -import com.lagradost.cloudstream4.generated.resources.ok -import com.lagradost.cloudstream4.viewmodels.DerivedRepositoryState -import com.lagradost.cloudstream4.viewmodels.FilterPlugin -import com.lagradost.cloudstream4.viewmodels.Plugin -import com.lagradost.cloudstream4.viewmodels.PluginStatus -import com.mihon.presentation.settings.widget.TextPreferenceWidget -import kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentSet -import kotlin.math.roundToInt -import kotlin.stackTraceToString -import kotlin.text.replace @Composable @PreviewLightDark @@ -154,7 +163,12 @@ fun Preview() { val uuid3 = Uuid.random() ExtensionsScreen.Content( state = ExtensionsState( - addDialog = AddRepositoryDialog(error = Throwable()), + //addDialog = AddRepositoryDialog(error = Throwable()), + updateAllPluginsDialog = PluginsUpdatingDialog( + total = 5, + downloaded = 4, + failed = 0 + ), repositories = SearchableData.from( data = persistentMapOf( uuid1 to Repository( @@ -224,8 +238,6 @@ object ExtensionsScreen : Screen { val (top, bottom) = remember { FocusRequester.createRefs() } val fabExpanded = remember { mutableStateOf(true) } - - com.mihon.material.Scaffold( // TODO @WindowInsets remove this when we have converted everything to compose contentWindowInsets = WindowInsets(0, 0, 0, 0), @@ -237,7 +249,7 @@ object ExtensionsScreen : Screen { down = bottom }, title = if (state.pluginFilter.repository != null) { - state.repositories.data[state.pluginFilter.repository]?.info?.name + state.repositories[state.pluginFilter.repository]?.info?.name /** Should never happen */ ?: stringResource(R.string.extensions) } else if (state.searchPlugins) { @@ -247,61 +259,7 @@ object ExtensionsScreen : Screen { }, navigateUp = LocalBackPress.current, actions = { - var languageDialog by remember { mutableStateOf(false) } - val derivedRepositoryState = state.derivedRepositoryState - - /** We only show the langauge filter button if we are searching plugins and there is languages to pick from*/ - if (state.searchPlugins && derivedRepositoryState.languageCount.isNotEmpty()) { - IconButton(onClick = { - languageDialog = true - }) { - Icon( - tint = MaterialTheme.colorScheme.onBackground, - painter = painterResource(R.drawable.language_korean_latin_24px), - contentDescription = stringResource(R.string.extension_language) - ) - } - } - if (languageDialog) { - val entries = - derivedRepositoryState.languageCount.keys.sortedBy { lang -> - fromTagToLanguageName(lang) ?: lang - } - - MultiSelectDialog( - title = stringResource(R.string.extension_language), - entries = entries.associateWith { lang -> - if (lang == "") { - stringResource(R.string.no_data) - } else { - (getNameNextToFlagEmoji( - lang - ) ?: lang) - } - }, - properties = DialogProperties( - usePlatformDefaultWidth = true, - ), - confirmText = stringResource(R.string.ok), - confirm = { selection -> - val removed = - derivedRepositoryState.languageCount.keys.subtract(selection) - onAction( - ExtensionsAction.FilterByInverseLanguages( - removed.toPersistentSet() - ) - ) - languageDialog = false - }, - dismissText = stringResource(R.string.cancel), - dismiss = { - languageDialog = false - }, - selectedKeys = derivedRepositoryState.languageCount.keys.subtract( - state.pluginFilter.removeLanguages - ), - ) - } + ActionRow(state = state, onAction = onAction) }, scrollBehavior = it, ) @@ -326,6 +284,118 @@ object ExtensionsScreen : Screen { ) } + @Composable + fun ActionRow(state: ExtensionsState, onAction: (ExtensionsAction) -> Unit) { + val scope = rememberCoroutineScope() + val clipboard = LocalClipboard.current + + var extraDialog by remember { mutableStateOf(false) } + if (!state.searchPlugins) { + IconButton(onClick = { + extraDialog = true + }) { + Icon( + tint = MaterialTheme.colorScheme.onBackground, + painter = painterResource(R.drawable.ic_baseline_more_vert_24), + contentDescription = stringResource(R.string.extensions) + ) + } + } + + DropdownMenu(expanded = extraDialog, onDismissRequest = { + extraDialog = false + }, shape = RoundedShape()) { + DropdownMenuItem(text = { + Text(text = stringResource(R.string.add_repository)) + }, onClick = { + scope.launch { + openRepositoryDialog(clipboard = clipboard, onAction = onAction) + extraDialog = false + } + }) + DropdownMenuItem(text = { + Text(text = stringResource(R.string.category_provider_test)) + }, onClick = { + extraDialog = false + activity?.navigate(R.id.navigation_test_providers) + }) + DropdownMenuItem(text = { + Text(text = stringResource(R.string.installed_plugins)) + }, onClick = { + extraDialog = false + val activity = activity ?: return@DropdownMenuItem + activity.findNavController(R.id.nav_host_fragment).navigate( + R.id.navigation_settings_extensions_to_navigation_settings_plugins, + PluginsFragment.newLocalInstance( + activity.getString(R.string.extensions), + ) + ) + }) + DropdownMenuItem(text = { + Text(text = stringResource(R.string.update_plugins_manually)) + }, onClick = { + extraDialog = false + onAction(ExtensionsAction.UpdateAllPlugins) + }) + } + + var languageDialog by remember { mutableStateOf(false) } + val derivedRepositoryState = state.derivedRepositoryState + + /** We only show the langauge filter button if we are searching plugins and there is languages to pick from*/ + if (state.searchPlugins && derivedRepositoryState.languageCount.isNotEmpty()) { + IconButton(onClick = { + languageDialog = true + }) { + Icon( + tint = MaterialTheme.colorScheme.onBackground, + painter = painterResource(R.drawable.plugin_lang), + contentDescription = stringResource(R.string.extension_language) + ) + } + } + if (languageDialog) { + val entries = + derivedRepositoryState.languageCount.keys.sortedBy { lang -> + fromTagToLanguageName(lang) ?: lang + } + + MultiSelectDialog( + title = stringResource(R.string.extension_language), + entries = entries.associateWith { lang -> + if (lang == "") { + stringResource(R.string.no_data) + } else { + (getNameNextToFlagEmoji( + lang + ) ?: lang) + } + }, + properties = DialogProperties( + usePlatformDefaultWidth = true, + ), + confirmText = stringResource(R.string.ok), + confirm = { selection -> + val removed = + derivedRepositoryState.languageCount.keys.subtract(selection) + onAction( + ExtensionsAction.FilterByInverseLanguages( + removed.toPersistentSet() + ) + ) + languageDialog = false + }, + dismissText = stringResource(R.string.cancel), + dismiss = { + languageDialog = false + }, + selectedKeys = derivedRepositoryState.languageCount.keys.subtract( + state.pluginFilter.removeLanguages + ), + ) + } + } + /** To make the UI even remotely usable, we batch the types */ val contentTypes: PersistentList>> = persistentListOf( R.string.movies_singular to persistentListOf(TvType.Movie), @@ -360,6 +430,7 @@ object ExtensionsScreen : Screen { fabExpanded: MutableState, ) { AddDialog(dialog = state.addDialog, onAction = onAction) + UpdateDialog(dialog = state.updateAllPluginsDialog, onAction = onAction) val lazyRepoState = rememberLazyListState() val textState = rememberTextFieldState() @@ -453,7 +524,7 @@ object ExtensionsScreen : Screen { } items(items = state.plugins.sorted, key = { uuid -> uuid }) { uuid -> - val plugin = state.plugins.data[uuid]!! + val plugin = state.plugins[uuid]!! val isHighlighted = uuid == highLight CompositionLocalProvider( LocalPreferenceHighlighted provides isHighlighted, @@ -464,8 +535,10 @@ object ExtensionsScreen : Screen { modifier = Modifier .animateItem() .graphicsLayer { alpha = itemAlpha.value }, - onClick = { - if (state.pluginFilter.repository == null) { + onClick = if (state.pluginFilter.repository != null) { + null + } else { + { onAction(ExtensionsAction.SelectPlugin(plugin.uuid)) highLightState.value = plugin.uuid } @@ -475,7 +548,7 @@ object ExtensionsScreen : Screen { } val repository = state.pluginFilter.repository - val status = repository?.let(state.repositories.data::get)?.status + val status = state.repositories[repository]?.status if (state.plugins.sorted.isEmpty()) { when (status) { is RepositoryStatus.Failure -> { @@ -525,7 +598,7 @@ object ExtensionsScreen : Screen { } } else { items(items = state.repositories.sorted, key = { uuid -> uuid }) { uuid -> - val repository = state.repositories.data[uuid]!! + val repository = state.repositories[uuid]!! val isHighlighted = uuid == highLight CompositionLocalProvider( LocalPreferenceHighlighted provides isHighlighted, @@ -659,10 +732,121 @@ object ExtensionsScreen : Screen { plugin: Plugin, onAction: (ExtensionsAction) -> Unit, modifier: Modifier, - onClick: () -> Unit, + onClick: (() -> Unit)?, ) { val description = plugin.info.description + var extendedDialog by remember { mutableStateOf(false) } + + if (extendedDialog) { + AlertDialog( + containerColor = MaterialTheme.colorScheme.background, + onDismissRequest = { + extendedDialog = false + }, + confirmButton = { + val repository = plugin.info.repositoryUrl + if (repository != null) { + /** TODO filter this based on trusted urls? */ + WhiteButton(text = stringResource(R.string.result_open_in_browser)) { + openBrowser(repository) + } + } + }, + dismissButton = { + BlackButton( + text = stringResource(R.string.dismiss), + onClick = { extendedDialog = false }) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small)) { + ProvideTextStyle( + LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.onBackground, + ) + ) { + val roundedBackground = + Modifier + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedShape() + ) + .padding(MaterialTheme.padding.extraSmall) + + val description = plugin.info.description + if (description.isNullOrBlank()) { + InfoRow( + modifier = roundedBackground, + text = R.string.extension_description, + content = stringResource(R.string.no_data) + ) + } else { + Text( + text = stringResource(R.string.extension_description), + modifier = roundedBackground + ) + Text(text = description) + } + + if (plugin.info.authors.isNotEmpty()) { + InfoRow( + modifier = roundedBackground, + text = R.string.extension_authors, + content = plugin.info.authors.joinToString() + ) + } + InfoRow( + modifier = roundedBackground, + text = R.string.extension_version, + content = plugin.info.version.toString() + ) + InfoRow( + modifier = roundedBackground, + text = R.string.extension_status, + content = stringArrayResource(R.array.extension_statuses).getOrNull( + plugin.info.status + ) ?: stringResource(R.string.no_data) + ) + val size = plugin.source?.size + if (size != null) { + InfoRow( + modifier = roundedBackground, + text = R.string.extension_size, + content = formatShortFileSize(LocalContext.current, size) + ) + } + if (plugin.info.tvTypes.isNotEmpty()) { + InfoRow( + modifier = roundedBackground, + text = R.string.extension_types, + content = + @Suppress("SimplifiableCallChain") + plugin.info.tvTypes.map { stringResource(it.toStringRes()) } + .joinToString()) + } + InfoRow( + modifier = roundedBackground, + text = R.string.extension_language, + content = plugin.info.language?.let { language -> + getNameNextToFlagEmoji(language) ?: language + } ?: stringResource(R.string.no_data)) + } + } + }, + title = { + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), + verticalAlignment = Alignment.CenterVertically + ) { + RepositoryIcon( + iconUrl = plugin.info.iconUrl, + defaultIcon = painterResource(R.drawable.extension_24px) + ) + Text(text = plugin.info.name) + } + }) + } + BasePreferenceWidget( modifier = modifier, title = plugin.info.name, @@ -723,8 +907,11 @@ object ExtensionsScreen : Screen { defaultIcon = painterResource(R.drawable.extension_24px) ) }, - onClick = onClick, + onClick = onClick ?: { + extendedDialog = true + }, onLongClick = { + extendedDialog = true }, widget = { when (val status = plugin.status) { @@ -835,6 +1022,77 @@ object ExtensionsScreen : Screen { ) } + @Composable + fun InfoRow( + modifier: Modifier, + text: Int, + content: String, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(text), + modifier = modifier + ) + Text(text = content) + } + } + + @Composable + fun UpdateDialog(dialog: PluginsUpdatingDialog?, onAction: (ExtensionsAction) -> Unit) { + if (dialog == null) { + return + } + val finished = dialog.progress >= dialog.total + + AlertDialog( + containerColor = MaterialTheme.colorScheme.background, + onDismissRequest = { + onAction(ExtensionsAction.CancelPluginsUpdate) + }, + confirmButton = { + }, + dismissButton = { + BlackButton( + text = if (finished) { + stringResource(R.string.dismiss) + } else { + stringResource(R.string.cancel) + }, onClick = { + onAction(ExtensionsAction.CancelPluginsUpdate) + }) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small)) { + if (dialog.failed > 0) { + Text( + text = + stringResource(R.string.plugins_not_downloaded, dialog.failed) + ) + } + if (!finished) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onBackground, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + progress = { + if (dialog.total == 0) { + 1.0f + } else { + dialog.progress.toFloat() / dialog.total.toFloat() + } + } + ) + } + } + }, + title = { + Text(text = stringResource(R.string.plugins_updated, dialog.downloaded)) + }) + } + @Composable fun AddDialog(dialog: AddRepositoryDialog?, onAction: (ExtensionsAction) -> Unit) { if (dialog == null) { @@ -988,33 +1246,40 @@ object ExtensionsScreen : Screen { }, onClick = { scope.launch { - val copiedText = clipboard.getPlainText()?.trim() ?: "" - - /** text is of format : */ - val url = if (copiedText.contains(SHAREABLE_REPO_SEPARATOR)) { - val (_, url) = copiedText.split( - SHAREABLE_REPO_SEPARATOR, - limit = 2 - ) - url.trim() - } - /** Can this realistically be an url? */ - else if (copiedText.length < 200 && !copiedText.contains("\n")) { - copiedText - } - /** Otherwise we just do a clear */ - else { - "" - } - onAction( - ExtensionsAction.SetRepositoryDialog( - AddRepositoryDialog(url = url) - ) - ) + openRepositoryDialog(clipboard, onAction) } }) } + suspend fun openRepositoryDialog( + clipboard: Clipboard, + onAction: (ExtensionsAction) -> Unit + ) { + val copiedText = clipboard.getPlainText()?.trim() ?: "" + + /** text is of format : */ + val url = if (copiedText.contains(SHAREABLE_REPO_SEPARATOR)) { + val (_, url) = copiedText.split( + SHAREABLE_REPO_SEPARATOR, + limit = 2 + ) + url.trim() + } + /** Can this realistically be an url? */ + else if (copiedText.length < 200 && !copiedText.contains("\n")) { + copiedText + } + /** Otherwise we just do a clear */ + else { + "" + } + onAction( + ExtensionsAction.SetRepositoryDialog( + AddRepositoryDialog(url = url) + ) + ) + } + @Composable fun BottomBar( state: ExtensionsState, @@ -1038,7 +1303,7 @@ object ExtensionsScreen : Screen { .weight( maxOf( state.plugins.state.downloadedAndEnabled.toFloat(), - 0.1f + 0.01f ) ) ) @@ -1049,7 +1314,7 @@ object ExtensionsScreen : Screen { .weight( maxOf( state.plugins.state.downloadedAndDisabled.toFloat(), - 0.1f + 0.01f ) ) ) @@ -1057,7 +1322,7 @@ object ExtensionsScreen : Screen { modifier = Modifier .height(size) .background(color = MaterialTheme.colorScheme.onSurfaceVariant) - .weight(maxOf(state.plugins.state.notDownloaded.toFloat(), 0.1f)) + .weight(maxOf(state.plugins.state.notDownloaded.toFloat(), 0.2f)) ) } Spacer(modifier = Modifier.height(MaterialTheme.padding.small)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f050ad99354..6c429519967 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -813,4 +813,5 @@ %d downloads queued Live + Installed plugins diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt index 41e1bfad17e..2e8855c43eb 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt @@ -179,6 +179,9 @@ data class SearchableDataState>( val filteredBy get() = internalData.filteredBy val sortedBy get() = internalData.sortedBy + @CheckResult + operator fun get(key : Key?) : Value? = internalData[key] + @CheckResult fun sortingBy(newSorting: Comparator): SearchableDataState = copy(internalData = internalData.sortingBy(newSorting)) @@ -310,6 +313,9 @@ data class SearchableData( * */ val sortedBy: Comparator, ) { + @CheckResult + operator fun get(key : Key?) : Value? = key?.let(data::get) + @CheckResult fun sortingBy(newSorting: Comparator): SearchableData { if (newSorting == sortedBy) { diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt index 4ee49664ab9..d7cc26c1208 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream4.viewmodels import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.InternalAPI import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream4.state.ActionHandler import com.lagradost.cloudstream4.state.DefaultEffectContainer @@ -29,6 +30,9 @@ import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.uuid.Uuid @Immutable @@ -74,6 +78,10 @@ data class PluginInfo( val iconUrl: String?, val language: String?, val description: String?, + /** + * This is different from source.repositoryUrl, + * as this may be a different url than the repo we got this from */ + val repositoryUrl: String?, ) @Immutable @@ -268,13 +276,23 @@ data class ExtensionsState( val pluginFilter: FilterPlugin = FilterPlugin(), //val repoFilter: FilterByQuery = FilterByQuery(query = "") { it.info.name }, val addDialog: AddRepositoryDialog? = null, - val searchPlugins: Boolean = false + val searchPlugins: Boolean = false, + val updateAllPluginsDialog: PluginsUpdatingDialog? = null, ) { val derivedRepositoryState: DerivedRepositoryState = pluginFilter.repository?.let(plugins.state.repositories::get) ?: plugins.state.sumOfRepositories } +@Immutable +data class PluginsUpdatingDialog( + val total: Int, + val downloaded: Int, + val failed: Int, +) { + val progress get() = downloaded + failed +} + @Immutable data class AddRepositoryDialog( val url: String = "", @@ -286,6 +304,7 @@ sealed class ExtensionsEffect { data class RepositoryAdded(val repository: RepositoryInfo, val uuid: Uuid) : ExtensionsEffect() } +@InternalAPI interface RepositoryManager { fun getRepositories(): List @@ -331,6 +350,8 @@ sealed class ExtensionsAction { data class SetRepositoryDialog(val dialog: AddRepositoryDialog?) : ExtensionsAction() object ClearPluginSearch : ExtensionsAction() + object UpdateAllPlugins : ExtensionsAction() + object CancelPluginsUpdate : ExtensionsAction() } class ExtensionsViewModel2( @@ -342,9 +363,17 @@ class ExtensionsViewModel2( private val repositoryDispatcher = MultiActiveQuery(dispatcher = Dispatchers.IO) private val downloadDispatcher = MultiActiveQuery(dispatcher = Dispatchers.IO) private val addRepositoryDispatcher = SingleActiveQuery(dispatcher = Dispatchers.IO) + private val updatePluginsDispatcher = SingleActiveQuery(dispatcher = Dispatchers.IO) + @OptIn(ExperimentalAtomicApi::class) override fun onAction(action: ExtensionsAction) { when (action) { + ExtensionsAction.UpdateAllPlugins -> { + viewModelScope.launch { + updateAllPlugins() + } + } + ExtensionsAction.ClearPluginSearch -> { updateState { val newPluginFilter = pluginFilter.copy(repository = null) @@ -362,9 +391,19 @@ class ExtensionsViewModel2( } } + ExtensionsAction.CancelPluginsUpdate -> { + viewModelScope.launch { + updatePluginsDispatcher.launch { + updateState { + copy(updateAllPluginsDialog = null) + } + } + } + } + is ExtensionsAction.ReloadRepository -> { val uuid = action.uuid - val current = state.value.repositories.data[uuid] ?: return + val current = state.value.repositories[uuid] ?: return viewModelScope.launch { updateState { copy(repositories = repositories.updating(uuid) { copy(status = RepositoryStatus.Loading) }) @@ -380,7 +419,7 @@ class ExtensionsViewModel2( } is ExtensionsAction.DeleteRepository -> { - val repository = state.value.repositories.data[action.uuid] ?: return + val repository = state.value.repositories[action.uuid] ?: return viewModelScope.launch { deleteRepository(repository) } @@ -399,7 +438,7 @@ class ExtensionsViewModel2( is ExtensionsAction.SelectPlugin -> { updateState { - val plugin = plugins.data[action.uuid] ?: return@updateState this + val plugin = plugins[action.uuid] ?: return@updateState this val newPluginFilter = pluginFilter.copy(repository = plugin.repository) copy( plugins = plugins.filteringBy(newPluginFilter), @@ -425,7 +464,6 @@ class ExtensionsViewModel2( val newPluginFilter = pluginFilter.copy(query = pluginFilter.query.update(action.query)) val activeQuery = action.query.isNotEmpty() - //val newRepoFilter = repoFilter.update(action.query) copy( plugins = plugins.searchBy( newSorting = if (activeQuery) { @@ -435,118 +473,34 @@ class ExtensionsViewModel2( }, newFilter = newPluginFilter ), - //repositories = repositories.filteringBy(newRepoFilter), pluginFilter = newPluginFilter, searchPlugins = pluginFilter.repository != null || activeQuery, - //repoFilter = newRepoFilter, ) } } is ExtensionsAction.DeletePlugin -> { - val plugin = state.value.plugins.data[action.uuid] ?: return + val plugin = state.value.plugins[action.uuid] ?: return viewModelScope.launch { deletePlugin(plugin) } } is ExtensionsAction.DownloadPlugin -> { - val plugin = state.value.plugins.data[action.uuid] ?: return + val plugin = state.value.plugins[action.uuid] ?: return viewModelScope.launch { - downloadPlugin(plugin) + downloadDispatcher.launch(plugin.uuid) { + downloadPlugin(plugin) + } } } is ExtensionsAction.DownloadRepositoryWithDialog -> { - val uuid = Uuid.random() - val url = action.url.trim() - viewModelScope.launch { - val found = - state.value.repositories.data.values.firstOrNull { it.info.url == url } - if (found != null) { - postEffect { RepositoryAdded(found.info, found.uuid) } - updateState { - copy(addDialog = null) - } - return@launch - } - updateState { - copy( - addDialog = AddRepositoryDialog( - /** Not a bug, the dialog should not get trimmed */ - url = action.url, - loading = true, - error = null - ) - ) - } - val repository = RepositoryInfo( - iconUrl = null, - name = "Pending", - url = url, - description = null - ) - addRepositoryDispatcher.launch { - runCatching { - manager.getPlugins( - repository, - uuid - ) - }.onFailure { t -> - /** Cancellation means that we restarted this, so the caller updates the status */ - if (!isActive) { - return@launch - } - updateState { - copy( - addDialog = AddRepositoryDialog( - /** Not a bug, the dialog should not get trimmed */ - url = action.url, - loading = false, - error = t - ) - ) - } - }.onSuccess { (newRepositoryInfo, pluginsFetched) -> - val found = - state.value.repositories.data.values.firstOrNull { it.info.url == newRepositoryInfo.url } - if (found != null) { - // Shortcodes can have a different url from the input - updateState { - copy(addDialog = null) - } - postEffect { - RepositoryAdded( - found.info, - found.uuid - ) - } - return@launch - } - onRepositoryAdded( - uuid = uuid, - newRepositoryInfo = newRepositoryInfo, - pluginsFetched = pluginsFetched - ) - updateState { - copy(addDialog = null) - } - postEffect { RepositoryAdded(newRepositoryInfo, uuid) } - } - } + downloadRepositoryWithDialog(action) } } - /*is ExtensionsAction.DownloadRepository -> { - viewModelScope.launch { - if (state.value.repositories.data.any { it.value.info.url == action.repository.url }) { - return@launch - } - val uuid = Uuid.random() - downloadRepository(uuid = uuid, repository = action.repository) - } - }*/ is ExtensionsAction.FilterByTypes -> { updateState { val newPluginFilter = pluginFilter.copy(tvTypes = action.types) @@ -559,25 +513,6 @@ class ExtensionsViewModel2( } } - /*private suspend fun downloadRepository( - uuid: Uuid, - repository: RepositoryInfo - ) { - updateState { - copy( - repositories = repositories.adding( - uuid, - Repository( - info = repository, - status = RepositoryStatus.Loading, - uuid = uuid - ) - ) - ) - } - loadRepository(uuid, repository) - }*/ - private suspend fun onRepositoryAdded( uuid: Uuid, newRepositoryInfo: RepositoryInfo, @@ -672,39 +607,179 @@ class ExtensionsViewModel2( } } - private suspend fun downloadPlugin(plugin: Plugin) { - val source = plugin.source ?: return - downloadDispatcher.launch(plugin.uuid) { + private suspend fun downloadRepositoryWithDialog(action: ExtensionsAction.DownloadRepositoryWithDialog) { + val trimmedUrl = action.url.trim() + val uuid = Uuid.random() + + val found = + state.value.repositories.data.values.firstOrNull { it.info.url == trimmedUrl } + if (found != null) { + postEffect { RepositoryAdded(found.info, found.uuid) } + updateState { + copy(addDialog = null) + } + return + } + updateState { + copy( + addDialog = AddRepositoryDialog( + /** Not a bug, the dialog should not get trimmed */ + url = action.url, + loading = true, + error = null + ) + ) + } + val repository = RepositoryInfo( + iconUrl = null, + name = "Pending", + url = trimmedUrl, + description = null + ) + addRepositoryDispatcher.launch { runCatching { - manager.downloadPlugin( - source = source, - downloadProgress = { downloadedBytes, totalBytes -> - updateState { - copy(plugins = plugins.updating(plugin.uuid) { - copy( - status = PluginStatus.Downloading( - total = totalBytes, - progress = downloadedBytes - ) - ) - }) - } - } + manager.getPlugins( + repository, + uuid ) }.onFailure { t -> + /** Cancellation means that we restarted this, so the caller updates the status */ + if (!isActive) { + return@launch + } updateState { - copy(plugins = plugins.updating(plugin.uuid) { - copy(status = PluginStatus.Failed(t)) - }) + copy( + addDialog = AddRepositoryDialog( + /** Not a bug, the dialog should not get trimmed */ + url = action.url, + loading = false, + error = t + ) + ) } - }.onSuccess { downloadLocation -> + }.onSuccess { (newRepositoryInfo, pluginsFetched) -> + val found = + state.value.repositories.data.values.firstOrNull { it.info.url == newRepositoryInfo.url } + if (found != null) { + // Shortcodes can have a different url from the input + updateState { + copy(addDialog = null) + } + postEffect { + RepositoryAdded( + found.info, + found.uuid + ) + } + return@launch + } + onRepositoryAdded( + uuid = uuid, + newRepositoryInfo = newRepositoryInfo, + pluginsFetched = pluginsFetched + ) updateState { - copy(plugins = plugins.updating(plugin.uuid) { - copy(status = PluginStatus.Idle, downloadLocation = downloadLocation) - }) + copy(addDialog = null) + } + postEffect { RepositoryAdded(newRepositoryInfo, uuid) } + } + } + } + + private suspend fun updateAllPlugins() = updatePluginsDispatcher.launch { + val plugins = state.value.plugins.data.values.filter { it.downloadLocation != null } + val updateMutex = Mutex() + var failed = 0 + var downloaded = 0 + val total = plugins.size + + updateState { + copy( + updateAllPluginsDialog = PluginsUpdatingDialog( + total = total, + failed = 0, + downloaded = 0 + ) + ) + } + + for (plugin in plugins) { + downloadDispatcher.launch(plugin.uuid) { + val isSuccess = downloadPlugin(plugin) + if (!isActive) { + return@launch + } + + updateMutex.withLock { + if (isSuccess) { + downloaded += 1 + } else { + failed += 1 + } + updateState { + copy( + updateAllPluginsDialog = PluginsUpdatingDialog( + total = total, + failed = failed, + downloaded = downloaded + ) + ) + } + } + } + } + + if (!isActive) { + return@launch + } + + updateMutex.withLock { + updateState { + copy( + updateAllPluginsDialog = PluginsUpdatingDialog( + total = total, + failed = failed, + downloaded = downloaded + ) + ) + } + } + } + + private suspend fun downloadPlugin(plugin: Plugin): Boolean { + val source = plugin.source ?: return false + runCatching { + manager.downloadPlugin( + source = source, + downloadProgress = { downloadedBytes, totalBytes -> + updateState { + copy(plugins = plugins.updating(plugin.uuid) { + copy( + status = PluginStatus.Downloading( + total = totalBytes, + progress = downloadedBytes + ) + ) + }) + } } + ) + }.onFailure { t -> + updateState { + copy(plugins = plugins.updating(plugin.uuid) { + copy(status = PluginStatus.Failed(t)) + }) + } + return false + }.onSuccess { downloadLocation -> + updateState { + copy(plugins = plugins.updating(plugin.uuid) { + copy(status = PluginStatus.Idle, downloadLocation = downloadLocation) + }) } + return true } + return false } private suspend fun loadRepository(uuid: Uuid, repository: RepositoryInfo) { @@ -747,7 +822,13 @@ class ExtensionsViewModel2( uuid to Repository(info = info, status = RepositoryStatus.Loading, uuid = uuid) }.toPersistentMap() - copy(repositories = repositories.setTo(newData = newRepositories)) + copy( + repositories = repositories.setTo( + newData = repositories.data.puttingAll( + newRepositories + ) + ) + ) } repositoryDispatcher.launch(localUuid) { From 73de2d7fcaa01067c3b179dd9b9475ac753ed933 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:48:27 +0200 Subject: [PATCH 07/19] Added local plugins --- .../extensions/AndroidRepositoryManager.kt | 22 ++++++- .../settings/extensions/ExtensionsScreen.kt | 22 +++++-- .../viewmodels/ExtensionsViewModel2.kt | 64 ++++++++++++++++++- 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt index 135f88ecd44..2d627711a1e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt @@ -223,7 +223,27 @@ object AndroidRepositoryManager : RepositoryManager { } override fun getLocalPlugins(localUuid: Uuid): List { - return listOf() // TODO + val plugins = PluginManager.getPluginsLocal() + return plugins.map { plugin -> + Plugin( + info = PluginInfo( + name = plugin.internalName, + internalName = plugin.internalName, + version = plugin.version, + tvTypes = persistentListOf(), + authors = persistentListOf(), + status = 1, + iconUrl = null, + language = null, + description = null, + repositoryUrl = null + ), + source = null, + status = PluginStatus.Idle, + downloadLocation = plugin.filePath, + repository = localUuid, + ) + } } override suspend fun deletePlugin(location: String) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt index 8a256e276e8..5487a171d74 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt @@ -948,6 +948,7 @@ object ExtensionsScreen : Screen { ) } if (errorDialog) { + val isDownloaded = plugin.downloadLocation != null ErrorDialog( error = status.cause, tag = stringResource( @@ -955,12 +956,21 @@ object ExtensionsScreen : Screen { plugin.info.name ), confirmButton = { - WhiteButton( - text = stringResource(R.string.reload_error), - onClick = { - errorDialog = false - onAction(ExtensionsAction.DownloadPlugin(plugin.uuid)) - }) + if (isDownloaded) { + WhiteButton( + text = stringResource(R.string.delete_plugin), + onClick = { + errorDialog = false + onAction(ExtensionsAction.DeletePlugin(plugin.uuid)) + }) + } else { + WhiteButton( + text = stringResource(R.string.reload_error), + onClick = { + errorDialog = false + onAction(ExtensionsAction.DownloadPlugin(plugin.uuid)) + }) + } }, onDismissRequest = { errorDialog = false diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt index d7cc26c1208..672deb042ad 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt @@ -23,6 +23,7 @@ import com.lagradost.cloudstream4.viewmodels.ExtensionsEffect.RepositoryAdded import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.PersistentSet +import kotlinx.collections.immutable.mutate import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toPersistentMap @@ -354,12 +355,29 @@ sealed class ExtensionsAction { object CancelPluginsUpdate : ExtensionsAction() } +/** + * Here viewmodel acts as a view state holder that keeps everything up to date, + * and RepositoryManager is invoked to handle the platform specific features + * + * UI -(onAction)-> Viewmodel -(invokes)-> RepositoryManager + * <---(state)---/ <--(callback)--/ + * */ class ExtensionsViewModel2( private val manager: RepositoryManager, private val localUuid: Uuid = Uuid.random(), ) : ViewModel(), StateContainer by DefaultStateContainer(ExtensionsState()), EffectContainer by DefaultEffectContainer(), ActionHandler { + private val localRepository = Repository( + info = RepositoryInfo( + iconUrl = null, + name = "Local plugins", + url = "https://github.com/recloudstream/cloudstream", + description = "Locally installed plugins" + ), + status = RepositoryStatus.Success, + uuid = localUuid + ) private val repositoryDispatcher = MultiActiveQuery(dispatcher = Dispatchers.IO) private val downloadDispatcher = MultiActiveQuery(dispatcher = Dispatchers.IO) private val addRepositoryDispatcher = SingleActiveQuery(dispatcher = Dispatchers.IO) @@ -833,9 +851,51 @@ class ExtensionsViewModel2( repositoryDispatcher.launch(localUuid) { val pluginsFetched = manager.getLocalPlugins(localUuid) - val newPlugins = pluginsFetched.associateBy { it.uuid } updateState { - copy(plugins = plugins.setTo(plugins.data.puttingAll(newPlugins))) + val oldPlugins = plugins.data.values.associate { it.downloadLocation to it.uuid } + + /** Reuse the uuid if possible, to avoid duplicates and avoid clearing UI */ + val newPluginsFetched = pluginsFetched.associate { plugin -> + require(plugin.downloadLocation != null) { + "All local plugins must have a download path" + } + + val newUuid = oldPlugins[plugin.downloadLocation] ?: Uuid.random() + newUuid to if (newUuid != plugin.uuid) { + plugin.copy(uuid = newUuid) + } else { + plugin + } + } + + /** + * Remove ALL the old ones instead of putting as something might have been removed + * (this is *very* *very* unlikely, but more correct + * */ + val newPlugins = plugins.data.mutate { edit -> + for ((uuid, plugin) in plugins.data) { + if (plugin.repository == localUuid) { + edit.remove(uuid) + } + } + edit.putAll(newPluginsFetched) + } + + copy( + plugins = + if (newPlugins != plugins) { + plugins.setTo(newPlugins) + } else { + plugins + }, + repositories = + /** Only show local plugins if those are actually used */ + if (newPluginsFetched.isEmpty()) { + repositories.removing(localUuid) + } else { + repositories.adding(localUuid, localRepository) + } + ) } } From aeef58001c1992a6e2c1a131ebc8b5f1f0dd906a Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:59:14 +0200 Subject: [PATCH 08/19] Fixed some TV navigation issues --- .../settings/extensions/ExtensionsScreen.kt | 330 ++++++++++++------ .../lagradost/cloudstream4/compose/Chip.kt | 3 +- .../viewmodels/ExtensionsViewModel2.kt | 11 +- 3 files changed, 238 insertions(+), 106 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt index 5487a171d74..95ce6e13133 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -66,6 +67,8 @@ import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory. import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2 import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.Clipboard @@ -125,6 +128,7 @@ import com.lagradost.cloudstream4.theme.CloudStreamPreviewTheme import com.lagradost.cloudstream4.viewmodels.AddRepositoryDialog import com.lagradost.cloudstream4.viewmodels.DerivedRepositoryState import com.lagradost.cloudstream4.viewmodels.ExtensionsAction +import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.* import com.lagradost.cloudstream4.viewmodels.ExtensionsEffect import com.lagradost.cloudstream4.viewmodels.ExtensionsState import com.lagradost.cloudstream4.viewmodels.ExtensionsViewModel2 @@ -150,6 +154,7 @@ import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.SerializationException +import kotlin.collections.get import kotlin.math.roundToInt import kotlin.time.Duration.Companion.seconds import kotlin.uuid.Uuid @@ -164,11 +169,11 @@ fun Preview() { ExtensionsScreen.Content( state = ExtensionsState( //addDialog = AddRepositoryDialog(error = Throwable()), - updateAllPluginsDialog = PluginsUpdatingDialog( + /*updateAllPluginsDialog = PluginsUpdatingDialog( total = 5, downloaded = 4, failed = 0 - ), + ),*/ repositories = SearchableData.from( data = persistentMapOf( uuid1 to Repository( @@ -235,7 +240,7 @@ object ExtensionsScreen : Screen { onAction: (ExtensionsAction) -> Unit, highLightState: MutableState = remember { mutableStateOf(null) }, ) { - val (top, bottom) = remember { FocusRequester.createRefs() } + val (topFocusRequester, bottomFocusRequester) = remember { FocusRequester.createRefs() } val fabExpanded = remember { mutableStateOf(true) } com.mihon.material.Scaffold( @@ -244,9 +249,9 @@ object ExtensionsScreen : Screen { topBar = { AppBar( modifier = Modifier - .focusRequester(top) + .focusRequester(topFocusRequester) .focusProperties { - down = bottom + down = bottomFocusRequester }, title = if (state.pluginFilter.repository != null) { state.repositories[state.pluginFilter.repository]?.info?.name @@ -278,7 +283,9 @@ object ExtensionsScreen : Screen { state = state, onAction = onAction, highLightState = highLightState, - fabExpanded = fabExpanded + fabExpanded = fabExpanded, + focusRequester = bottomFocusRequester, + topBarFocusRequester = topFocusRequester ) }, ) @@ -428,6 +435,8 @@ object ExtensionsScreen : Screen { onAction: (ExtensionsAction) -> Unit, highLightState: MutableState, fabExpanded: MutableState, + topBarFocusRequester: FocusRequester, + focusRequester: FocusRequester, ) { AddDialog(dialog = state.addDialog, onAction = onAction) UpdateDialog(dialog = state.updateAllPluginsDialog, onAction = onAction) @@ -497,7 +506,13 @@ object ExtensionsScreen : Screen { LazyColumn( state = lazyState, contentPadding = contentPadding, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .focusGroup() + .focusRequester(focusRequester) + .focusProperties { + up = topBarFocusRequester + }, ) { item(key = "header") { Spacer(modifier = Modifier.height(MaterialTheme.padding.medium)) @@ -634,16 +649,30 @@ object ExtensionsScreen : Screen { derivedState: DerivedRepositoryState, onAction: (ExtensionsAction) -> Unit, ) { + val defaultFocusRequester = remember { FocusRequester() } + LazyRow( + modifier = Modifier + .fillMaxWidth() + .focusGroup() + .focusRestorer(defaultFocusRequester), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), contentPadding = PaddingValues(horizontal = MaterialTheme.padding.small) ) { + var first = true for ((text, types) in contentTypes) { if (types.none(derivedState::containsType)) { continue } + val modifier = if (first) { + first = false + Modifier.focusRequester(defaultFocusRequester) + } else { + Modifier + } item(key = text) { FilterSelection( + modifier = modifier, filter = filter, text = text, types = types, @@ -660,11 +689,13 @@ object ExtensionsScreen : Screen { text: Int, types: PersistentList, onAction: (ExtensionsAction) -> Unit, + modifier: Modifier = Modifier, ) { val selected = filter.tvTypes.contains(types[0]) WhiteFilterChip( + modifier = modifier, selected = selected, label = stringResource(text), ) { @@ -697,12 +728,20 @@ object ExtensionsScreen : Screen { ?.replace(":", " ") } .joinToString("\n") + + val (logFocusRequester, dismissFocusRequester) = remember { FocusRequester.createRefs() } + AlertDialog( containerColor = MaterialTheme.colorScheme.background, onDismissRequest = onDismissRequest, confirmButton = confirmButton, dismissButton = { - BlackButton(text = stringResource(R.string.dismiss), onClick = onDismissRequest) + BlackButton( + text = stringResource(R.string.dismiss), + onClick = onDismissRequest, + modifier = Modifier + .focusRequester(dismissFocusRequester) + ) }, text = { val clipboard = LocalClipboard.current @@ -719,7 +758,13 @@ object ExtensionsScreen : Screen { scope.launch { clipboard.setPlainText("Logcat", message) } - }) + }, + modifier = Modifier + .focusRequester(logFocusRequester) + .focusProperties { + down = dismissFocusRequester + } + ) } }, title = { @@ -847,8 +892,15 @@ object ExtensionsScreen : Screen { }) } + val (backgroundFocusRequester, buttonsFocusRequester) = + remember { FocusRequester.createRefs() } + BasePreferenceWidget( - modifier = modifier, + modifier = modifier + .focusRequester(backgroundFocusRequester) + .focusProperties { + end = buttonsFocusRequester + }, title = plugin.info.name, subcomponent = { @@ -914,6 +966,132 @@ object ExtensionsScreen : Screen { extendedDialog = true }, widget = { + PluginWidget( + plugin = plugin, + onAction = onAction, + returnFocusRequester = backgroundFocusRequester, + modifier = Modifier + .focusRequester(buttonsFocusRequester) + .focusProperties { + start = backgroundFocusRequester + } + .focusGroup() + ) + }, + ) + } + + @Composable + fun PluginWidget( + plugin: Plugin, + onAction: (ExtensionsAction) -> Unit, + modifier: Modifier, + returnFocusRequester: FocusRequester, + ) { + var errorDialog by remember { mutableStateOf(null) } + val errorCause = errorDialog + if (errorCause != null) { + val isDownloaded = plugin.downloadLocation != null + ErrorDialog( + error = errorCause, + tag = stringResource( + R.string.plugin_load_fail, + plugin.info.name + ), + confirmButton = { + if (isDownloaded) { + WhiteButton( + text = stringResource(R.string.delete_plugin), + onClick = { + errorDialog = null + onAction(DeletePlugin(plugin.uuid)) + }) + } else { + WhiteButton( + text = stringResource(R.string.reload_error), + onClick = { + errorDialog = null + onAction(DownloadPlugin(plugin.uuid)) + }) + } + }, + onDismissRequest = { + errorDialog = null + } + ) + } + + Row( + modifier = modifier + ) { + val cs3Plugin = plugin.downloadLocation?.let { location -> + (plugin.source?.pluginUrl?.let(PluginManager.urlPlugins::get) + ?: (PluginManager.plugins[location])) as? com.lagradost.cloudstream3.plugins.Plugin + } + val settings = cs3Plugin?.openSettings + + val (button1FocusRequester, button2FocusRequester) = remember { + FocusRequester.createRefs() + } + + if (settings != null) { + val context = LocalContext.current + IconButton( + modifier = Modifier + .focusRequester(button1FocusRequester) + .focusProperties { + start = returnFocusRequester + end = button2FocusRequester + }, + onClick = { + try { + settings.invoke(context) + } catch (e: Throwable) { + Log.e( + "PluginAdapter", + "Failed to open ${plugin.info.name} settings: ${ + Log.getStackTraceString(e) + }" + ) + } + }) { + Icon( + painter = painterResource(R.drawable.ic_baseline_tune_24), + contentDescription = null + ) + } + } + + IconButton( + modifier = Modifier + .focusRequester(button2FocusRequester) + .focusProperties { + start = if (settings == null) { + returnFocusRequester + } else { + button1FocusRequester + } + }, + onClick = { + when (val status = plugin.status) { + is PluginStatus.Downloading -> { + onAction(CancelPluginDownloader(plugin.uuid)) + } + + is PluginStatus.Failed -> { + errorDialog = status.cause + } + + PluginStatus.Idle -> { + if (plugin.downloadLocation != null) { + onAction(DeletePlugin(plugin.uuid)) + } else { + onAction(DownloadPlugin(plugin.uuid)) + } + } + } + }, + ) { when (val status = plugin.status) { is PluginStatus.Downloading -> { val total = status.total @@ -938,98 +1116,28 @@ object ExtensionsScreen : Screen { } is PluginStatus.Failed -> { - var errorDialog by remember { mutableStateOf(false) } - IconButton(onClick = { - errorDialog = true - }) { - Icon( - painter = painterResource(R.drawable.warning_24px), - contentDescription = null - ) - } - if (errorDialog) { - val isDownloaded = plugin.downloadLocation != null - ErrorDialog( - error = status.cause, - tag = stringResource( - R.string.plugin_load_fail, - plugin.info.name - ), - confirmButton = { - if (isDownloaded) { - WhiteButton( - text = stringResource(R.string.delete_plugin), - onClick = { - errorDialog = false - onAction(ExtensionsAction.DeletePlugin(plugin.uuid)) - }) - } else { - WhiteButton( - text = stringResource(R.string.reload_error), - onClick = { - errorDialog = false - onAction(ExtensionsAction.DownloadPlugin(plugin.uuid)) - }) - } - }, - onDismissRequest = { - errorDialog = false - } - ) - } + Icon( + painter = painterResource(R.drawable.warning_24px), + contentDescription = stringResource(R.string.error) + ) } PluginStatus.Idle -> { if (plugin.downloadLocation != null) { - val cs3Plugin = - (plugin.source?.pluginUrl?.let(PluginManager.urlPlugins::get) - ?: (PluginManager.plugins[plugin.downloadLocation])) as? com.lagradost.cloudstream3.plugins.Plugin - - Row { - val settings = cs3Plugin?.openSettings - if (settings != null) { - val context = LocalContext.current - IconButton(onClick = { - try { - settings.invoke(context) - } catch (e: Throwable) { - Log.e( - "PluginAdapter", - "Failed to open ${plugin.info.name} settings: ${ - Log.getStackTraceString(e) - }" - ) - } - }) { - Icon( - painter = painterResource(R.drawable.ic_baseline_tune_24), - contentDescription = null - ) - } - } - IconButton(onClick = { - onAction(ExtensionsAction.DeletePlugin(plugin.uuid)) - }) { - Icon( - painter = painterResource(R.drawable.delete_24px), - contentDescription = null - ) - } - } + Icon( + painter = painterResource(R.drawable.delete_24px), + contentDescription = stringResource(R.string.delete_plugin) + ) } else { - IconButton(onClick = { - onAction(ExtensionsAction.DownloadPlugin(plugin.uuid)) - }) { - Icon( - painter = painterResource(R.drawable.netflix_download), - contentDescription = null - ) - } + Icon( + painter = painterResource(R.drawable.netflix_download), + contentDescription = stringResource(R.string.download) + ) } } } - }, - ) + } + } } @Composable @@ -1418,8 +1526,14 @@ object ExtensionsScreen : Screen { val scope = rememberCoroutineScope() val copyTextLabel = stringResource(R.string.repo_copy_label) + val (backgroundFocusRequester, buttonFocusRequester) = remember { FocusRequester.createRefs() } + BasePreferenceWidget( - modifier = modifier, + modifier = modifier + .focusRequester(backgroundFocusRequester) + .focusProperties { + end = buttonFocusRequester + }, title = repository.info.name, subcomponent = { Text( @@ -1461,9 +1575,16 @@ object ExtensionsScreen : Screen { when (val reason = repository.status) { is RepositoryStatus.Failure -> { var errorDialog by remember { mutableStateOf(false) } - IconButton(onClick = { - errorDialog = true - }) { + IconButton( + onClick = { + errorDialog = true + }, + modifier = Modifier + .focusRequester(buttonFocusRequester) + .focusProperties { + start = backgroundFocusRequester + }) + { Icon( painter = painterResource(R.drawable.warning_24px), contentDescription = null @@ -1488,9 +1609,16 @@ object ExtensionsScreen : Screen { } RepositoryStatus.Loading, RepositoryStatus.Success -> { - IconButton(onClick = { - deleteDialog = true - }) { + IconButton( + onClick = { + deleteDialog = true + }, + modifier = Modifier + .focusRequester(buttonFocusRequester) + .focusProperties { + start = backgroundFocusRequester + } + ) { Icon( painter = painterResource(R.drawable.delete_24px), contentDescription = null diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Chip.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Chip.kt index 9dbc7561eef..a87e6acbe3d 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Chip.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Chip.kt @@ -22,6 +22,7 @@ import org.jetbrains.compose.resources.painterResource fun WhiteFilterChip( selected: Boolean, label: String, + modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit, ) { @@ -36,7 +37,7 @@ fun WhiteFilterChip( selectedBorderColor = Color.Transparent, disabledSelectedBorderColor = MaterialTheme.colorScheme.surfaceVariant, ), - modifier = Modifier.onFocusChanged { newFocus -> + modifier = modifier.onFocusChanged { newFocus -> hasFocus = canHaveFocus && newFocus.hasFocus }, selected = selected, diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt index 672deb042ad..0a37379ac98 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt @@ -353,6 +353,7 @@ sealed class ExtensionsAction { object ClearPluginSearch : ExtensionsAction() object UpdateAllPlugins : ExtensionsAction() object CancelPluginsUpdate : ExtensionsAction() + data class CancelPluginDownloader(val uuid: Uuid) : ExtensionsAction() } /** @@ -419,6 +420,12 @@ class ExtensionsViewModel2( } } + is ExtensionsAction.CancelPluginDownloader -> { + viewModelScope.launch { + downloadDispatcher.cancelOne(action.uuid) + } + } + is ExtensionsAction.ReloadRepository -> { val uuid = action.uuid val current = state.value.repositories[uuid] ?: return @@ -602,10 +609,6 @@ class ExtensionsViewModel2( } } - private suspend fun cancelPlugin(uuid: Uuid) { - downloadDispatcher.cancelOne(uuid) - } - private suspend fun deletePlugin(plugin: Plugin) { val downloadLocation = plugin.downloadLocation ?: return downloadDispatcher.launch(plugin.uuid) { From 06a58fe70efba6deeffd9dcf7f952a9685c7cd3b Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:27:33 +0200 Subject: [PATCH 09/19] Minor UI changes --- .../settings/extensions/ExtensionsScreen.kt | 199 ++++++++++++------ 1 file changed, 131 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt index 95ce6e13133..5ac13968697 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -19,11 +20,13 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -68,7 +71,7 @@ import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory. import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.Clipboard @@ -128,7 +131,22 @@ import com.lagradost.cloudstream4.theme.CloudStreamPreviewTheme import com.lagradost.cloudstream4.viewmodels.AddRepositoryDialog import com.lagradost.cloudstream4.viewmodels.DerivedRepositoryState import com.lagradost.cloudstream4.viewmodels.ExtensionsAction -import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.* +import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.CancelPluginDownloader +import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.CancelPluginsUpdate +import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.ClearPluginSearch +import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.DeletePlugin +import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.DeleteRepository +import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.DownloadPlugin +import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.DownloadRepositoryWithDialog +import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.FilterByInverseLanguages +import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.FilterByTypes +import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.Init +import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.ReloadRepository +import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.Search +import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.SelectPlugin +import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.SelectRepository +import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.SetRepositoryDialog +import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.UpdateAllPlugins import com.lagradost.cloudstream4.viewmodels.ExtensionsEffect import com.lagradost.cloudstream4.viewmodels.ExtensionsState import com.lagradost.cloudstream4.viewmodels.ExtensionsViewModel2 @@ -154,7 +172,6 @@ import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.SerializationException -import kotlin.collections.get import kotlin.math.roundToInt import kotlin.time.Duration.Companion.seconds import kotlin.uuid.Uuid @@ -220,7 +237,7 @@ object ExtensionsScreen : Screen { val viewModel = viewModel { ExtensionsViewModel2(manager = AndroidRepositoryManager) } LaunchedEffect(Unit) { // TODO fix this to only do if necessary - viewModel.onAction(ExtensionsAction.Init) + viewModel.onAction(Init) } val state by viewModel.state.collectAsState() val highLight = remember { mutableStateOf(null) } @@ -330,19 +347,13 @@ object ExtensionsScreen : Screen { Text(text = stringResource(R.string.installed_plugins)) }, onClick = { extraDialog = false - val activity = activity ?: return@DropdownMenuItem - activity.findNavController(R.id.nav_host_fragment).navigate( - R.id.navigation_settings_extensions_to_navigation_settings_plugins, - PluginsFragment.newLocalInstance( - activity.getString(R.string.extensions), - ) - ) + openInstalledPlugins() }) DropdownMenuItem(text = { Text(text = stringResource(R.string.update_plugins_manually)) }, onClick = { extraDialog = false - onAction(ExtensionsAction.UpdateAllPlugins) + onAction(UpdateAllPlugins) }) } @@ -386,7 +397,7 @@ object ExtensionsScreen : Screen { val removed = derivedRepositoryState.languageCount.keys.subtract(selection) onAction( - ExtensionsAction.FilterByInverseLanguages( + FilterByInverseLanguages( removed.toPersistentSet() ) ) @@ -427,6 +438,16 @@ object ExtensionsScreen : Screen { ), ) + fun openInstalledPlugins() { + val activity = activity ?: return + activity.findNavController(R.id.nav_host_fragment).navigate( + R.id.navigation_settings_extensions_to_navigation_settings_plugins, + PluginsFragment.newLocalInstance( + activity.getString(R.string.extensions), + ) + ) + } + @SuppressLint("UnrememberedMutableState") @Composable fun Content( @@ -451,7 +472,7 @@ object ExtensionsScreen : Screen { BackHandler(enabled = state.searchPlugins) { textState.clearText() - onAction(ExtensionsAction.ClearPluginSearch) + onAction(ClearPluginSearch) } /** Fab scrolling, this works but is currently a bit too fast IMPO */ @@ -500,7 +521,7 @@ object ExtensionsScreen : Screen { } LaunchedEffect(textState.text) { - onAction(ExtensionsAction.Search(textState.text.toString())) + onAction(Search(textState.text.toString())) } LazyColumn( @@ -554,7 +575,7 @@ object ExtensionsScreen : Screen { null } else { { - onAction(ExtensionsAction.SelectPlugin(plugin.uuid)) + onAction(SelectPlugin(plugin.uuid)) highLightState.value = plugin.uuid } } @@ -615,12 +636,15 @@ object ExtensionsScreen : Screen { items(items = state.repositories.sorted, key = { uuid -> uuid }) { uuid -> val repository = state.repositories[uuid]!! val isHighlighted = uuid == highLight + val derivedState = state.plugins.state.repositories[uuid] + CompositionLocalProvider( LocalPreferenceHighlighted provides isHighlighted, ) { Repository( repository = repository, onAction = onAction, + derivedState = derivedState, modifier = Modifier .animateItem() .graphicsLayer { alpha = itemAlpha.value } @@ -700,7 +724,7 @@ object ExtensionsScreen : Screen { label = stringResource(text), ) { onAction( - ExtensionsAction.FilterByTypes( + FilterByTypes( if (selected) { filter.tvTypes.removingAll(types) } else { @@ -991,7 +1015,6 @@ object ExtensionsScreen : Screen { var errorDialog by remember { mutableStateOf(null) } val errorCause = errorDialog if (errorCause != null) { - val isDownloaded = plugin.downloadLocation != null ErrorDialog( error = errorCause, tag = stringResource( @@ -999,7 +1022,7 @@ object ExtensionsScreen : Screen { plugin.info.name ), confirmButton = { - if (isDownloaded) { + if (plugin.downloadLocation != null) { WhiteButton( text = stringResource(R.string.delete_plugin), onClick = { @@ -1168,7 +1191,7 @@ object ExtensionsScreen : Screen { AlertDialog( containerColor = MaterialTheme.colorScheme.background, onDismissRequest = { - onAction(ExtensionsAction.CancelPluginsUpdate) + onAction(CancelPluginsUpdate) }, confirmButton = { }, @@ -1179,7 +1202,7 @@ object ExtensionsScreen : Screen { } else { stringResource(R.string.cancel) }, onClick = { - onAction(ExtensionsAction.CancelPluginsUpdate) + onAction(CancelPluginsUpdate) }) }, text = { @@ -1220,16 +1243,16 @@ object ExtensionsScreen : Screen { AlertDialog( containerColor = MaterialTheme.colorScheme.background, onDismissRequest = { - onAction(ExtensionsAction.SetRepositoryDialog(null)) + onAction(SetRepositoryDialog(null)) }, confirmButton = { WhiteButton(text = stringResource(R.string.add_repository), onClick = { - onAction(ExtensionsAction.DownloadRepositoryWithDialog(url = dialog.url)) + onAction(DownloadRepositoryWithDialog(url = dialog.url)) }) }, dismissButton = { BlackButton(text = stringResource(R.string.cancel), onClick = { - onAction(ExtensionsAction.SetRepositoryDialog(null)) + onAction(SetRepositoryDialog(null)) }) }, text = { @@ -1241,7 +1264,7 @@ object ExtensionsScreen : Screen { singleLine = true ) { newValue -> onAction( - ExtensionsAction.SetRepositoryDialog( + SetRepositoryDialog( dialog.copy( url = newValue, error = null @@ -1392,7 +1415,7 @@ object ExtensionsScreen : Screen { "" } onAction( - ExtensionsAction.SetRepositoryDialog( + SetRepositoryDialog( AddRepositoryDialog(url = url) ) ) @@ -1408,6 +1431,11 @@ object ExtensionsScreen : Screen { .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceVariant) .padding(MaterialTheme.padding.small) + .clickable( + enabled = isLayout(PHONE), + onClick = { + openInstalledPlugins() + }) ) { Text(stringResource(R.string.extensions)) Spacer(modifier = Modifier.height(MaterialTheme.padding.small)) @@ -1450,50 +1478,23 @@ object ExtensionsScreen : Screen { MaterialTheme.padding.extraSmall ) ) { - Box( - modifier = Modifier - .size(size) - .background( - color = MaterialTheme.colorScheme.onBackground, - shape = shape - ) - ) - Text( + DownloadBox( text = stringResource( R.string.plugins_downloaded, state.plugins.state.downloadedAndEnabled - ), - style = MaterialTheme.typography.labelMedium - ) - Box( - modifier = Modifier - .size(size) - .background( - color = MaterialTheme.colorScheme.primary, - shape = shape - ) + ), color = MaterialTheme.colorScheme.onBackground ) - Text( + DownloadBox( text = stringResource( R.string.plugins_disabled, state.plugins.state.downloadedAndDisabled - ), - style = MaterialTheme.typography.labelMedium - ) - Box( - modifier = Modifier - .size(size) - .background( - color = MaterialTheme.colorScheme.onSurfaceVariant, - shape = shape - ) + ), color = MaterialTheme.colorScheme.primary ) - Text( + DownloadBox( text = stringResource( R.string.plugins_not_downloaded, state.plugins.state.notDownloaded - ), - style = MaterialTheme.typography.labelMedium + ), color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -1503,6 +1504,7 @@ object ExtensionsScreen : Screen { fun Repository( modifier: Modifier, repository: Repository, + derivedState: DerivedRepositoryState?, onAction: (ExtensionsAction) -> Unit ) { var deleteDialog by remember { mutableStateOf(false) } @@ -1515,7 +1517,7 @@ object ExtensionsScreen : Screen { deleteDialog = false }, confirm = { - onAction(ExtensionsAction.DeleteRepository(repository.uuid)) + onAction(DeleteRepository(repository.uuid)) }, confirmText = stringResource(R.string.delete), dismissText = stringResource(R.string.cancel), @@ -1536,14 +1538,56 @@ object ExtensionsScreen : Screen { }, title = repository.info.name, subcomponent = { - Text( - text = repository.info.description ?: repository.info.url, + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), modifier = Modifier .padding(horizontal = PrefsHorizontalPadding) - .secondaryItemAlpha(), - style = MaterialTheme.typography.bodyMedium, - maxLines = 10, - ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.padding.extraSmall + ), + modifier = Modifier.defaultMinSize(minHeight = 16.dp) + ) { + if (derivedState != null) { + DownloadBox( + text = stringResource( + R.string.plugins_downloaded, + derivedState.downloadedAndEnabled + ), color = MaterialTheme.colorScheme.onBackground + ) + if (derivedState.downloadedAndDisabled > 0) { + DownloadBox( + text = stringResource( + R.string.plugins_disabled, + derivedState.downloadedAndDisabled + ), color = MaterialTheme.colorScheme.primary + ) + } + } else if (repository.status == RepositoryStatus.Loading) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onBackground, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } else { + Text( + text = stringResource( + R.string.plugin_load_fail, + repository.info.name + ), + style = MaterialTheme.typography.labelMedium + ) + } + } + Text( + text = repository.info.description ?: repository.info.url, + modifier = Modifier.secondaryItemAlpha(), + style = MaterialTheme.typography.bodyMedium, + maxLines = 10, + ) + } }, icon = { RepositoryIcon( @@ -1558,7 +1602,7 @@ object ExtensionsScreen : Screen { ) }, onClick = { - onAction(ExtensionsAction.SelectRepository(repository.uuid)) + onAction(SelectRepository(repository.uuid)) }, onLongClick = { val shareableRepoData = @@ -1596,7 +1640,7 @@ object ExtensionsScreen : Screen { tag = stringResource(R.string.error_invalid_data), confirmButton = { WhiteButton(text = stringResource(R.string.reload_error)) { - onAction(ExtensionsAction.ReloadRepository(repository.uuid)) + onAction(ReloadRepository(repository.uuid)) } WhiteButton(text = stringResource(R.string.delete_repository)) { deleteDialog = true @@ -1636,6 +1680,25 @@ object ExtensionsScreen : Screen { ) } + @Composable + fun DownloadBox( + text: String, + color: Color, + ) { + Box( + modifier = Modifier + .size(10.dp) + .background( + color = color, + shape = RoundedCornerShape(3.dp) + ) + ) + Text( + text = text, + style = MaterialTheme.typography.labelMedium + ) + } + @Composable fun remapUrl(url: String, size: Dp): String { tailrec fun findClosestBase2(target: Int, current: Int = 16, max: Int = 512): Int { From 9408f5d5f954c5988b3898c60cacd63ffb9d400c Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:23:50 +0200 Subject: [PATCH 10/19] Added clear all button --- .../extensions/AndroidRepositoryManager.kt | 7 +- .../settings/extensions/ExtensionsScreen.kt | 94 ++++++++++++++++--- .../res/drawable/arrow_circle_down_24px.xml | 10 ++ .../main/res/drawable/delete_sweep_24px.xml | 10 ++ .../res/drawable/disabled_by_default_24px.xml | 10 ++ .../main/res/drawable/extension_off_24px.xml | 10 ++ .../cloudstream4/compose/LazyList.kt | 5 +- .../viewmodels/ExtensionsViewModel2.kt | 14 ++- 8 files changed, 143 insertions(+), 17 deletions(-) create mode 100644 app/src/main/res/drawable/arrow_circle_down_24px.xml create mode 100644 app/src/main/res/drawable/delete_sweep_24px.xml create mode 100644 app/src/main/res/drawable/disabled_by_default_24px.xml create mode 100644 app/src/main/res/drawable/extension_off_24px.xml diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt index 2d627711a1e..8c54af75c4f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt @@ -115,6 +115,7 @@ object AndroidRepositoryManager : RepositoryManager { internalName = plugin.internalName, repositoryUrl = url ) + val language = plugin.language?.trim() Plugin( info = PluginInfo( name = plugin.name, @@ -126,7 +127,11 @@ object AndroidRepositoryManager : RepositoryManager { ?: persistentListOf(), authors = plugin.authors.toPersistentList(), iconUrl = plugin.iconUrl, - language = plugin.language, + language = if (language.isNullOrBlank()) { + null + } else { + language + }, description = plugin.description, repositoryUrl = plugin.repositoryUrl ), diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt index 5ac13968697..fc3d36afa18 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt @@ -21,12 +21,12 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -95,6 +95,7 @@ import coil3.compose.AsyncImage import com.fasterxml.jackson.core.JacksonException import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser import com.lagradost.cloudstream3.CommonActivity.activity +import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.plugins.PluginManager @@ -281,7 +282,7 @@ object ExtensionsScreen : Screen { }, navigateUp = LocalBackPress.current, actions = { - ActionRow(state = state, onAction = onAction) + TopActionRow(state = state, onAction = onAction) }, scrollBehavior = it, ) @@ -309,7 +310,7 @@ object ExtensionsScreen : Screen { } @Composable - fun ActionRow(state: ExtensionsState, onAction: (ExtensionsAction) -> Unit) { + fun TopActionRow(state: ExtensionsState, onAction: (ExtensionsAction) -> Unit) { val scope = rememberCoroutineScope() val clipboard = LocalClipboard.current @@ -358,8 +359,22 @@ object ExtensionsScreen : Screen { } var languageDialog by remember { mutableStateOf(false) } + var deleteDialog by remember { mutableStateOf(false) } val derivedRepositoryState = state.derivedRepositoryState + val activeRepository = state.pluginFilter.repository + if (state.searchPlugins && activeRepository != null && derivedRepositoryState.notDownloaded != derivedRepositoryState.total) { + IconButton(onClick = { + deleteDialog = true + }) { + Icon( + tint = MaterialTheme.colorScheme.onBackground, + painter = painterResource(R.drawable.delete_sweep_24px), + contentDescription = stringResource(R.string.delete_repository) + ) + } + } + /** We only show the langauge filter button if we are searching plugins and there is languages to pick from*/ if (state.searchPlugins && derivedRepositoryState.languageCount.isNotEmpty()) { IconButton(onClick = { @@ -372,6 +387,24 @@ object ExtensionsScreen : Screen { ) } } + + if(deleteDialog && activeRepository != null) { + ActionDialog( + title = stringResource(R.string.delete_files), + text = stringResource(R.string.delete_repository_plugins), + icon = painterResource(R.drawable.delete_sweep_24px), + dismiss = { + deleteDialog = false + }, + confirm = { + onAction(ExtensionsAction.DeletePluginsInRepository(activeRepository)) + deleteDialog = false + }, + confirmText = stringResource(R.string.delete), + dismissText = stringResource(R.string.cancel), + ) + } + if (languageDialog) { val entries = derivedRepositoryState.languageCount.keys.sortedBy { lang -> @@ -919,13 +952,20 @@ object ExtensionsScreen : Screen { val (backgroundFocusRequester, buttonsFocusRequester) = remember { FocusRequester.createRefs() } + val isPluginDisabledByAuthor = plugin.info.status == PROVIDER_STATUS_DOWN + BasePreferenceWidget( modifier = modifier .focusRequester(backgroundFocusRequester) .focusProperties { end = buttonsFocusRequester }, - title = plugin.info.name, + title = + if (isPluginDisabledByAuthor) { + stringResource(R.string.single_plugin_disabled, plugin.info.name) + } else { + plugin.info.name + }, subcomponent = { Column(modifier = Modifier.padding(horizontal = PrefsHorizontalPadding)) { @@ -980,7 +1020,13 @@ object ExtensionsScreen : Screen { icon = { RepositoryIcon( iconUrl = plugin.info.iconUrl, - defaultIcon = painterResource(R.drawable.extension_24px) + defaultIcon = + if (isPluginDisabledByAuthor) { + painterResource(R.drawable.extension_off_24px) + } else { + painterResource(R.drawable.extension_24px) + } + ) }, onClick = onClick ?: { @@ -1044,6 +1090,8 @@ object ExtensionsScreen : Screen { ) } + val isPluginDisabledByAuthor = plugin.info.status == PROVIDER_STATUS_DOWN + val isDownloadDisabled = isPluginDisabledByAuthor || plugin.source == null Row( modifier = modifier ) { @@ -1060,7 +1108,7 @@ object ExtensionsScreen : Screen { if (settings != null) { val context = LocalContext.current IconButton( - modifier = Modifier + modifier = Modifier.fillMaxHeight() .focusRequester(button1FocusRequester) .focusProperties { start = returnFocusRequester @@ -1108,6 +1156,8 @@ object ExtensionsScreen : Screen { PluginStatus.Idle -> { if (plugin.downloadLocation != null) { onAction(DeletePlugin(plugin.uuid)) + } else if (isDownloadDisabled) { + /** The author has disabled the plugin, so do nothing here */ } else { onAction(DownloadPlugin(plugin.uuid)) } @@ -1151,9 +1201,17 @@ object ExtensionsScreen : Screen { painter = painterResource(R.drawable.delete_24px), contentDescription = stringResource(R.string.delete_plugin) ) + } else if (isDownloadDisabled) { + Icon( + painter = painterResource(R.drawable.visibility_off_24px), + contentDescription = stringResource( + R.string.single_plugin_disabled, + plugin.info.name + ) + ) } else { Icon( - painter = painterResource(R.drawable.netflix_download), + painter = painterResource(R.drawable.arrow_circle_down_24px), contentDescription = stringResource(R.string.download) ) } @@ -1430,12 +1488,12 @@ object ExtensionsScreen : Screen { modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceVariant) - .padding(MaterialTheme.padding.small) .clickable( enabled = isLayout(PHONE), onClick = { openInstalledPlugins() }) + .padding(MaterialTheme.padding.small) ) { Text(stringResource(R.string.extensions)) Spacer(modifier = Modifier.height(MaterialTheme.padding.small)) @@ -1511,6 +1569,7 @@ object ExtensionsScreen : Screen { if (deleteDialog) { ActionDialog( + icon = painterResource(R.drawable.delete_24px), title = stringResource(R.string.delete_repository), text = stringResource(R.string.delete_repository_plugins), dismiss = { @@ -1518,6 +1577,7 @@ object ExtensionsScreen : Screen { }, confirm = { onAction(DeleteRepository(repository.uuid)) + deleteDialog = false }, confirmText = stringResource(R.string.delete), dismissText = stringResource(R.string.cancel), @@ -1548,21 +1608,28 @@ object ExtensionsScreen : Screen { horizontalArrangement = Arrangement.spacedBy( MaterialTheme.padding.extraSmall ), - modifier = Modifier.defaultMinSize(minHeight = 16.dp) + modifier = Modifier.defaultMinSize(minHeight = 14.dp) ) { if (derivedState != null) { - DownloadBox( + Text( text = stringResource( R.string.plugins_downloaded, derivedState.downloadedAndEnabled - ), color = MaterialTheme.colorScheme.onBackground + ) + /** + Not very good concatenation translation here tbh, + but we do not want to add translation keys + */ + + "/${derivedState.total}", + style = MaterialTheme.typography.labelMedium ) if (derivedState.downloadedAndDisabled > 0) { - DownloadBox( + Text( text = stringResource( R.string.plugins_disabled, derivedState.downloadedAndDisabled - ), color = MaterialTheme.colorScheme.primary + ), + style = MaterialTheme.typography.labelMedium ) } } else if (repository.status == RepositoryStatus.Loading) { @@ -1641,6 +1708,7 @@ object ExtensionsScreen : Screen { confirmButton = { WhiteButton(text = stringResource(R.string.reload_error)) { onAction(ReloadRepository(repository.uuid)) + errorDialog = false } WhiteButton(text = stringResource(R.string.delete_repository)) { deleteDialog = true diff --git a/app/src/main/res/drawable/arrow_circle_down_24px.xml b/app/src/main/res/drawable/arrow_circle_down_24px.xml new file mode 100644 index 00000000000..03edb80d2ee --- /dev/null +++ b/app/src/main/res/drawable/arrow_circle_down_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/delete_sweep_24px.xml b/app/src/main/res/drawable/delete_sweep_24px.xml new file mode 100644 index 00000000000..220f68509d2 --- /dev/null +++ b/app/src/main/res/drawable/delete_sweep_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/disabled_by_default_24px.xml b/app/src/main/res/drawable/disabled_by_default_24px.xml new file mode 100644 index 00000000000..bc935c293a2 --- /dev/null +++ b/app/src/main/res/drawable/disabled_by_default_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/extension_off_24px.xml b/app/src/main/res/drawable/extension_off_24px.xml new file mode 100644 index 00000000000..6386d44ee26 --- /dev/null +++ b/app/src/main/res/drawable/extension_off_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/LazyList.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/LazyList.kt index d41ef01ce3e..4a81bb9d09a 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/LazyList.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/LazyList.kt @@ -14,12 +14,13 @@ fun LazyGridState.IsScrolling(up : Runnable, down : Runnable) { if(lastScrolledForward) down.run() } } +@Suppress("FrequentlyChangingValue") @Composable fun LazyListState.IsScrolling(up : Runnable, down : Runnable) { - LaunchedEffect(lastScrolledBackward) { + LaunchedEffect(firstVisibleItemIndex) { if(lastScrolledBackward) up.run() } - LaunchedEffect(lastScrolledForward) { + LaunchedEffect(firstVisibleItemIndex) { if(lastScrolledForward) down.run() } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt index 0a37379ac98..eb53060c15c 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt @@ -125,7 +125,7 @@ data class FilterPlugin( plugin.info.tvTypes.any(tvTypes::contains) }) && /** Filter by language */ - (plugin.info.language?.let(removeLanguages::contains)?.not() ?: true) + !(plugin.info.language?.let(removeLanguages::contains) ?: removeLanguages.contains("")) } @Immutable @@ -336,6 +336,7 @@ interface RepositoryManager { sealed class ExtensionsAction { object Init : ExtensionsAction() data class DeleteRepository(val uuid: Uuid) : ExtensionsAction() + data class DeletePluginsInRepository(val uuid: Uuid) : ExtensionsAction() data class DeletePlugin(val uuid: Uuid) : ExtensionsAction() data class DownloadPlugin(val uuid: Uuid) : ExtensionsAction() @@ -443,6 +444,17 @@ class ExtensionsViewModel2( } } + is ExtensionsAction.DeletePluginsInRepository -> { + viewModelScope.launch { + val allPlugins = state.value.plugins + for(plugin in allPlugins.data.values) { + if(plugin.repository == action.uuid) { + deletePlugin(plugin) + } + } + } + } + is ExtensionsAction.DeleteRepository -> { val repository = state.value.repositories[action.uuid] ?: return viewModelScope.launch { From fdecd7a49560781e11e1fbfffccc6a66c925076f Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:10:06 +0200 Subject: [PATCH 11/19] Removed auto download from repository --- app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt | 4 ++-- .../cloudstream3/ui/settings/SettingsUpdatesScreen.kt | 4 ++-- .../cloudstream3/ui/settings/extensions/ExtensionsScreen.kt | 2 +- .../com/lagradost/cloudstream4/DevicePreferenceStore.kt | 1 + 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 00e18158b0f..c13b2600c13 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -1379,7 +1379,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } //Automatically download not existing plugins, using mode specified. - val autoDownloadPlugin = AutoDownloadMode.getEnum( + /*val autoDownloadPlugin = AutoDownloadMode.getEnum( settingsManager.getInt( getString(R.string.auto_download_plugins_key), 0 @@ -1390,7 +1390,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa this@MainActivity, autoDownloadPlugin ) - } + }*/ } ioSafe { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt index 3336b6127e8..3ce3ad6fbbc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt @@ -178,7 +178,7 @@ object SettingsUpdatesScreen : SearchableSettings { preference = settings.plugins.autoUpdate, ), - Preference.PreferenceItem.ListPreference( + /*Preference.PreferenceItem.ListPreference( title = stringResource(R.string.automatic_plugin_download), subtitle = "%s\n" + stringResource(R.string.automatic_plugin_download_summary), icon = painterResource(R.drawable.extention_renew2), @@ -197,7 +197,7 @@ object SettingsUpdatesScreen : SearchableSettings { ) } } - ), + ),*/ Preference.PreferenceItem.TextPreference( title = stringResource(R.string.test_extensions), subtitle = stringResource(R.string.test_extensions_summary), diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt index fc3d36afa18..c11c083819b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt @@ -1108,7 +1108,7 @@ object ExtensionsScreen : Screen { if (settings != null) { val context = LocalContext.current IconButton( - modifier = Modifier.fillMaxHeight() + modifier = Modifier .focusRequester(button1FocusRequester) .focusProperties { start = returnFocusRequester diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt index 3b2ae59c4ed..cad05c42229 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt @@ -34,6 +34,7 @@ class AppSettings internal constructor( class PluginPreferences(preferences: PreferenceStore) { val autoUpdate = preferences.getBoolean("auto_update_plugins", true) + /** Not used anymore, as a user should never auto install all plugins in a repository */ val autoDownload = preferences.getInt("auto_download_plugins_key2", 0) } From b5c255ad50c2a29f8f319f21a3c10641b75d6825 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:58:38 +0200 Subject: [PATCH 12/19] Minor focus fixes --- .../settings/extensions/ExtensionsScreen.kt | 78 ++++++++++++++----- 1 file changed, 59 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt index c11c083819b..9f5be2a4400 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -52,6 +51,7 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -116,6 +116,7 @@ import com.lagradost.cloudstream4.compose.MultiSelectDialog import com.lagradost.cloudstream4.compose.PHONE import com.lagradost.cloudstream4.compose.RoundedShape import com.lagradost.cloudstream4.compose.Screen +import com.lagradost.cloudstream4.compose.TV import com.lagradost.cloudstream4.compose.WhiteButton import com.lagradost.cloudstream4.compose.WhiteFilterChip import com.lagradost.cloudstream4.compose.isLayout @@ -388,7 +389,7 @@ object ExtensionsScreen : Screen { } } - if(deleteDialog && activeRepository != null) { + if (deleteDialog && activeRepository != null) { ActionDialog( title = stringResource(R.string.delete_files), text = stringResource(R.string.delete_repository_plugins), @@ -772,10 +773,11 @@ object ExtensionsScreen : Screen { fun ErrorDialog( error: Throwable, tag: String, - confirmButton: @Composable (() -> Unit), + confirmButton: @Composable ((Modifier) -> Unit), onDismissRequest: () -> Unit ) { - val message = error.toString() + "\n\n" + error.stackTraceToString() + val message = error.toString() + val stackTrace = error.stackTraceToString() .lines() .mapNotNull { line -> val trimmed = line.trim() @@ -787,11 +789,19 @@ object ExtensionsScreen : Screen { .joinToString("\n") val (logFocusRequester, dismissFocusRequester) = remember { FocusRequester.createRefs() } + val isTv = isLayout(TV) + LaunchedEffect(Unit) { + if (isTv) { + dismissFocusRequester.requestFocus() + } + } AlertDialog( containerColor = MaterialTheme.colorScheme.background, onDismissRequest = onDismissRequest, - confirmButton = confirmButton, + confirmButton = { + confirmButton(Modifier) + }, dismissButton = { BlackButton( text = stringResource(R.string.dismiss), @@ -803,7 +813,13 @@ object ExtensionsScreen : Screen { text = { val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .focusRequester(logFocusRequester) + .focusProperties { + down = dismissFocusRequester + }) { Row(modifier = Modifier.fillMaxWidth()) { LogBoxWhite(LogLevel.Error.identifier) LogBoxBlack(tag) @@ -813,15 +829,20 @@ object ExtensionsScreen : Screen { message = message, onClick = { scope.launch { - clipboard.setPlainText("Logcat", message) + clipboard.setPlainText("Error", message) } }, - modifier = Modifier - .focusRequester(logFocusRequester) - .focusProperties { - down = dismissFocusRequester - } ) + if(stackTrace.isNotBlank()) { + LogText( + level = LogLevel.Error, + message = stackTrace, + onClick = { + scope.launch { + clipboard.setPlainText("Stacktrace", stackTrace) + } + }) + } } }, title = { @@ -837,9 +858,16 @@ object ExtensionsScreen : Screen { onClick: (() -> Unit)?, ) { val description = plugin.info.description - var extendedDialog by remember { mutableStateOf(false) } + val dismissFocusRequester = remember { FocusRequester() } + val isTv = isLayout(TV) + LaunchedEffect(Unit) { + if (isTv) { + dismissFocusRequester.requestFocus() + } + } + if (extendedDialog) { AlertDialog( containerColor = MaterialTheme.colorScheme.background, @@ -857,8 +885,10 @@ object ExtensionsScreen : Screen { }, dismissButton = { BlackButton( + modifier = Modifier.focusRequester(dismissFocusRequester), text = stringResource(R.string.dismiss), - onClick = { extendedDialog = false }) + onClick = { extendedDialog = false } + ) }, text = { Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small)) { @@ -1067,9 +1097,10 @@ object ExtensionsScreen : Screen { R.string.plugin_load_fail, plugin.info.name ), - confirmButton = { + confirmButton = { modifier -> if (plugin.downloadLocation != null) { WhiteButton( + modifier = modifier, text = stringResource(R.string.delete_plugin), onClick = { errorDialog = null @@ -1077,6 +1108,7 @@ object ExtensionsScreen : Screen { }) } else { WhiteButton( + modifier = modifier, text = stringResource(R.string.reload_error), onClick = { errorDialog = null @@ -1335,7 +1367,9 @@ object ExtensionsScreen : Screen { Column { Spacer(modifier = Modifier.height(MaterialTheme.padding.medium)) LinearProgressIndicator( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.defaultMinSize( + minWidth = TextFieldDefaults.MinWidth, + ), color = MaterialTheme.colorScheme.onBackground, trackColor = MaterialTheme.colorScheme.surfaceVariant, ) @@ -1705,12 +1739,18 @@ object ExtensionsScreen : Screen { ErrorDialog( error = reason.cause, tag = stringResource(R.string.error_invalid_data), - confirmButton = { - WhiteButton(text = stringResource(R.string.reload_error)) { + confirmButton = { modifier -> + WhiteButton( + text = stringResource(R.string.reload_error), + modifier = modifier + ) { onAction(ReloadRepository(repository.uuid)) errorDialog = false } - WhiteButton(text = stringResource(R.string.delete_repository)) { + WhiteButton( + text = stringResource(R.string.delete_repository), + modifier = modifier + ) { deleteDialog = true } }, From eb702e3d54ee13a3f3ff5a147ba2cf67bb07649d Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:15:52 +0200 Subject: [PATCH 13/19] Fixed sorting filter to include description --- .../viewmodels/ExtensionsViewModel2.kt | 65 +++++++++++++++++-- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt index eb53060c15c..38c07bc0082 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt @@ -1,16 +1,17 @@ package com.lagradost.cloudstream4.viewmodels +import androidx.annotation.CheckResult import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.InternalAPI import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.utils.Levenshtein import com.lagradost.cloudstream4.state.ActionHandler import com.lagradost.cloudstream4.state.DefaultEffectContainer import com.lagradost.cloudstream4.state.DefaultStateContainer import com.lagradost.cloudstream4.state.DerivedState import com.lagradost.cloudstream4.state.EffectContainer -import com.lagradost.cloudstream4.state.FilterByQuery import com.lagradost.cloudstream4.state.MultiActiveQuery import com.lagradost.cloudstream4.state.SearchableData import com.lagradost.cloudstream4.state.SearchableDataState @@ -101,19 +102,66 @@ data class PluginDownloadSource( val digest: DigestPair?, ) +@Immutable +data class PluginFilterByQuery( + val query: String, + private val ratio: Int = 80, +) : Function1 { + override fun invoke(value: Plugin): Boolean { + if (query.isEmpty()) return true + val score1 = Levenshtein.partialRatio( + value.info.name.lowercase(), + query + ) + + if (score1 > ratio) return true + val score2 = value.info.description?.take(64)?.lowercase() + ?.let { description -> + Levenshtein.partialRatio( + description, + query + ) + } + + return score2 != null && score2 > ratio + } + + override fun equals(other: Any?): Boolean { + if (other === this) return true + val other = other as? PluginFilterByQuery ?: return false + if (query != other.query) return false + if (ratio != other.ratio) return false + return true + } + + override fun hashCode(): Int { + var result = ratio + result = 31 * result + query.hashCode() + return result + } + + @CheckResult + fun update(newQuery: String): PluginFilterByQuery { + val lowerCaseQuery = newQuery.lowercase() + return if (lowerCaseQuery == query) { + this + } else { + copy(query = lowerCaseQuery) + } + } +} + @Immutable data class FilterPlugin( val tvTypes: PersistentSet = TvType.entries.toSet().toPersistentSet(), val repository: Uuid? = null, - val query: FilterByQuery = FilterByQuery(query = "") { it.info.name }, + val query: PluginFilterByQuery = PluginFilterByQuery(query = ""), /** We filter by the inverse, as that makes is easy to initalize it as all = {} */ val removeLanguages: PersistentSet = persistentSetOf(), ) : Function1 { override fun invoke(plugin: Plugin): Boolean = /** Filter by repository */ (repository == null || (plugin.repository == repository)) && - /** Filter by query */ - query(plugin) && /** Filter by type */ (if (plugin.info.tvTypes.isEmpty()) { /** @@ -125,7 +173,10 @@ data class FilterPlugin( plugin.info.tvTypes.any(tvTypes::contains) }) && /** Filter by language */ - !(plugin.info.language?.let(removeLanguages::contains) ?: removeLanguages.contains("")) + !(plugin.info.language?.let(removeLanguages::contains) + ?: removeLanguages.contains("")) && + /** Filter by query */ + query(plugin) } @Immutable @@ -447,8 +498,8 @@ class ExtensionsViewModel2( is ExtensionsAction.DeletePluginsInRepository -> { viewModelScope.launch { val allPlugins = state.value.plugins - for(plugin in allPlugins.data.values) { - if(plugin.repository == action.uuid) { + for (plugin in allPlugins.data.values) { + if (plugin.repository == action.uuid) { deletePlugin(plugin) } } From aa17c941f5eb6df93c134178afe9e799dd3cd0db Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:53:53 +0200 Subject: [PATCH 14/19] Fixed sorting --- .../cloudstream4/state/ImmutableState.kt | 4 +-- .../viewmodels/ExtensionsViewModel2.kt | 34 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt index 2e8855c43eb..e506fbabb68 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt @@ -36,7 +36,7 @@ data class FilterByQuery( if (query.isEmpty()) return onEmpty val transformed = transform(value) val matchAgainst = if (ignoreCase) transformed.lowercase() else transformed - return matchAgainst.startsWith(query) || Levenshtein.ratio( + return Levenshtein.partialRatio( query, matchAgainst ) > ratio @@ -86,7 +86,7 @@ data class SortByMatching( fun score(p0: Value) : Int { val transformed = transform(p0) val matchAgainst = if (ignoreCase) transformed.lowercase() else transformed - return Levenshtein.ratio( + return Levenshtein.partialRatio( query, matchAgainst ) diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt index 38c07bc0082..baf935e8100 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt @@ -16,7 +16,6 @@ import com.lagradost.cloudstream4.state.MultiActiveQuery import com.lagradost.cloudstream4.state.SearchableData import com.lagradost.cloudstream4.state.SearchableDataState import com.lagradost.cloudstream4.state.SingleActiveQuery -import com.lagradost.cloudstream4.state.SortByMatching import com.lagradost.cloudstream4.state.SortByName import com.lagradost.cloudstream4.state.StateContainer import com.lagradost.cloudstream4.state.UniqueItem @@ -102,6 +101,37 @@ data class PluginDownloadSource( val digest: DigestPair?, ) +@Immutable +data class PluginSortBy( + val query: String, + val ignoreCase: Boolean = true, +) : Comparator { + override fun compare(p0: Plugin?, p1: Plugin?): Int { + if (p0 == p1) return 0 + if (p0 == null) return -1 + if (p1 == null) return 1 + return score(p1).compareTo(score(p0)) + } + + fun score(plugin: Plugin): Int { + val score1 = Levenshtein.partialRatio( + plugin.info.name.lowercase(), + query + ) + if (score1 > 80) { + return score1 + } + val score2 = plugin.info.description?.take(64)?.lowercase() + ?.let { description -> + Levenshtein.partialRatio( + description, + query + ) + } ?: 0 + return maxOf(score1, score2) + } +} + @Immutable data class PluginFilterByQuery( val query: String, @@ -555,7 +585,7 @@ class ExtensionsViewModel2( copy( plugins = plugins.searchBy( newSorting = if (activeQuery) { - SortByMatching(query = action.query.lowercase()) { it.info.name } + PluginSortBy(query = action.query.lowercase()) } else { SortByName { it.info.name } }, From 6a38e73262335483f57ad26707d71787c7729e7d Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:58:20 +0200 Subject: [PATCH 15/19] Added accessibility for TV --- .../cloudstream3/plugins/RepositoryManager.kt | 4 +- .../ui/settings/SettingsFragmentScreen.kt | 3 +- .../extensions/AndroidRepositoryManager.kt | 4 +- .../settings/extensions/ExtensionsScreen.kt | 82 +++++++++++++++---- .../lagradost/cloudstream4/compose/Button.kt | 26 ++++++ .../lagradost/cloudstream4/compose/Chip.kt | 12 ++- .../lagradost/cloudstream4/compose/Colors.kt | 18 ++++ .../kotlin/com/mihon/material/AppBar.kt | 5 +- 8 files changed, 131 insertions(+), 23 deletions(-) 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 e899cb2182b..d9da5519398 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -161,7 +161,7 @@ object RepositoryManager { suspend fun parseRepository(url: String): Repository? { return safeAsync { // Take manifestVersion and such into account later - app.get(convertRawGitUrl(url), cacheTime = 5, cacheUnit = TimeUnit.MINUTES) + app.get(convertRawGitUrl(url), cacheTime = 15, cacheUnit = TimeUnit.MINUTES) .parsedSafe() } } @@ -169,7 +169,7 @@ object RepositoryManager { private suspend fun parsePlugins(pluginUrls: String): List { // Take manifestVersion and such into account later return try { - app.get(convertRawGitUrl(pluginUrls), cacheTime = 5, cacheUnit = TimeUnit.MINUTES) + app.get(convertRawGitUrl(pluginUrls), cacheTime = 15, cacheUnit = TimeUnit.MINUTES) .parsed>().toList() } catch (t: Throwable) { logError(t) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt index c906b098279..d6ee90a35d6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll @@ -30,7 +31,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable @@ -300,6 +300,7 @@ object SettingsFragmentScreen : Screen { // This option is bugged on compose, making it unable to open at all // showKeyboardOnFocus = false ), + lineLimits = TextFieldLineLimits.SingleLine, modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp - 12.dp * focusProgress) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt index 8c54af75c4f..19da7c38e63 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt @@ -79,7 +79,7 @@ object AndroidRepositoryManager : RepositoryManager { val parsedRepository = app.get( url = convertRawGitUrl(url), - cacheTime = 5, + cacheTime = 15, cacheUnit = TimeUnit.MINUTES ).parsed() @@ -105,7 +105,7 @@ object AndroidRepositoryManager : RepositoryManager { /** This should really be a list of size ~ 1 */ app.get( url = convertRawGitUrl(url), - cacheTime = 5, + cacheTime = 15, cacheUnit = TimeUnit.MINUTES ).parsed>() }.flatMap { plugins -> diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt index 9f5be2a4400..e0ef8ee1bec 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -26,6 +27,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -43,7 +46,6 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LocalContentColor @@ -108,6 +110,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream4.compose.ActionDialog import com.lagradost.cloudstream4.compose.BlackButton import com.lagradost.cloudstream4.compose.BlackTextField +import com.lagradost.cloudstream4.compose.IconButtonOutline import com.lagradost.cloudstream4.compose.IsScrolling import com.lagradost.cloudstream4.compose.LogBoxBlack import com.lagradost.cloudstream4.compose.LogBoxWhite @@ -119,6 +122,7 @@ import com.lagradost.cloudstream4.compose.Screen import com.lagradost.cloudstream4.compose.TV import com.lagradost.cloudstream4.compose.WhiteButton import com.lagradost.cloudstream4.compose.WhiteFilterChip +import com.lagradost.cloudstream4.compose.focusOutline import com.lagradost.cloudstream4.compose.isLayout import com.lagradost.cloudstream4.compose.rounded import com.lagradost.cloudstream4.generated.resources.Res @@ -164,6 +168,7 @@ import com.mihon.material.padding import com.mihon.presentation.LocalBackPress import com.mihon.presentation.secondaryItemAlpha import com.mihon.presentation.settings.LocalPreferenceHighlighted +import com.mihon.presentation.settings.LocalPreferenceMinHeight import com.mihon.presentation.settings.widget.BasePreferenceWidget import com.mihon.presentation.settings.widget.PrefsHorizontalPadding import com.mihon.presentation.settings.widget.TextPreferenceWidget @@ -317,9 +322,25 @@ object ExtensionsScreen : Screen { var extraDialog by remember { mutableStateOf(false) } if (!state.searchPlugins) { - IconButton(onClick = { - extraDialog = true - }) { + if (isLayout(TV)) { + IconButtonOutline( + onClick = { + scope.launch { + openRepositoryDialog(clipboard = clipboard, onAction = onAction) + } + }) { + Icon( + tint = MaterialTheme.colorScheme.onBackground, + painter = painterResource(R.drawable.add_24px), + contentDescription = stringResource(R.string.add_repository) + ) + } + } + + IconButtonOutline( + onClick = { + extraDialog = true + }) { Icon( tint = MaterialTheme.colorScheme.onBackground, painter = painterResource(R.drawable.ic_baseline_more_vert_24), @@ -365,7 +386,7 @@ object ExtensionsScreen : Screen { val activeRepository = state.pluginFilter.repository if (state.searchPlugins && activeRepository != null && derivedRepositoryState.notDownloaded != derivedRepositoryState.total) { - IconButton(onClick = { + IconButtonOutline(onClick = { deleteDialog = true }) { Icon( @@ -378,7 +399,7 @@ object ExtensionsScreen : Screen { /** We only show the langauge filter button if we are searching plugins and there is languages to pick from*/ if (state.searchPlugins && derivedRepositoryState.languageCount.isNotEmpty()) { - IconButton(onClick = { + IconButtonOutline(onClick = { languageDialog = true }) { Icon( @@ -696,6 +717,36 @@ object ExtensionsScreen : Screen { textAlign = TextAlign.Center ) } + item { + val scope = rememberCoroutineScope() + val clipboard = LocalClipboard.current + val minHeight = LocalPreferenceMinHeight.current + Row( + modifier = Modifier + .focusOutline() + .sizeIn(minHeight = minHeight) + .combinedClickable( onClick = { scope.launch { + openRepositoryDialog( + clipboard = clipboard, + onAction = onAction + ) + } }) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + painter = painterResource(R.drawable.add_24px), + tint = MaterialTheme.colorScheme.onBackground, + contentDescription = stringResource(R.string.add_repository) + ) + Spacer(modifier = Modifier.width(MaterialTheme.padding.medium)) + Text( + text = stringResource(R.string.add_repository), + style = MaterialTheme.typography.bodyMedium + ) + } + } } } } @@ -833,7 +884,7 @@ object ExtensionsScreen : Screen { } }, ) - if(stackTrace.isNotBlank()) { + if (stackTrace.isNotBlank()) { LogText( level = LogLevel.Error, message = stackTrace, @@ -867,6 +918,7 @@ object ExtensionsScreen : Screen { dismissFocusRequester.requestFocus() } } + val isPluginDisabledByAuthor = plugin.info.status == PROVIDER_STATUS_DOWN if (extendedDialog) { AlertDialog( @@ -972,7 +1024,11 @@ object ExtensionsScreen : Screen { ) { RepositoryIcon( iconUrl = plugin.info.iconUrl, - defaultIcon = painterResource(R.drawable.extension_24px) + defaultIcon = if (isPluginDisabledByAuthor) { + painterResource(R.drawable.extension_off_24px) + } else { + painterResource(R.drawable.extension_24px) + } ) Text(text = plugin.info.name) } @@ -982,7 +1038,6 @@ object ExtensionsScreen : Screen { val (backgroundFocusRequester, buttonsFocusRequester) = remember { FocusRequester.createRefs() } - val isPluginDisabledByAuthor = plugin.info.status == PROVIDER_STATUS_DOWN BasePreferenceWidget( modifier = modifier @@ -1056,7 +1111,6 @@ object ExtensionsScreen : Screen { } else { painterResource(R.drawable.extension_24px) } - ) }, onClick = onClick ?: { @@ -1139,7 +1193,7 @@ object ExtensionsScreen : Screen { if (settings != null) { val context = LocalContext.current - IconButton( + IconButtonOutline( modifier = Modifier .focusRequester(button1FocusRequester) .focusProperties { @@ -1165,7 +1219,7 @@ object ExtensionsScreen : Screen { } } - IconButton( + IconButtonOutline( modifier = Modifier .focusRequester(button2FocusRequester) .focusProperties { @@ -1720,7 +1774,7 @@ object ExtensionsScreen : Screen { when (val reason = repository.status) { is RepositoryStatus.Failure -> { var errorDialog by remember { mutableStateOf(false) } - IconButton( + IconButtonOutline( onClick = { errorDialog = true }, @@ -1761,7 +1815,7 @@ object ExtensionsScreen : Screen { } RepositoryStatus.Loading, RepositoryStatus.Success -> { - IconButton( + IconButtonOutline( onClick = { deleteDialog = true }, diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Button.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Button.kt index 348a904223f..eba57df8704 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Button.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Button.kt @@ -2,10 +2,15 @@ package com.lagradost.cloudstream4.compose import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonColors import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -15,6 +20,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.dp import com.lagradost.cloudstream4.generated.resources.Res import com.lagradost.cloudstream4.generated.resources.check @@ -49,6 +55,26 @@ fun BlackButton( ) } +@Composable +fun IconButtonOutline( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), + interactionSource: MutableInteractionSource? = null, + shape: Shape = IconButtonDefaults.standardShape, + content: @Composable () -> Unit, +) = + IconButton( + onClick = onClick, + modifier = modifier.focusOutline(shape = CircleShape), + enabled = enabled, + colors = colors, + interactionSource = interactionSource, + shape = shape, + content = content + ) + @Composable fun BaseButton( text: String, onClick: () -> Unit, buttonColors: ButtonColors, diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Chip.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Chip.kt index a87e6acbe3d..1238ed64938 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Chip.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Chip.kt @@ -32,7 +32,11 @@ fun WhiteFilterChip( border = FilterChipDefaults.filterChipBorder( enabled = enabled, selected = selected, - borderColor = MaterialTheme.colorScheme.surfaceVariant, + borderColor = if (hasFocus) { + MaterialTheme.colorScheme.onBackground + } else { + MaterialTheme.colorScheme.surfaceVariant + }, disabledBorderColor = MaterialTheme.colorScheme.surfaceVariant, selectedBorderColor = Color.Transparent, disabledSelectedBorderColor = MaterialTheme.colorScheme.surfaceVariant, @@ -41,7 +45,11 @@ fun WhiteFilterChip( hasFocus = canHaveFocus && newFocus.hasFocus }, selected = selected, - colors = Colors.whiteChip, + colors = if (hasFocus) { + Colors.whiteChipFocused + } else { + Colors.whiteChip + }, label = { Text(label) }, diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt index c15c66b2392..f65a1a62576 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt @@ -38,6 +38,24 @@ object Colors { disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + selectedContainerColor = MaterialTheme.colorScheme.onBackground, + selectedLabelColor = MaterialTheme.colorScheme.background, + selectedLeadingIconColor = MaterialTheme.colorScheme.background, + selectedTrailingIconColor = MaterialTheme.colorScheme.background, + ) + internal val whiteChipFocused + @Composable @ReadOnlyComposable get() = SelectableChipColors( + containerColor = Color.Transparent, + labelColor = MaterialTheme.colorScheme.onBackground, + leadingIconColor = MaterialTheme.colorScheme.onBackground, + trailingIconColor = MaterialTheme.colorScheme.onBackground, + + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + disabledSelectedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + selectedContainerColor = MaterialTheme.colorScheme.onBackground, selectedLabelColor = MaterialTheme.colorScheme.background, selectedLeadingIconColor = MaterialTheme.colorScheme.background, diff --git a/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt b/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt index e5444159553..2b79a6c149f 100644 --- a/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt +++ b/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.compose.IconButtonOutline import com.lagradost.cloudstream4.generated.resources.Res import com.lagradost.cloudstream4.generated.resources.action_bar_up_description import com.lagradost.cloudstream4.generated.resources.arrow_back @@ -104,7 +105,7 @@ fun AppBar( windowInsets = WindowInsets(0, 0, 0, 0), navigationIcon = { if (isActionMode) { - IconButton(onClick = onCancelActionMode) { + IconButtonOutline(onClick = onCancelActionMode) { Icon( painter = painterResource(Res.drawable.close), contentDescription = stringResource(Res.string.cancel), @@ -112,7 +113,7 @@ fun AppBar( } } else { navigateUp?.let { - IconButton(onClick = it) { + IconButtonOutline(onClick = it) { UpIcon(navigationIcon = navigationIcon) } } From 64109f2a30d51c29a1791ff8053a0dd6c546be5d Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:04:02 +0200 Subject: [PATCH 16/19] Fixed minor UI issue --- .../cloudstream3/ui/settings/extensions/ExtensionsScreen.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt index e0ef8ee1bec..26b5aec08ca 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt @@ -1595,7 +1595,7 @@ object ExtensionsScreen : Screen { .weight( maxOf( state.plugins.state.downloadedAndEnabled.toFloat(), - 0.01f + 0.001f ) ) ) @@ -1606,7 +1606,7 @@ object ExtensionsScreen : Screen { .weight( maxOf( state.plugins.state.downloadedAndDisabled.toFloat(), - 0.01f + 0.001f ) ) ) @@ -1614,7 +1614,7 @@ object ExtensionsScreen : Screen { modifier = Modifier .height(size) .background(color = MaterialTheme.colorScheme.onSurfaceVariant) - .weight(maxOf(state.plugins.state.notDownloaded.toFloat(), 0.2f)) + .weight(maxOf(state.plugins.state.notDownloaded.toFloat(), 0.02f)) ) } Spacer(modifier = Modifier.height(MaterialTheme.padding.small)) From b6b26fa85ea86bea83d1117dc011e9f69e897379 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:30:26 +0200 Subject: [PATCH 17/19] Fixes for input fields and focus --- .../cloudstream3/ui/account/AccountScreen.kt | 6 +-- .../ui/settings/SettingsFragmentScreen.kt | 43 ++++++++++++++-- .../settings/extensions/ExtensionsScreen.kt | 51 +++++++++++-------- .../lagradost/cloudstream4/compose/Button.kt | 2 +- .../cloudstream4/compose/TextField.kt | 3 ++ .../kotlin/com/mihon/material/AppBar.kt | 7 ++- 6 files changed, 78 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountScreen.kt index 3d80f374227..056d3224e2c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountScreen.kt @@ -143,7 +143,7 @@ object AccountScreen { .padding(contentPadding), horizontalAlignment = Alignment.End ) { - val (edit, accounts) = FocusRequester.createRefs() + val (edit, accounts) = remember { FocusRequester.createRefs() } IconButton( onClick = { @@ -318,7 +318,7 @@ object AccountScreen { ) } - val (focusRequester) = FocusRequester.createRefs() + val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.requestFocus() } @@ -746,7 +746,7 @@ object AccountScreen { val innerPadding by animateDpAsState( targetValue = if (hasFocus || isLayout(PHONE or EMULATOR)) 0.dp else MaterialTheme.padding.extraSmall, ) - val (focusRequester) = FocusRequester.createRefs() + val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { if (selected) { focusRequester.requestFocus() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt index d6ee90a35d6..305b8ec28b5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt @@ -47,6 +47,11 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -288,26 +293,54 @@ object SettingsFragmentScreen : Screen { @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable - fun SettingsSearch(textFieldState : TextFieldState) { + fun SettingsSearch(textFieldState: TextFieldState) { var hasFocus by remember { mutableStateOf(false) } + var isWritable by remember { mutableStateOf(false) } val focusProgress by animateFloatAsState(targetValue = if (hasFocus) 1.0f else 0.0f) val focusManager = LocalFocusManager.current val focusRequester = remember { FocusRequester() } + val isTv = isLayout(TV) TextField( state = textFieldState, keyboardOptions = KeyboardOptions.Default.copy( imeAction = ImeAction.Search, - // This option is bugged on compose, making it unable to open at all - // showKeyboardOnFocus = false + /** + * We do not want to pull up the keyboard when navigating to the item on TV, therefore + * we listen to when the TV user presses some key to "extra" focus it + * + * showKeyboardOnFocus is somewhat bugged, so need to change this to true when you + * press it, as otherwise no keyboard will show even with keyboardController + * */ + showKeyboardOnFocus = if (isTv) isWritable else true ), lineLimits = TextFieldLineLimits.SingleLine, modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp - 12.dp * focusProgress) - .focusOutline(enabled = isLayout(TV), CircleShape) + .focusOutline(enabled = isTv, CircleShape) .onFocusChanged { newFocus -> hasFocus = newFocus.hasFocus - }.focusRequester(focusRequester), + /** Loss of focus -> readonly */ + if (!newFocus.hasFocus) { + isWritable = false + } + } + .focusRequester(focusRequester) + .onPreviewKeyEvent { keyEvent -> + if (isWritable || !isTv || !hasFocus || keyEvent.type != KeyEventType.KeyUp) { + return@onPreviewKeyEvent false + } + + /** Focus + press once -> writable */ + return@onPreviewKeyEvent when (keyEvent.key) { + Key.Enter, Key.DirectionCenter, Key.NumPadEnter, Key.Spacebar -> { + isWritable = true + true + } + + else -> false + } + }, placeholder = { Text(text = stringResource(R.string.search_hint)) }, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt index 26b5aec08ca..ca8dfac60cf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.rememberTextFieldState @@ -110,7 +111,6 @@ import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream4.compose.ActionDialog import com.lagradost.cloudstream4.compose.BlackButton import com.lagradost.cloudstream4.compose.BlackTextField -import com.lagradost.cloudstream4.compose.IconButtonOutline import com.lagradost.cloudstream4.compose.IsScrolling import com.lagradost.cloudstream4.compose.LogBoxBlack import com.lagradost.cloudstream4.compose.LogBoxWhite @@ -122,6 +122,7 @@ import com.lagradost.cloudstream4.compose.Screen import com.lagradost.cloudstream4.compose.TV import com.lagradost.cloudstream4.compose.WhiteButton import com.lagradost.cloudstream4.compose.WhiteFilterChip +import com.lagradost.cloudstream4.compose.WhiteIconButton import com.lagradost.cloudstream4.compose.focusOutline import com.lagradost.cloudstream4.compose.isLayout import com.lagradost.cloudstream4.compose.rounded @@ -323,7 +324,7 @@ object ExtensionsScreen : Screen { var extraDialog by remember { mutableStateOf(false) } if (!state.searchPlugins) { if (isLayout(TV)) { - IconButtonOutline( + WhiteIconButton( onClick = { scope.launch { openRepositoryDialog(clipboard = clipboard, onAction = onAction) @@ -337,7 +338,7 @@ object ExtensionsScreen : Screen { } } - IconButtonOutline( + WhiteIconButton( onClick = { extraDialog = true }) { @@ -386,7 +387,7 @@ object ExtensionsScreen : Screen { val activeRepository = state.pluginFilter.repository if (state.searchPlugins && activeRepository != null && derivedRepositoryState.notDownloaded != derivedRepositoryState.total) { - IconButtonOutline(onClick = { + WhiteIconButton(onClick = { deleteDialog = true }) { Icon( @@ -399,7 +400,7 @@ object ExtensionsScreen : Screen { /** We only show the langauge filter button if we are searching plugins and there is languages to pick from*/ if (state.searchPlugins && derivedRepositoryState.languageCount.isNotEmpty()) { - IconButtonOutline(onClick = { + WhiteIconButton(onClick = { languageDialog = true }) { Icon( @@ -910,17 +911,17 @@ object ExtensionsScreen : Screen { ) { val description = plugin.info.description var extendedDialog by remember { mutableStateOf(false) } - - val dismissFocusRequester = remember { FocusRequester() } val isTv = isLayout(TV) - LaunchedEffect(Unit) { - if (isTv) { - dismissFocusRequester.requestFocus() - } - } val isPluginDisabledByAuthor = plugin.info.status == PROVIDER_STATUS_DOWN if (extendedDialog) { + val dismissFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (isTv) { + dismissFocusRequester.requestFocus() + } + } + AlertDialog( containerColor = MaterialTheme.colorScheme.background, onDismissRequest = { @@ -1193,7 +1194,7 @@ object ExtensionsScreen : Screen { if (settings != null) { val context = LocalContext.current - IconButtonOutline( + WhiteIconButton( modifier = Modifier .focusRequester(button1FocusRequester) .focusProperties { @@ -1219,7 +1220,7 @@ object ExtensionsScreen : Screen { } } - IconButtonOutline( + WhiteIconButton( modifier = Modifier .focusRequester(button2FocusRequester) .focusProperties { @@ -1384,6 +1385,8 @@ object ExtensionsScreen : Screen { return } + val nextFocusRequester = remember { FocusRequester() } + AlertDialog( containerColor = MaterialTheme.colorScheme.background, onDismissRequest = { @@ -1392,7 +1395,7 @@ object ExtensionsScreen : Screen { confirmButton = { WhiteButton(text = stringResource(R.string.add_repository), onClick = { onAction(DownloadRepositoryWithDialog(url = dialog.url)) - }) + }, modifier = Modifier.focusRequester(nextFocusRequester)) }, dismissButton = { BlackButton(text = stringResource(R.string.cancel), onClick = { @@ -1405,6 +1408,14 @@ object ExtensionsScreen : Screen { value = dialog.url, placeHolder = stringResource(R.string.repository_url_hint), keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri), + keyboardActions = KeyboardActions( + onDone = { + onAction(DownloadRepositoryWithDialog(url = dialog.url)) + }, + onNext = { + nextFocusRequester.requestFocus() + } + ), singleLine = true ) { newValue -> onAction( @@ -1421,9 +1432,7 @@ object ExtensionsScreen : Screen { Column { Spacer(modifier = Modifier.height(MaterialTheme.padding.medium)) LinearProgressIndicator( - modifier = Modifier.defaultMinSize( - minWidth = TextFieldDefaults.MinWidth, - ), + modifier = Modifier.width(TextFieldDefaults.MinWidth), color = MaterialTheme.colorScheme.onBackground, trackColor = MaterialTheme.colorScheme.surfaceVariant, ) @@ -1460,7 +1469,7 @@ object ExtensionsScreen : Screen { val message = error.message TextPreferenceWidget( - modifier = Modifier.rounded(), + modifier = Modifier.rounded().width(TextFieldDefaults.MinWidth), title = when { error is JacksonException -> { stringResource(R.string.no_repository_found_error) @@ -1774,7 +1783,7 @@ object ExtensionsScreen : Screen { when (val reason = repository.status) { is RepositoryStatus.Failure -> { var errorDialog by remember { mutableStateOf(false) } - IconButtonOutline( + WhiteIconButton( onClick = { errorDialog = true }, @@ -1815,7 +1824,7 @@ object ExtensionsScreen : Screen { } RepositoryStatus.Loading, RepositoryStatus.Success -> { - IconButtonOutline( + WhiteIconButton( onClick = { deleteDialog = true }, diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Button.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Button.kt index eba57df8704..dcf8511d366 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Button.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Button.kt @@ -56,7 +56,7 @@ fun BlackButton( } @Composable -fun IconButtonOutline( +fun WhiteIconButton( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/TextField.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/TextField.kt index be96bf5e4e8..08e21a4c7b0 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/TextField.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/TextField.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream4.compose +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Text import androidx.compose.material3.TextField @@ -12,6 +13,7 @@ fun BlackTextField( placeHolder : String, modifier: Modifier = Modifier, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, singleLine: Boolean = false, onValueChange: (String) -> Unit, ) { @@ -20,6 +22,7 @@ fun BlackTextField( value = value, onValueChange = onValueChange, keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, placeholder = { Text(text = placeHolder) }, diff --git a/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt b/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt index 2b79a6c149f..0af8eaf40cb 100644 --- a/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt +++ b/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -21,7 +20,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.lagradost.cloudstream4.compose.IconButtonOutline +import com.lagradost.cloudstream4.compose.WhiteIconButton import com.lagradost.cloudstream4.generated.resources.Res import com.lagradost.cloudstream4.generated.resources.action_bar_up_description import com.lagradost.cloudstream4.generated.resources.arrow_back @@ -105,7 +104,7 @@ fun AppBar( windowInsets = WindowInsets(0, 0, 0, 0), navigationIcon = { if (isActionMode) { - IconButtonOutline(onClick = onCancelActionMode) { + WhiteIconButton(onClick = onCancelActionMode) { Icon( painter = painterResource(Res.drawable.close), contentDescription = stringResource(Res.string.cancel), @@ -113,7 +112,7 @@ fun AppBar( } } else { navigateUp?.let { - IconButtonOutline(onClick = it) { + WhiteIconButton(onClick = it) { UpIcon(navigationIcon = navigationIcon) } } From ceb6b554e7a5957d11e7655a8a7c691d7b539aa6 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:57:25 +0200 Subject: [PATCH 18/19] Fixed some digest stuff, added nsfw filter --- .../cloudstream3/ui/settings/ApkUpdater.kt | 52 +++++++++++++++++++ .../extensions/AndroidRepositoryManager.kt | 11 ++++ .../settings/extensions/ExtensionsScreen.kt | 27 +++++++--- .../viewmodels/ExtensionsViewModel2.kt | 46 ++++++++++++---- 4 files changed, 117 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt index 89280f2b09b..34fea2938c1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt @@ -189,6 +189,58 @@ object ApkUpdater : AppUpdater { } } + /** + * Returns true if we have a non-null digest that matches a digest on the specified existing file + * */ + suspend fun isFileDigest(file: File, digest: DigestPair?): Boolean { + if (digest == null) { + return false + } + try { + val fileDigest = calculateDigest( + digestAlgorithm = digest.algorithm, + readStream = file.inputStream() + ) + return fileDigest != null && digest.digest.contentEquals(fileDigest) + } catch (_: Throwable) { + // File not found/security/read issues, not very useful errors + } + return false + } + + /** + * Calculates the digest of a stream with the specified algorithm, use transfer instead if + * you already download something. + * + * Will throw read errors + * Will return null if the algorithm is null or does not exist/has some error + * */ + @Throws + suspend fun calculateDigest(digestAlgorithm: String?, readStream: InputStream) = + withContext(Dispatchers.IO) { + if (digestAlgorithm == null) return@withContext null + + val md = + safe { + MessageDigest.getInstance(digestAlgorithm) + } ?: return@withContext null + + val context = currentCoroutineContext() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var read: Int + while ((readStream.read(buffer, 0, DEFAULT_BUFFER_SIZE) + .also { read = it }) >= 0 + ) { + try { + md.update(buffer, 0, read) + } catch (_: Throwable) { + return@withContext null + } + context.ensureActive() + } + return@withContext safe { md.digest() } + } + @Throws suspend fun legacyDownloader( activity: Activity, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt index 19da7c38e63..3fbc67a7424 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt @@ -21,6 +21,7 @@ import com.lagradost.cloudstream3.plugins.RepositoryManager.parseRepoUrl import com.lagradost.cloudstream3.plugins.RepositoryManager.removeRepository import com.lagradost.cloudstream3.plugins.SitePlugin import com.lagradost.cloudstream3.ui.settings.ApkUpdater +import com.lagradost.cloudstream3.ui.settings.ApkUpdater.isFileDigest import com.lagradost.cloudstream4.viewmodels.DigestPair import com.lagradost.cloudstream4.viewmodels.Plugin import com.lagradost.cloudstream4.viewmodels.PluginDownloadSource @@ -173,6 +174,16 @@ object AndroidRepositoryManager : RepositoryManager { repositoryUrl = source.repositoryUrl ) + /** + * Before we do anything that needs a network connection, + * we actually check if we need to update it + * + * Because if the file digest is correct, then why do we need to do a network connection? + * */ + if (isFileDigest(file, source.digest)) { + return@withContext file.absolutePath + } + val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir) tempFile.outputStream().use { writeStream -> diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt index ca8dfac60cf..428cb998ca3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt @@ -129,6 +129,7 @@ import com.lagradost.cloudstream4.compose.rounded import com.lagradost.cloudstream4.generated.resources.Res import com.lagradost.cloudstream4.generated.resources.no_results_found import com.lagradost.cloudstream4.getPlainText +import com.lagradost.cloudstream4.rememberAppSettings import com.lagradost.cloudstream4.setPlainText import com.lagradost.cloudstream4.state.LogLevel import com.lagradost.cloudstream4.state.ObserveEffect @@ -242,8 +243,14 @@ fun Preview() { object ExtensionsScreen : Screen { @Composable override fun Content() { + val settings = rememberAppSettings() val viewModel = - viewModel { ExtensionsViewModel2(manager = AndroidRepositoryManager) } + viewModel { + ExtensionsViewModel2( + manager = AndroidRepositoryManager, + settings = settings + ) + } LaunchedEffect(Unit) { // TODO fix this to only do if necessary viewModel.onAction(Init) } @@ -726,12 +733,14 @@ object ExtensionsScreen : Screen { modifier = Modifier .focusOutline() .sizeIn(minHeight = minHeight) - .combinedClickable( onClick = { scope.launch { - openRepositoryDialog( - clipboard = clipboard, - onAction = onAction - ) - } }) + .combinedClickable(onClick = { + scope.launch { + openRepositoryDialog( + clipboard = clipboard, + onAction = onAction + ) + } + }) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, @@ -1469,7 +1478,9 @@ object ExtensionsScreen : Screen { val message = error.message TextPreferenceWidget( - modifier = Modifier.rounded().width(TextFieldDefaults.MinWidth), + modifier = Modifier + .rounded() + .width(TextFieldDefaults.MinWidth), title = when { error is JacksonException -> { stringResource(R.string.no_repository_found_error) diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt index baf935e8100..6a6bdfe2cec 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.InternalAPI import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.utils.Levenshtein +import com.lagradost.cloudstream4.AppSettings import com.lagradost.cloudstream4.state.ActionHandler import com.lagradost.cloudstream4.state.DefaultEffectContainer import com.lagradost.cloudstream4.state.DefaultStateContainer @@ -446,6 +447,7 @@ sealed class ExtensionsAction { * <---(state)---/ <--(callback)--/ * */ class ExtensionsViewModel2( + private val settings : AppSettings, private val manager: RepositoryManager, private val localUuid: Uuid = Uuid.random(), ) : ViewModel(), StateContainer by DefaultStateContainer(ExtensionsState()), @@ -632,7 +634,7 @@ class ExtensionsViewModel2( } private suspend fun onRepositoryAdded( - uuid: Uuid, + repositoryUuid: Uuid, newRepositoryInfo: RepositoryInfo, pluginsFetched: List ) { @@ -650,10 +652,23 @@ class ExtensionsViewModel2( uniqueId(plugin) to plugin.uuid } - /** If we refresh plugins that already exist by assigning them the old uuid */ - val newPlugins = pluginsFetched.associate { plugin -> - val newUuid = oldPlugins[uniqueId(plugin)] - ?: plugin.uuid + /** + * NSFW is a unique setting that we filter here, instead of the regular filter + * to avoid issues where the UI says something else that what we can see + * + * Moreover, we only filter remove plugins, as you should always have access to "local" + * plugins + * */ + val includeNSFW = settings.provider.preferredMedia.get().contains(TvType.NSFW.ordinal.toString()) + val filteredPluginsFetched = if(includeNSFW) { + pluginsFetched + } else { + pluginsFetched.filterNot { it.info.tvTypes.contains(TvType.NSFW) } + } + + /** Reuse the uuid if possible, to avoid duplicates and avoid clearing UI */ + val newPluginsFetched = filteredPluginsFetched.associate { plugin -> + val newUuid = oldPlugins[uniqueId(plugin)] ?: plugin.uuid newUuid to if (newUuid != plugin.uuid) { plugin.copy(uuid = newUuid) } else { @@ -661,13 +676,22 @@ class ExtensionsViewModel2( } } + val newPlugins = plugins.data.mutate { edit -> + for ((uuid, plugin) in plugins.data) { + if (plugin.repository == repositoryUuid) { + edit.remove(uuid) + } + } + edit.putAll(newPluginsFetched) + } + copy( - plugins = plugins.setTo(plugins.data.puttingAll(newPlugins)), + plugins = plugins.setTo(newPlugins), repositories = repositories.adding( - uuid, Repository( + repositoryUuid, Repository( status = RepositoryStatus.Success, info = newRepositoryInfo, - uuid = uuid + uuid = repositoryUuid ) ) ) @@ -788,7 +812,7 @@ class ExtensionsViewModel2( return@launch } onRepositoryAdded( - uuid = uuid, + repositoryUuid = uuid, newRepositoryInfo = newRepositoryInfo, pluginsFetched = pluginsFetched ) @@ -915,7 +939,7 @@ class ExtensionsViewModel2( } }.onSuccess { (newRepositoryInfo, pluginsFetched) -> onRepositoryAdded( - uuid = uuid, + repositoryUuid = uuid, newRepositoryInfo = newRepositoryInfo, pluginsFetched = pluginsFetched ) @@ -956,7 +980,7 @@ class ExtensionsViewModel2( "All local plugins must have a download path" } - val newUuid = oldPlugins[plugin.downloadLocation] ?: Uuid.random() + val newUuid = oldPlugins[plugin.downloadLocation] ?: plugin.uuid newUuid to if (newUuid != plugin.uuid) { plugin.copy(uuid = newUuid) } else { From bcf58e1b8e25e1f39c25ef56e17560b357d2d195 Mon Sep 17 00:00:00 2001 From: LagradOst <11805592+LagradOst@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:12:27 +0200 Subject: [PATCH 19/19] Fixed sorting order --- .../cloudstream3/plugins/RepositoryManager.kt | 11 ++- .../settings/extensions/ExtensionsScreen.kt | 69 +++++++++------ .../viewmodels/ExtensionsViewModel2.kt | 84 +++++++++++++++---- 3 files changed, 121 insertions(+), 43 deletions(-) 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 d9da5519398..910d847353e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -248,8 +248,15 @@ object RepositoryManager { suspend fun addRepository(repository: RepositoryData) { repoLock.withLock { // No duplicates, but we want the new repository instead of the old one - val currentRepos = getRepositories().associateBy { it.url } + (repository.url to repository) - setKey(REPOSITORIES_KEY, currentRepos.values.toTypedArray()) + var currentRepos = getRepositories() + val index = currentRepos.indexOfFirst { it.url == repository.url } + if(index == -1) { + currentRepos += repository + } else { + /** We want to keep the ordering here, as that is the addition order */ + currentRepos[index] = repository + } + setKey(REPOSITORIES_KEY, currentRepos) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt index 428cb998ca3..3725a99da61 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt @@ -96,6 +96,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.findNavController import coil3.compose.AsyncImage import com.fasterxml.jackson.core.JacksonException +import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN @@ -210,7 +211,8 @@ fun Preview() { url = "https://raw.githubusercontent.com/recloudstream/extensions/refs/heads/master/repo.json", iconUrl = null ), - status = RepositoryStatus.Loading + status = RepositoryStatus.Loading, + sortingOrder = 0, ), uuid2 to Repository( uuid = uuid2, @@ -219,7 +221,8 @@ fun Preview() { url = "https://codeberg.org/", iconUrl = null ), - status = RepositoryStatus.Loading + status = RepositoryStatus.Loading, + sortingOrder = 1, ), uuid3 to Repository( uuid = uuid3, @@ -228,7 +231,8 @@ fun Preview() { url = "https://pastebin.com/", iconUrl = null ), - status = RepositoryStatus.Loading + status = RepositoryStatus.Loading, + sortingOrder = 2, ) ), sortedBy = SortByName { it.info.name } @@ -386,6 +390,19 @@ object ExtensionsScreen : Screen { extraDialog = false onAction(UpdateAllPlugins) }) + + /** + * Easier to debug the system if we have this. + * No need to translate this IMPO + * */ + if (BuildConfig.DEBUG) { + DropdownMenuItem(text = { + Text(text = "Delete all repositories") + }, onClick = { + extraDialog = false + onAction(ExtensionsAction.DeleteAllRepositories) + }) + } } var languageDialog by remember { mutableStateOf(false) } @@ -561,9 +578,8 @@ object ExtensionsScreen : Screen { val index = list.indexOfFirst { uuid -> uuid == highLight } if (index != -1) { lazyRepoState.animateScrollToItem( - index + - /** We have some extra items here */ - if (state.searchPlugins) 2 else 1 + /** We have some extra items here */ + index + 2 ) } delay(5.0.seconds) @@ -696,24 +712,6 @@ object ExtensionsScreen : Screen { } } } else { - items(items = state.repositories.sorted, key = { uuid -> uuid }) { uuid -> - val repository = state.repositories[uuid]!! - val isHighlighted = uuid == highLight - val derivedState = state.plugins.state.repositories[uuid] - - CompositionLocalProvider( - LocalPreferenceHighlighted provides isHighlighted, - ) { - Repository( - repository = repository, - onAction = onAction, - derivedState = derivedState, - modifier = Modifier - .animateItem() - .graphicsLayer { alpha = itemAlpha.value } - ) - } - } if (state.repositories.sorted.isEmpty()) { item { Text( @@ -757,6 +755,29 @@ object ExtensionsScreen : Screen { ) } } + } else { + item(key = "items top padding") { + Spacer(modifier = Modifier.height(MaterialTheme.padding.small)) + } + } + + items(items = state.repositories.sorted, key = { uuid -> uuid }) { uuid -> + val repository = state.repositories[uuid]!! + val isHighlighted = uuid == highLight + val derivedState = state.plugins.state.repositories[uuid] + + CompositionLocalProvider( + LocalPreferenceHighlighted provides isHighlighted, + ) { + Repository( + repository = repository, + onAction = onAction, + derivedState = derivedState, + modifier = Modifier + .animateItem() + .graphicsLayer { alpha = itemAlpha.value } + ) + } } } } diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt index 6a6bdfe2cec..41c58b21241 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt @@ -20,6 +20,7 @@ import com.lagradost.cloudstream4.state.SingleActiveQuery import com.lagradost.cloudstream4.state.SortByName import com.lagradost.cloudstream4.state.StateContainer import com.lagradost.cloudstream4.state.UniqueItem +import com.lagradost.cloudstream4.state.sortByInt import com.lagradost.cloudstream4.viewmodels.ExtensionsEffect.RepositoryAdded import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.PersistentMap @@ -27,6 +28,7 @@ import kotlinx.collections.immutable.PersistentSet import kotlinx.collections.immutable.mutate import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.puttingAll import kotlinx.collections.immutable.toPersistentMap import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.Dispatchers @@ -49,6 +51,14 @@ data class RepositoryInfo( data class Repository( val info: RepositoryInfo, val status: RepositoryStatus, + /** + * To avoid the issue of people using ! to sort repositories higher up, + * we use the added insertion order. + * + * We can add an alphabetical sort later, + * but the default should not prioritize on name + * */ + val sortingOrder: Int, override val uuid: Uuid ) : UniqueItem @@ -349,7 +359,7 @@ data class DerivedPluginsState( data class ExtensionsState( val repositories: SearchableData = SearchableData.from( data = persistentMapOf(), - sortedBy = SortByName { it.info.name } + sortedBy = sortByInt(Repository::sortingOrder) ), val plugins: SearchableDataState = SearchableDataState.from( data = persistentMapOf(), @@ -389,6 +399,7 @@ sealed class ExtensionsEffect { @InternalAPI interface RepositoryManager { + /** Returns an ***ordered*** list of installed repositories */ fun getRepositories(): List @Throws @@ -435,6 +446,7 @@ sealed class ExtensionsAction { data class SetRepositoryDialog(val dialog: AddRepositoryDialog?) : ExtensionsAction() object ClearPluginSearch : ExtensionsAction() object UpdateAllPlugins : ExtensionsAction() + object DeleteAllRepositories : ExtensionsAction() object CancelPluginsUpdate : ExtensionsAction() data class CancelPluginDownloader(val uuid: Uuid) : ExtensionsAction() } @@ -447,7 +459,7 @@ sealed class ExtensionsAction { * <---(state)---/ <--(callback)--/ * */ class ExtensionsViewModel2( - private val settings : AppSettings, + private val settings: AppSettings, private val manager: RepositoryManager, private val localUuid: Uuid = Uuid.random(), ) : ViewModel(), StateContainer by DefaultStateContainer(ExtensionsState()), @@ -461,7 +473,8 @@ class ExtensionsViewModel2( description = "Locally installed plugins" ), status = RepositoryStatus.Success, - uuid = localUuid + uuid = localUuid, + sortingOrder = -1, ) private val repositoryDispatcher = MultiActiveQuery(dispatcher = Dispatchers.IO) private val downloadDispatcher = MultiActiveQuery(dispatcher = Dispatchers.IO) @@ -504,6 +517,15 @@ class ExtensionsViewModel2( } } + ExtensionsAction.DeleteAllRepositories -> { + val repositories = state.value.repositories.data.values + viewModelScope.launch { + for (repository in repositories) { + deleteRepository(repository) + } + } + } + is ExtensionsAction.CancelPluginDownloader -> { viewModelScope.launch { downloadDispatcher.cancelOne(action.uuid) @@ -659,8 +681,9 @@ class ExtensionsViewModel2( * Moreover, we only filter remove plugins, as you should always have access to "local" * plugins * */ - val includeNSFW = settings.provider.preferredMedia.get().contains(TvType.NSFW.ordinal.toString()) - val filteredPluginsFetched = if(includeNSFW) { + val includeNSFW = + settings.provider.preferredMedia.get().contains(TvType.NSFW.ordinal.toString()) + val filteredPluginsFetched = if (includeNSFW) { pluginsFetched } else { pluginsFetched.filterNot { it.info.tvTypes.contains(TvType.NSFW) } @@ -685,13 +708,28 @@ class ExtensionsViewModel2( edit.putAll(newPluginsFetched) } + /** + * Order = `current ?: max + 1 ?: 0` as that will keep the insertion order + * even if we can remove repos in the middle of the process. + * + * e.g. 0,1,2 -> 0,1,2,3 + * 0,2 -> 0,2,3 + * + * But after reloading, 0,2,3 will become 0,1,2 again + * */ + val sortingOrder = + repositories[repositoryUuid]?.sortingOrder ?: repositories.data.values.maxOfOrNull( + Repository::sortingOrder + )?.plus(1) ?: 0 + copy( plugins = plugins.setTo(newPlugins), repositories = repositories.adding( repositoryUuid, Repository( status = RepositoryStatus.Success, info = newRepositoryInfo, - uuid = repositoryUuid + uuid = repositoryUuid, + sortingOrder = sortingOrder, ) ) ) @@ -955,18 +993,30 @@ class ExtensionsViewModel2( repository.info.url to repository.uuid } - val newRepositories = repos.associate { info -> - val uuid = oldRepositories[info.url] ?: Uuid.random() - uuid to Repository(info = info, status = RepositoryStatus.Loading, uuid = uuid) - }.toPersistentMap() - - copy( - repositories = repositories.setTo( - newData = repositories.data.puttingAll( - newRepositories + val newRepositories = + persistentMapOf().puttingAll(repos.mapIndexed { index, info -> + val uuid = oldRepositories[info.url] ?: Uuid.random() + uuid to Repository( + info = info, + status = RepositoryStatus.Loading, + uuid = uuid, + sortingOrder = index ) - ) - ) + }) + + val oldLocalRepository = repositories[localUuid] + + /** + * Clear the old repositories if they are deleted from outside the context, + * but include the local repository + * */ + val newRepositoriesWithLocal = if (oldLocalRepository != null) { + newRepositories.putting(localUuid, oldLocalRepository) + } else { + newRepositories + } + + copy(repositories = repositories.setTo(newData = newRepositoriesWithLocal)) } repositoryDispatcher.launch(localUuid) {