From 9fa1540e85bfeea0f4ec1bd37bd67f460d351b0e Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:59:03 +0200 Subject: [PATCH 1/6] fix --- .../utils/downloader/DownloadManager.kt | 13 +++++++++---- .../utils/downloader/DownloadObjects.kt | 4 +++- app/src/main/res/values/strings.xml | 3 +++ 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt index adefedd20..5923a4a7d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt @@ -531,7 +531,7 @@ object VideoDownloadManager { /** This class handles the notifications, as well as the relevant key */ data class DownloadMetaData( private val id: Int?, - private val linkHash : Int, + private val linkHash: Int, var bytesDownloaded: Long = 0, var bytesWritten: Long = 0, @@ -543,7 +543,7 @@ object VideoDownloadManager { private val createNotificationCallback: (CreateNotificationMetadata) -> Unit, private var internalType: DownloadType = DownloadType.IsPending, - val isHLS : Boolean, + val isHLS: Boolean, // how many segments that we have downloaded var hlsProgress: Int = 0, // how many segments that exist @@ -1045,7 +1045,7 @@ object VideoDownloadManager { ) ) - // too short file, treat it as a invalid link + // too short file, treat it as an invalid link if (items.totalLength != null && items.totalLength < minimumSize) { fileStream.closeQuietly() metadata.onDelete() @@ -1241,13 +1241,15 @@ object VideoDownloadManager { metadata.type = DownloadType.IsDone return@withContext DOWNLOAD_SUCCESS } catch (e: IOException) { - // some sort of IO error, this should not happened + // some sort of IO error, this usually happens when storage access is revoked // we just rethrow it + showToast(R.string.error_file_io_toast) logError(e) throw e } catch (t: Throwable) { // some sort of network error, will error logError(t) + showToast(R.string.error_file_unknown_toast) // note that when failing we don't want to delete the file, // only user interaction has that power metadata.type = DownloadType.IsFailed @@ -1818,6 +1820,9 @@ object VideoDownloadManager { isCompleted = true break } else if (!connectionResult.tryNext || index >= item.links.lastIndex) { + if (connectionResult == DOWNLOAD_INVALID_INPUT) { + showToast(R.string.error_file_invalid_toast) + } isFailed = true break } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt index 946e7ecf9..b1436d171 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt @@ -215,7 +215,9 @@ object DownloadObjects { } fun delete(): Boolean { - return file.delete() == true + return runCatching { + file.delete() + }.getOrNull() == true } val resume: Boolean get() = fileLength > 0L diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f050ad993..ca7cfff0f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -83,6 +83,9 @@ Network stream Open local video Error Loading Links + Error occurred when writing to the file. Try setting a new download directory. + Error occurred when downloading the file. Try again. + Download source is invalid. Try another source. Links Reloaded Internal Storage Dub From 5cddd5b364fcd5f1988914d46446bf2814ec3e07 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:04:39 +0000 Subject: [PATCH 2/6] Update theintrodb to v4 Closes #3264 --- .../cloudstream3/utils/videoskip/TheIntroDBSkip.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt index 3fd050f1a..642a3bae8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt @@ -32,10 +32,10 @@ class TheIntroDBSkip : SkipAPI() { ?: return null val url = if (data.isMovie()) { - "$mainUrl/v2/media?$idSuffix" + "$mainUrl/v4/media?$idSuffix" } else { val season = episode.season ?: return null - "$mainUrl/v2/media?$idSuffix&season=$season&episode=${episode.episode}" + "$mainUrl/v4/media?$idSuffix&season=$season&episode=${episode.episode}" } val root = app.get(url).parsed() return arrayOf( @@ -43,7 +43,7 @@ class TheIntroDBSkip : SkipAPI() { root.credits to SkipType.Credits, root.recap to SkipType.Recap, root.preview to SkipType.Preview - ).map { (list, type) -> + ).flatMap { (list, type) -> list.map { stamp -> SkipStamp( type, @@ -51,12 +51,11 @@ class TheIntroDBSkip : SkipAPI() { stamp.endMs ?: episodeDurationMs ) } - }.flatten() + } } @Serializable data class Root( - @JsonProperty("tmdb_id") @SerialName("tmdb_id") val tmdbId: Long, @JsonProperty("type") @SerialName("type") val type: String, @JsonProperty("intro") @SerialName("intro") val intro: List = emptyList(), @JsonProperty("recap") @SerialName("recap") val recap: List = emptyList(), From 4c4c6796ff6a972e8f1339675b349e8c53194577 Mon Sep 17 00:00:00 2001 From: Void <254280517+voidece@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:37:35 +0530 Subject: [PATCH 3/6] fix: skip notification permission request while biometric lock is enabled (#3256) --- app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt index 4ce09bd44..8daf168c1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt @@ -52,6 +52,7 @@ import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.updateTv import com.lagradost.cloudstream3.ui.settings.extensions.PluginAdapter import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod @@ -266,7 +267,9 @@ object CommonActivity { } // Ask for notification permissions on Android 13 + // Not while biometric locked, the dialog cancels the biometric prompt if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + !isAuthEnabled(componentActivity) && ContextCompat.checkSelfPermission( componentActivity, Manifest.permission.POST_NOTIFICATIONS From eb962b4fb7b1387b8de42ec58000cee0a01fe741 Mon Sep 17 00:00:00 2001 From: Osten <11805592+LagradOst@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:45:11 +0200 Subject: [PATCH 4/6] Fixes some minor issue with the +10 button (#3266) --- .../lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt index 1c7086d12..d3340cb57 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt @@ -887,7 +887,6 @@ class PlayerGestureHelper(private val playerView: PlayerView) { val wasShowing = playerView.callbacks?.isUIShowing() ?: false // Only expose the parent chain when controls are currently hidden. - val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true if (!wasShowing) { playerView.playerCenterMenu?.isGone = false @@ -906,7 +905,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { resetRewindText() // Restore parent chain only if we changed it and controls are still hidden. if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) { - playerView.playerCenterMenu?.isGone = prevCenterMenuGone + playerView.playerCenterMenu?.isGone = true playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible rewHolder.alpha = 0f } @@ -927,7 +926,6 @@ class PlayerGestureHelper(private val playerView: PlayerView) { val ffwdText = playerView.exoFfwdText val wasShowing = playerView.callbacks?.isUIShowing() ?: false - val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true if (!wasShowing) { playerView.playerCenterMenu?.isGone = false @@ -945,7 +943,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { ffwdText?.post { resetFastForwardText() if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) { - playerView.playerCenterMenu?.isGone = prevCenterMenuGone + playerView.playerCenterMenu?.isGone = true playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible ffwdHolder.alpha = 0f } From dc9d1f0f9ec69c5fdd76d8c6e5bb47de5572fee8 Mon Sep 17 00:00:00 2001 From: Osten <11805592+LagradOst@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:55:09 +0200 Subject: [PATCH 5/6] Update extentions screen to Compose (#3258) --- .../lagradost/cloudstream3/MainActivity.kt | 4 +- .../cloudstream3/plugins/PluginManager.kt | 2 +- .../cloudstream3/plugins/RepositoryManager.kt | 17 +- .../cloudstream3/ui/account/AccountScreen.kt | 6 +- .../cloudstream3/ui/settings/ApkUpdater.kt | 52 + .../ui/settings/SettingsFragmentScreen.kt | 46 +- .../ui/settings/SettingsUpdatesScreen.kt | 4 +- .../extensions/AndroidRepositoryManager.kt | 282 +++ .../settings/extensions/ExtensionsFragment.kt | 2 +- .../extensions/ExtensionsFragment2.kt | 6 + .../settings/extensions/ExtensionsScreen.kt | 1922 +++++++++++++++++ .../extensions/ExtensionsViewModel.kt | 15 +- .../cloudstream3/utils/AppContextUtils.kt | 3 +- .../res/drawable/arrow_circle_down_24px.xml | 10 + app/src/main/res/drawable/codeberg.xml | 51 + .../main/res/drawable/delete_sweep_24px.xml | 10 + .../res/drawable/disabled_by_default_24px.xml | 10 + app/src/main/res/drawable/error_24px.xml | 10 + .../main/res/drawable/extension_off_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 +- app/src/main/res/values/strings.xml | 1 + .../cloudstream3/mvvm/ArchComponentExt.kt | 2 +- .../cloudstream4/ClipboardHelper.android.kt | 23 + .../lagradost/cloudstream4/ClipboardHelper.kt | 6 + .../cloudstream4/DevicePreferenceStore.kt | 1 + .../lagradost/cloudstream4/compose/Button.kt | 26 + .../lagradost/cloudstream4/compose/Chip.kt | 15 +- .../lagradost/cloudstream4/compose/Colors.kt | 18 + .../lagradost/cloudstream4/compose/Dialog.kt | 8 +- .../cloudstream4/compose/LazyList.kt | 26 + .../cloudstream4/compose/TextField.kt | 5 + .../cloudstream4/state/ImmutableState.kt | 72 +- .../viewmodels/ExtensionsViewModel2.kt | 1077 +++++++++ .../kotlin/com/mihon/material/AppBar.kt | 6 +- .../settings/widget/BasePreferenceWidget.kt | 12 +- .../cloudstream4/ClipboardHelper.jvm.kt | 26 + 39 files changed, 3774 insertions(+), 39 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/arrow_circle_down_24px.xml create mode 100644 app/src/main/res/drawable/codeberg.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/error_24px.xml create mode 100644 app/src/main/res/drawable/extension_off_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/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 00e18158b..c13b2600c 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/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index 6054bbfaf..7ea736416 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 3879ddca4..910d84735 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) @@ -247,9 +247,16 @@ 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 + 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/account/AccountScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountScreen.kt index 3d80f3742..056d3224e 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/ApkUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt index 89280f2b0..34fea2938 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/SettingsFragmentScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt index c906b0982..305b8ec28 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 @@ -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,25 +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/SettingsUpdatesScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt index 3336b6127..3ce3ad6fb 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/AndroidRepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt new file mode 100644 index 000000000..3fbc67a74 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt @@ -0,0 +1,282 @@ +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 +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.cloudstream3.ui.settings.ApkUpdater.isFileDigest +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.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.uuid.Uuid + +@InternalAPI +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 = 15, + 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 = 15, + cacheUnit = TimeUnit.MINUTES + ).parsed>() + }.flatMap { plugins -> + plugins.map { plugin -> + val file = getPluginPath( + context = activity, + internalName = plugin.internalName, + repositoryUrl = url + ) + val language = plugin.language?.trim() + 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 = if (language.isNullOrBlank()) { + null + } else { + language + }, + description = plugin.description, + repositoryUrl = plugin.repositoryUrl + ), + 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 + ) + + /** + * 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 -> + 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 { + 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) { + 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 107227497..4d6567856 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 000000000..7041887df --- /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 000000000..3725a99da --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt @@ -0,0 +1,1922 @@ +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 +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.combinedClickable +import androidx.compose.foundation.focusGroup +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 +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.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 +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 +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 +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.material3.TextFieldDefaults +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.focus.focusRestorer +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 +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 +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 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 +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.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 +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 +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.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 +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 +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.LocalPreferenceMinHeight +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 + +@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()), + /*updateAllPluginsDialog = PluginsUpdatingDialog( + total = 5, + downloaded = 4, + failed = 0 + ),*/ + 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, + sortingOrder = 0, + ), + uuid2 to Repository( + uuid = uuid2, + info = RepositoryInfo( + name = "codeberg test", + url = "https://codeberg.org/", + iconUrl = null + ), + status = RepositoryStatus.Loading, + sortingOrder = 1, + ), + uuid3 to Repository( + uuid = uuid3, + info = RepositoryInfo( + name = "parsebin test", + url = "https://pastebin.com/", + iconUrl = null + ), + status = RepositoryStatus.Loading, + sortingOrder = 2, + ) + ), + sortedBy = SortByName { it.info.name } + ) + ), + onAction = {} + ) + } +} + + +object ExtensionsScreen : Screen { + @Composable + override fun Content() { + val settings = rememberAppSettings() + val viewModel = + viewModel { + ExtensionsViewModel2( + manager = AndroidRepositoryManager, + settings = settings + ) + } + LaunchedEffect(Unit) { // TODO fix this to only do if necessary + viewModel.onAction(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 (topFocusRequester, bottomFocusRequester) = 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(topFocusRequester) + .focusProperties { + down = bottomFocusRequester + }, + title = if (state.pluginFilter.repository != null) { + state.repositories[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 = { + TopActionRow(state = state, onAction = onAction) + }, + 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, + focusRequester = bottomFocusRequester, + topBarFocusRequester = topFocusRequester + ) + }, + ) + } + + @Composable + fun TopActionRow(state: ExtensionsState, onAction: (ExtensionsAction) -> Unit) { + val scope = rememberCoroutineScope() + val clipboard = LocalClipboard.current + + var extraDialog by remember { mutableStateOf(false) } + if (!state.searchPlugins) { + if (isLayout(TV)) { + WhiteIconButton( + 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) + ) + } + } + + WhiteIconButton( + 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 + openInstalledPlugins() + }) + DropdownMenuItem(text = { + Text(text = stringResource(R.string.update_plugins_manually)) + }, onClick = { + 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) } + var deleteDialog by remember { mutableStateOf(false) } + val derivedRepositoryState = state.derivedRepositoryState + + val activeRepository = state.pluginFilter.repository + if (state.searchPlugins && activeRepository != null && derivedRepositoryState.notDownloaded != derivedRepositoryState.total) { + WhiteIconButton(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()) { + WhiteIconButton(onClick = { + languageDialog = true + }) { + Icon( + tint = MaterialTheme.colorScheme.onBackground, + painter = painterResource(R.drawable.plugin_lang), + contentDescription = stringResource(R.string.extension_language) + ) + } + } + + 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 -> + 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( + 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), + 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), + R.string.others to persistentListOf( + TvType.CustomMedia, + TvType.Video, + TvType.Others + ), + ) + + 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( + contentPadding: PaddingValues, + state: ExtensionsState, + onAction: (ExtensionsAction) -> Unit, + highLightState: MutableState, + fabExpanded: MutableState, + topBarFocusRequester: FocusRequester, + focusRequester: FocusRequester, + ) { + AddDialog(dialog = state.addDialog, onAction = onAction) + UpdateDialog(dialog = state.updateAllPluginsDialog, onAction = onAction) + + val lazyRepoState = rememberLazyListState() + val textState = rememberTextFieldState() + val lazyState = if (state.searchPlugins) { + rememberLazyListState() + } else { + lazyRepoState + } + + BackHandler(enabled = state.searchPlugins) { + textState.clearText() + onAction(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 list = if (state.searchPlugins) { + state.plugins.sorted + } else { + state.repositories.sorted + } + val index = list.indexOfFirst { uuid -> uuid == highLight } + if (index != -1) { + lazyRepoState.animateScrollToItem( + /** We have some extra items here */ + index + 2 + ) + } + 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(Search(textState.text.toString())) + } + + LazyColumn( + state = lazyState, + contentPadding = contentPadding, + modifier = Modifier + .fillMaxSize() + .focusGroup() + .focusRequester(focusRequester) + .focusProperties { + up = topBarFocusRequester + }, + ) { + 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[uuid]!! + val isHighlighted = uuid == highLight + CompositionLocalProvider( + LocalPreferenceHighlighted provides isHighlighted, + ) { + Plugin( + plugin = plugin, + onAction = onAction, + modifier = Modifier + .animateItem() + .graphicsLayer { alpha = itemAlpha.value }, + onClick = if (state.pluginFilter.repository != null) { + null + } else { + { + onAction(SelectPlugin(plugin.uuid)) + highLightState.value = plugin.uuid + } + } + ) + } + } + + val repository = state.pluginFilter.repository + val status = state.repositories[repository]?.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 { + if (state.repositories.sorted.isEmpty()) { + item { + Text( + text = stringResource(R.string.blank_repo_message), + modifier = Modifier + .secondaryItemAlpha() + .fillMaxWidth() + .padding(20.dp), + 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 + ) + } + } + } 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 } + ) + } + } + } + } + } + + @Composable + fun FilterRow( + filter: FilterPlugin, + 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, + onAction = onAction + ) + } + } + } + } + + @Composable + fun FilterSelection( + filter: FilterPlugin, + 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), + ) { + onAction( + FilterByTypes( + if (selected) { + filter.tvTypes.removingAll(types) + } else { + filter.tvTypes.addingAll(types) + } + ) + ) + } + } + + @Composable + fun ErrorDialog( + error: Throwable, + tag: String, + confirmButton: @Composable ((Modifier) -> Unit), + onDismissRequest: () -> Unit + ) { + val message = error.toString() + val stackTrace = 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") + + 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(Modifier) + }, + dismissButton = { + BlackButton( + text = stringResource(R.string.dismiss), + onClick = onDismissRequest, + modifier = Modifier + .focusRequester(dismissFocusRequester) + ) + }, + text = { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .focusRequester(logFocusRequester) + .focusProperties { + down = dismissFocusRequester + }) { + Row(modifier = Modifier.fillMaxWidth()) { + LogBoxWhite(LogLevel.Error.identifier) + LogBoxBlack(tag) + } + LogText( + level = LogLevel.Error, + message = message, + onClick = { + scope.launch { + clipboard.setPlainText("Error", message) + } + }, + ) + if (stackTrace.isNotBlank()) { + LogText( + level = LogLevel.Error, + message = stackTrace, + onClick = { + scope.launch { + clipboard.setPlainText("Stacktrace", stackTrace) + } + }) + } + } + }, + title = { + Text(text = stringResource(R.string.error)) + }) + } + + @Composable + fun Plugin( + plugin: Plugin, + onAction: (ExtensionsAction) -> Unit, + modifier: Modifier, + onClick: (() -> Unit)?, + ) { + val description = plugin.info.description + var extendedDialog by remember { mutableStateOf(false) } + val isTv = isLayout(TV) + 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 = { + 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( + modifier = Modifier.focusRequester(dismissFocusRequester), + 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 = if (isPluginDisabledByAuthor) { + painterResource(R.drawable.extension_off_24px) + } else { + painterResource(R.drawable.extension_24px) + } + ) + Text(text = plugin.info.name) + } + }) + } + + val (backgroundFocusRequester, buttonsFocusRequester) = + remember { FocusRequester.createRefs() } + + + BasePreferenceWidget( + modifier = modifier + .focusRequester(backgroundFocusRequester) + .focusProperties { + end = buttonsFocusRequester + }, + title = + if (isPluginDisabledByAuthor) { + stringResource(R.string.single_plugin_disabled, plugin.info.name) + } else { + plugin.info.name + }, + subcomponent = + { + 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 = + if (isPluginDisabledByAuthor) { + painterResource(R.drawable.extension_off_24px) + } else { + painterResource(R.drawable.extension_24px) + } + ) + }, + onClick = onClick ?: { + extendedDialog = true + }, + onLongClick = { + 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) { + ErrorDialog( + error = errorCause, + tag = stringResource( + R.string.plugin_load_fail, + plugin.info.name + ), + confirmButton = { modifier -> + if (plugin.downloadLocation != null) { + WhiteButton( + modifier = modifier, + text = stringResource(R.string.delete_plugin), + onClick = { + errorDialog = null + onAction(DeletePlugin(plugin.uuid)) + }) + } else { + WhiteButton( + modifier = modifier, + text = stringResource(R.string.reload_error), + onClick = { + errorDialog = null + onAction(DownloadPlugin(plugin.uuid)) + }) + } + }, + onDismissRequest = { + errorDialog = null + } + ) + } + + val isPluginDisabledByAuthor = plugin.info.status == PROVIDER_STATUS_DOWN + val isDownloadDisabled = isPluginDisabledByAuthor || plugin.source == 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 + WhiteIconButton( + 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 + ) + } + } + + WhiteIconButton( + 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 if (isDownloadDisabled) { + /** The author has disabled the plugin, so do nothing here */ + } else { + onAction(DownloadPlugin(plugin.uuid)) + } + } + } + }, + ) { + 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 -> { + Icon( + painter = painterResource(R.drawable.warning_24px), + contentDescription = stringResource(R.string.error) + ) + } + + PluginStatus.Idle -> { + if (plugin.downloadLocation != null) { + Icon( + 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.arrow_circle_down_24px), + contentDescription = stringResource(R.string.download) + ) + } + } + } + } + } + } + + @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(CancelPluginsUpdate) + }, + confirmButton = { + }, + dismissButton = { + BlackButton( + text = if (finished) { + stringResource(R.string.dismiss) + } else { + stringResource(R.string.cancel) + }, onClick = { + onAction(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) { + return + } + + val nextFocusRequester = remember { FocusRequester() } + + AlertDialog( + containerColor = MaterialTheme.colorScheme.background, + onDismissRequest = { + onAction(SetRepositoryDialog(null)) + }, + 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 = { + onAction(SetRepositoryDialog(null)) + }) + }, + text = { + Column { + BlackTextField( + 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( + SetRepositoryDialog( + dialog.copy( + url = newValue, + error = null + ) + ) + ) + } + Crossfade(dialog.loading) { loading -> + if (loading) { + Column { + Spacer(modifier = Modifier.height(MaterialTheme.padding.medium)) + LinearProgressIndicator( + modifier = Modifier.width(TextFieldDefaults.MinWidth), + 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() + .width(TextFieldDefaults.MinWidth), + 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 { + 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( + SetRepositoryDialog( + AddRepositoryDialog(url = url) + ) + ) + } + + @Composable + fun BottomBar( + state: ExtensionsState, + onAction: (ExtensionsAction) -> Unit + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable( + enabled = isLayout(PHONE), + onClick = { + openInstalledPlugins() + }) + .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.001f + ) + ) + ) + Box( + modifier = Modifier + .height(size) + .background(color = MaterialTheme.colorScheme.primary) + .weight( + maxOf( + state.plugins.state.downloadedAndDisabled.toFloat(), + 0.001f + ) + ) + ) + Box( + modifier = Modifier + .height(size) + .background(color = MaterialTheme.colorScheme.onSurfaceVariant) + .weight(maxOf(state.plugins.state.notDownloaded.toFloat(), 0.02f)) + ) + } + Spacer(modifier = Modifier.height(MaterialTheme.padding.small)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.padding.extraSmall + ) + ) { + DownloadBox( + text = stringResource( + R.string.plugins_downloaded, + state.plugins.state.downloadedAndEnabled + ), color = MaterialTheme.colorScheme.onBackground + ) + DownloadBox( + text = stringResource( + R.string.plugins_disabled, + state.plugins.state.downloadedAndDisabled + ), color = MaterialTheme.colorScheme.primary + ) + DownloadBox( + text = stringResource( + R.string.plugins_not_downloaded, + state.plugins.state.notDownloaded + ), color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + @Composable + fun Repository( + modifier: Modifier, + repository: Repository, + derivedState: DerivedRepositoryState?, + onAction: (ExtensionsAction) -> Unit + ) { + var deleteDialog by remember { mutableStateOf(false) } + + if (deleteDialog) { + ActionDialog( + icon = painterResource(R.drawable.delete_24px), + title = stringResource(R.string.delete_repository), + text = stringResource(R.string.delete_repository_plugins), + dismiss = { + deleteDialog = false + }, + confirm = { + onAction(DeleteRepository(repository.uuid)) + deleteDialog = false + }, + 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) + + val (backgroundFocusRequester, buttonFocusRequester) = remember { FocusRequester.createRefs() } + + BasePreferenceWidget( + modifier = modifier + .focusRequester(backgroundFocusRequester) + .focusProperties { + end = buttonFocusRequester + }, + title = repository.info.name, + subcomponent = { + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), + modifier = Modifier + .padding(horizontal = PrefsHorizontalPadding) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.padding.extraSmall + ), + modifier = Modifier.defaultMinSize(minHeight = 14.dp) + ) { + if (derivedState != null) { + Text( + text = stringResource( + R.string.plugins_downloaded, + derivedState.downloadedAndEnabled + ) + /** + 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) { + Text( + text = stringResource( + R.string.plugins_disabled, + derivedState.downloadedAndDisabled + ), + style = MaterialTheme.typography.labelMedium + ) + } + } 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( + 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(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) } + WhiteIconButton( + onClick = { + errorDialog = true + }, + modifier = Modifier + .focusRequester(buttonFocusRequester) + .focusProperties { + start = backgroundFocusRequester + }) + { + Icon( + painter = painterResource(R.drawable.warning_24px), + contentDescription = null + ) + } + if (errorDialog) { + ErrorDialog( + error = reason.cause, + tag = stringResource(R.string.error_invalid_data), + confirmButton = { modifier -> + WhiteButton( + text = stringResource(R.string.reload_error), + modifier = modifier + ) { + onAction(ReloadRepository(repository.uuid)) + errorDialog = false + } + WhiteButton( + text = stringResource(R.string.delete_repository), + modifier = modifier + ) { + deleteDialog = true + } + }, + onDismissRequest = { + errorDialog = false + }) + } + } + + RepositoryStatus.Loading, RepositoryStatus.Success -> { + WhiteIconButton( + onClick = { + deleteDialog = true + }, + modifier = Modifier + .focusRequester(buttonFocusRequester) + .focusProperties { + start = backgroundFocusRequester + } + ) { + 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 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 { + 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 d30962598..2fe63a59b 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 e0661721a..2e3829120 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/arrow_circle_down_24px.xml b/app/src/main/res/drawable/arrow_circle_down_24px.xml new file mode 100644 index 000000000..03edb80d2 --- /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/codeberg.xml b/app/src/main/res/drawable/codeberg.xml new file mode 100644 index 000000000..9ac01ce95 --- /dev/null +++ b/app/src/main/res/drawable/codeberg.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + 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 000000000..220f68509 --- /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 000000000..bc935c293 --- /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/error_24px.xml b/app/src/main/res/drawable/error_24px.xml new file mode 100644 index 000000000..31feeeca1 --- /dev/null +++ b/app/src/main/res/drawable/error_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 000000000..6386d44ee --- /dev/null +++ b/app/src/main/res/drawable/extension_off_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 000000000..9a2d5cd97 --- /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 000000000..f2e719d3a --- /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 000000000..ab1606dc9 --- /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 d60abc203..b29fc5127 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -196,7 +196,7 @@ %d downloads queued Live + Installed plugins diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt index 73de880db..af2f7ab93 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt @@ -128,7 +128,7 @@ fun Throwable.getAllMessages(): String { } fun Throwable.getStackTracePretty(showMessage: Boolean = true): String { - val prefix = if (showMessage) this.message?.let { "\n$it" } ?: "" else "" + val prefix = if (showMessage) this.message?.let { "$it\n" } ?: "" else "" return prefix + this.stackTraceToString() .lines() .mapNotNull { line -> 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 000000000..016019918 --- /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 000000000..4b432877e --- /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/DevicePreferenceStore.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt index 3b2ae59c4..cad05c422 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) } 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 348a90422..dcf8511d3 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 WhiteIconButton( + 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 9dbc7561e..1238ed649 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, ) { @@ -31,16 +32,24 @@ 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, ), - modifier = Modifier.onFocusChanged { newFocus -> + modifier = modifier.onFocusChanged { newFocus -> 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 c15c66b23..f65a1a625 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/lagradost/cloudstream4/compose/Dialog.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Dialog.kt index a944889a5..357cc26d0 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 000000000..4a81bb9d0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/LazyList.kt @@ -0,0 +1,26 @@ +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() + } +} +@Suppress("FrequentlyChangingValue") +@Composable +fun LazyListState.IsScrolling(up : Runnable, down : Runnable) { + LaunchedEffect(firstVisibleItemIndex) { + if(lastScrolledBackward) up.run() + } + LaunchedEffect(firstVisibleItemIndex) { + 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 81f421671..08e21a4c7 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,8 @@ fun BlackTextField( placeHolder : String, modifier: Modifier = Modifier, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, onValueChange: (String) -> Unit, ) { TextField( @@ -19,9 +22,11 @@ fun BlackTextField( value = value, onValueChange = onValueChange, keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, 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 c20f2a16a..e506fbabb 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 Levenshtein.partialRatio( query, - if (ignoreCase) transformed.lowercase() else transformed + matchAgainst ) > ratio } @@ -68,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.partialRatio( + query, + matchAgainst + ) + } +} + /** Default sorting by name (Ascending A->Z), use `rev()` if Z->A is desired */ @Immutable data class SortByName( @@ -122,6 +147,8 @@ fun PersistentMap.edit(key: K, updater: V.() -> V): PersistentMap, Value> { /** State = {} */ @@ -137,6 +164,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>( @@ -149,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)) @@ -157,6 +190,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, @@ -273,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) { @@ -294,6 +337,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, @@ -341,8 +406,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 new file mode 100644 index 000000000..41c58b212 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt @@ -0,0 +1,1077 @@ +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.AppSettings +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.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.state.sortByInt +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.puttingAll +import kotlinx.collections.immutable.toPersistentMap +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 +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, + /** + * 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 + +@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?, + /** + * This is different from source.repositoryUrl, + * as this may be a different url than the repo we got this from */ + val repositoryUrl: 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 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, + 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: 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 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) + }) && + /** Filter by language */ + !(plugin.info.language?.let(removeLanguages::contains) + ?: removeLanguages.contains("")) && + /** Filter by query */ + query(plugin) +} + +@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, + /** + * 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 { + 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 + 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 + } + 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, + 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 + } + 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, + 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 = sortByInt(Repository::sortingOrder) + ), + 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, + 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 = "", + val loading: Boolean = false, + val error: Throwable? = null, +) + +sealed class ExtensionsEffect { + data class RepositoryAdded(val repository: RepositoryInfo, val uuid: Uuid) : ExtensionsEffect() +} + +@InternalAPI +interface RepositoryManager { + /** Returns an ***ordered*** list of installed repositories */ + 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 DeletePluginsInRepository(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 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() + object UpdateAllPlugins : ExtensionsAction() + object DeleteAllRepositories : ExtensionsAction() + object CancelPluginsUpdate : ExtensionsAction() + data class CancelPluginDownloader(val uuid: Uuid) : 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 settings: AppSettings, + 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, + sortingOrder = -1, + ) + 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) + copy( + searchPlugins = false, + pluginFilter = newPluginFilter, + plugins = plugins.filteringBy(newPluginFilter), + ) + } + } + + ExtensionsAction.Init -> { + viewModelScope.launch { + init() + } + } + + ExtensionsAction.CancelPluginsUpdate -> { + viewModelScope.launch { + updatePluginsDispatcher.launch { + updateState { + copy(updateAllPluginsDialog = null) + } + } + } + } + + 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) + } + } + + is ExtensionsAction.ReloadRepository -> { + val uuid = action.uuid + val current = state.value.repositories[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) + } + } + + 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 { + deleteRepository(repository) + } + } + + is ExtensionsAction.SelectRepository -> { + updateState { + val newPluginFilter = pluginFilter.copy(repository = action.uuid) + copy( + plugins = plugins.filteringBy(newPluginFilter), + pluginFilter = newPluginFilter, + searchPlugins = true, + ) + } + } + + is ExtensionsAction.SelectPlugin -> { + updateState { + val plugin = plugins[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() + copy( + plugins = plugins.searchBy( + newSorting = if (activeQuery) { + PluginSortBy(query = action.query.lowercase()) + } else { + SortByName { it.info.name } + }, + newFilter = newPluginFilter + ), + pluginFilter = newPluginFilter, + searchPlugins = pluginFilter.repository != null || activeQuery, + ) + } + } + + is ExtensionsAction.DeletePlugin -> { + val plugin = state.value.plugins[action.uuid] ?: return + viewModelScope.launch { + deletePlugin(plugin) + } + } + + is ExtensionsAction.DownloadPlugin -> { + val plugin = state.value.plugins[action.uuid] ?: return + viewModelScope.launch { + downloadDispatcher.launch(plugin.uuid) { + downloadPlugin(plugin) + } + } + } + + is ExtensionsAction.DownloadRepositoryWithDialog -> { + viewModelScope.launch { + downloadRepositoryWithDialog(action) + } + } + + is ExtensionsAction.FilterByTypes -> { + updateState { + val newPluginFilter = pluginFilter.copy(tvTypes = action.types) + copy( + plugins = plugins.filteringBy(newPluginFilter), + pluginFilter = newPluginFilter + ) + } + } + } + } + + private suspend fun onRepositoryAdded( + repositoryUuid: Uuid, + newRepositoryInfo: RepositoryInfo, + pluginsFetched: List + ) { + manager.updateRepositoryInfo(newRepositoryInfo) + 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 + } + + /** + * 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 { + plugin + } + } + + val newPlugins = plugins.data.mutate { edit -> + for ((uuid, plugin) in plugins.data) { + if (plugin.repository == repositoryUuid) { + edit.remove(uuid) + } + } + 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, + sortingOrder = sortingOrder, + ) + ) + ) + } + } + + 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 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 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.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( + repositoryUuid = uuid, + newRepositoryInfo = newRepositoryInfo, + pluginsFetched = pluginsFetched + ) + updateState { + 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) { + 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( + repositoryUuid = uuid, + newRepositoryInfo = newRepositoryInfo, + pluginsFetched = pluginsFetched + ) + } + } + } + + private suspend fun init() { + val repos = manager.getRepositories() + + updateState { + val oldRepositories = repositories.data.values.associate { repository -> + repository.info.url to repository.uuid + } + + 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) { + val pluginsFetched = manager.getLocalPlugins(localUuid) + updateState { + 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] ?: plugin.uuid + 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) + } + ) + } + } + + val repositories = state.value.repositories.data + for ((uuid, repository) in repositories) { + loadRepository(uuid, repository.info) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt b/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt index e54441595..0af8eaf40 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,6 +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.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 @@ -104,7 +104,7 @@ fun AppBar( windowInsets = WindowInsets(0, 0, 0, 0), navigationIcon = { if (isActionMode) { - IconButton(onClick = onCancelActionMode) { + WhiteIconButton(onClick = onCancelActionMode) { Icon( painter = painterResource(Res.drawable.close), contentDescription = stringResource(Res.string.cancel), @@ -112,7 +112,7 @@ fun AppBar( } } else { navigateUp?.let { - IconButton(onClick = it) { + WhiteIconButton(onClick = it) { UpIcon(navigationIcon = navigationIcon) } } 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 e103a1440..467e0b3e5 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 000000000..5e5e3d240 --- /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 ad44a64f0cd660433ffc84994369c13336ae4015 Mon Sep 17 00:00:00 2001 From: Osten <11805592+LagradOst@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:51:31 +0200 Subject: [PATCH 6/6] Fixed backup frequency (#3271) --- .../ui/settings/SettingsUpdatesScreen.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 3ce3ad6fb..cc867ae25 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 @@ -13,17 +13,15 @@ import androidx.compose.ui.res.integerArrayResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource -import com.lagradost.cloudstream3.AutoDownloadMode import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.MainActivityScreen import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.services.BackupWorkManager import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BackupUtils.restorePrompt -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.InAppUpdater.installPreReleaseIfNeeded import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream4.AppSettings @@ -147,7 +145,14 @@ object SettingsUpdatesScreen : SearchableSettings { entries = integerArrayResource(R.array.periodic_work_values).zip( stringArrayResource(R.array.periodic_work_names) ).toMap(), - preference = settings.backup.frequency + preference = settings.backup.frequency, + onValueChanged = { value -> + BackupWorkManager.enqueuePeriodicWork( + CloudStreamApp.context, + value.toLong() + ) + return@ListPreference true + } ), Preference.PreferenceItem.TextPreference( title = stringResource(R.string.backup_path_title),