diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt index 8daf168c1..4ce09bd44 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt @@ -52,7 +52,6 @@ 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 @@ -267,9 +266,7 @@ 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 diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index c13b2600c..00e18158b 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 7ea736416..6054bbfaf 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 * */ - internal suspend fun loadPlugin(context: Context, file: File, data: PluginData): Boolean { + private 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 910d84735..3879ddca4 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 = 15, cacheUnit = TimeUnit.MINUTES) + app.get(convertRawGitUrl(url), cacheTime = 5, 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 = 15, cacheUnit = TimeUnit.MINUTES) + app.get(convertRawGitUrl(pluginUrls), cacheTime = 5, cacheUnit = TimeUnit.MINUTES) .parsed>().toList() } catch (t: Throwable) { logError(t) @@ -247,16 +247,9 @@ object RepositoryManager { private val repoLock = Mutex() suspend fun addRepository(repository: RepositoryData) { repoLock.withLock { - // 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) + val currentRepos = getRepositories() + // No duplicates + setKey(REPOSITORIES_KEY, (currentRepos + repository).distinctBy { it.url }) } } 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 056d3224e..3d80f3742 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) = remember { FocusRequester.createRefs() } + val (edit, accounts) = FocusRequester.createRefs() IconButton( onClick = { @@ -318,7 +318,7 @@ object AccountScreen { ) } - val focusRequester = remember { FocusRequester() } + val (focusRequester) = FocusRequester.createRefs() 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 = remember { FocusRequester() } + val (focusRequester) = FocusRequester.createRefs() LaunchedEffect(Unit) { if (selected) { focusRequester.requestFocus() 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 d3340cb57..1c7086d12 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,6 +887,7 @@ 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 @@ -905,7 +906,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 = true + playerView.playerCenterMenu?.isGone = prevCenterMenuGone playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible rewHolder.alpha = 0f } @@ -926,6 +927,7 @@ 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 @@ -943,7 +945,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) { ffwdText?.post { resetFastForwardText() if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) { - playerView.playerCenterMenu?.isGone = true + playerView.playerCenterMenu?.isGone = prevCenterMenuGone playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible ffwdHolder.alpha = 0f } 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 34fea2938..89280f2b0 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,58 +189,6 @@ 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 305b8ec28..c906b0982 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,7 +17,6 @@ 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 @@ -31,6 +30,7 @@ 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,11 +47,6 @@ 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 @@ -293,54 +288,25 @@ 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, - /** - * 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 + // This option is bugged on compose, making it unable to open at all + // showKeyboardOnFocus = false ), - lineLimits = TextFieldLineLimits.SingleLine, modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp - 12.dp * focusProgress) - .focusOutline(enabled = isTv, CircleShape) + .focusOutline(enabled = isLayout(TV), CircleShape) .onFocusChanged { newFocus -> hasFocus = newFocus.hasFocus - /** 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 - } - }, + }.focusRequester(focusRequester), 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 cc867ae25..3336b6127 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,15 +13,17 @@ 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.services.BackupWorkManager +import com.lagradost.cloudstream3.plugins.PluginManager 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 @@ -145,14 +147,7 @@ object SettingsUpdatesScreen : SearchableSettings { entries = integerArrayResource(R.array.periodic_work_values).zip( stringArrayResource(R.array.periodic_work_names) ).toMap(), - preference = settings.backup.frequency, - onValueChanged = { value -> - BackupWorkManager.enqueuePeriodicWork( - CloudStreamApp.context, - value.toLong() - ) - return@ListPreference true - } + preference = settings.backup.frequency ), Preference.PreferenceItem.TextPreference( title = stringResource(R.string.backup_path_title), @@ -183,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), @@ -202,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 deleted file mode 100644 index 3fbc67a74..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt +++ /dev/null @@ -1,282 +0,0 @@ -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 4d6567856..107227497 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, repository.description) + val newRepo = RepositoryData(repository.iconUrl, fixedName, url) RepositoryManager.addRepository(newRepo) extensionViewModel.loadStats() extensionViewModel.loadRepositories() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment2.kt deleted file mode 100644 index 7041887df..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment2.kt +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 3725a99da..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt +++ /dev/null @@ -1,1922 +0,0 @@ -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 2fe63a59b..d30962598 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,21 +23,8 @@ 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( - iconUrl = null, - name = name, - url = url, - description = null - ) - - constructor(iconUrl: String?, name: String, url: String) : this( - iconUrl = iconUrl, - name = name, - url = url, - description = null - ) + constructor(name: String, url: String): this(null, name, url) } 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 2e3829120..e0661721a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -527,8 +527,7 @@ object AppContextUtils { val data = RepositoryData( repo.iconUrl ?: "", repo.name, - url, - repo.description + url ) RepositoryManager.addRepository(data) main { 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/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt index 642a3bae8..3fd050f1a 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/v4/media?$idSuffix" + "$mainUrl/v2/media?$idSuffix" } else { val season = episode.season ?: return null - "$mainUrl/v4/media?$idSuffix&season=$season&episode=${episode.episode}" + "$mainUrl/v2/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 - ).flatMap { (list, type) -> + ).map { (list, type) -> list.map { stamp -> SkipStamp( type, @@ -51,11 +51,12 @@ 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(), diff --git a/app/src/main/res/drawable/arrow_circle_down_24px.xml b/app/src/main/res/drawable/arrow_circle_down_24px.xml deleted file mode 100644 index 03edb80d2..000000000 --- a/app/src/main/res/drawable/arrow_circle_down_24px.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/codeberg.xml b/app/src/main/res/drawable/codeberg.xml deleted file mode 100644 index 9ac01ce95..000000000 --- a/app/src/main/res/drawable/codeberg.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/delete_sweep_24px.xml b/app/src/main/res/drawable/delete_sweep_24px.xml deleted file mode 100644 index 220f68509..000000000 --- a/app/src/main/res/drawable/delete_sweep_24px.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/disabled_by_default_24px.xml b/app/src/main/res/drawable/disabled_by_default_24px.xml deleted file mode 100644 index bc935c293..000000000 --- a/app/src/main/res/drawable/disabled_by_default_24px.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/error_24px.xml b/app/src/main/res/drawable/error_24px.xml deleted file mode 100644 index 31feeeca1..000000000 --- a/app/src/main/res/drawable/error_24px.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/extension_off_24px.xml b/app/src/main/res/drawable/extension_off_24px.xml deleted file mode 100644 index 6386d44ee..000000000 --- a/app/src/main/res/drawable/extension_off_24px.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/pastebin.xml b/app/src/main/res/drawable/pastebin.xml deleted file mode 100644 index 9a2d5cd97..000000000 --- a/app/src/main/res/drawable/pastebin.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/volunteer_activism_24px.xml b/app/src/main/res/drawable/volunteer_activism_24px.xml deleted file mode 100644 index f2e719d3a..000000000 --- a/app/src/main/res/drawable/volunteer_activism_24px.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/warning_24px.xml b/app/src/main/res/drawable/warning_24px.xml deleted file mode 100644 index ab1606dc9..000000000 --- a/app/src/main/res/drawable/warning_24px.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index b29fc5127..d60abc203 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -196,7 +196,7 @@ 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 @@ -813,5 +816,4 @@ %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 af2f7ab93..73de880db 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 { "$it\n" } ?: "" else "" + val prefix = if (showMessage) this.message?.let { "\n$it" } ?: "" 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 deleted file mode 100644 index 016019918..000000000 --- a/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.android.kt +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 4b432877e..000000000 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.kt +++ /dev/null @@ -1,6 +0,0 @@ -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 cad05c422..3b2ae59c4 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt @@ -34,7 +34,6 @@ 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 dcf8511d3..348a90422 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Button.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Button.kt @@ -2,15 +2,10 @@ 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 @@ -20,7 +15,6 @@ 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 @@ -55,26 +49,6 @@ 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 1238ed649..9dbc7561e 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Chip.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Chip.kt @@ -22,7 +22,6 @@ import org.jetbrains.compose.resources.painterResource fun WhiteFilterChip( selected: Boolean, label: String, - modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit, ) { @@ -32,24 +31,16 @@ fun WhiteFilterChip( border = FilterChipDefaults.filterChipBorder( enabled = enabled, selected = selected, - borderColor = if (hasFocus) { - MaterialTheme.colorScheme.onBackground - } else { - MaterialTheme.colorScheme.surfaceVariant - }, + borderColor = 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 = if (hasFocus) { - Colors.whiteChipFocused - } else { - Colors.whiteChip - }, + colors = 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 f65a1a625..c15c66b23 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt @@ -38,24 +38,6 @@ 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 357cc26d0..a944889a5 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Dialog.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Dialog.kt @@ -1,16 +1,12 @@ 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 @@ -67,9 +63,7 @@ fun ActionDialog( } } }, - text = { - Text(text = text, modifier = Modifier.verticalScroll(rememberScrollState())) - }, + text = { Text(text = text) }, 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 deleted file mode 100644 index 4a81bb9d0..000000000 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/LazyList.kt +++ /dev/null @@ -1,26 +0,0 @@ -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 08e21a4c7..81f421671 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/TextField.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/TextField.kt @@ -1,6 +1,5 @@ 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 @@ -13,8 +12,6 @@ fun BlackTextField( placeHolder : String, modifier: Modifier = Modifier, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - keyboardActions: KeyboardActions = KeyboardActions.Default, - singleLine: Boolean = false, onValueChange: (String) -> Unit, ) { TextField( @@ -22,11 +19,9 @@ 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 e506fbabb..c20f2a16a 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt @@ -35,10 +35,9 @@ data class FilterByQuery( override fun invoke(value: Value): Boolean { if (query.isEmpty()) return onEmpty val transformed = transform(value) - val matchAgainst = if (ignoreCase) transformed.lowercase() else transformed - return Levenshtein.partialRatio( + return Levenshtein.ratio( query, - matchAgainst + if (ignoreCase) transformed.lowercase() else transformed ) > ratio } @@ -69,30 +68,6 @@ 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( @@ -147,8 +122,6 @@ fun PersistentMap.edit(key: K, updater: V.() -> V): PersistentMap, Value> { /** State = {} */ @@ -164,9 +137,6 @@ 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>( @@ -179,9 +149,6 @@ 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)) @@ -190,13 +157,6 @@ 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, @@ -313,9 +273,6 @@ 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) { @@ -337,28 +294,6 @@ 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, @@ -406,7 +341,8 @@ 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 deleted file mode 100644 index 41c58b212..000000000 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt +++ /dev/null @@ -1,1077 +0,0 @@ -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 0af8eaf40..e54441595 100644 --- a/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt +++ b/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt @@ -5,6 +5,7 @@ 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 @@ -20,7 +21,6 @@ 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) { - WhiteIconButton(onClick = onCancelActionMode) { + IconButton(onClick = onCancelActionMode) { Icon( painter = painterResource(Res.drawable.close), contentDescription = stringResource(Res.string.cancel), @@ -112,7 +112,7 @@ fun AppBar( } } else { navigateUp?.let { - WhiteIconButton(onClick = it) { + IconButton(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 467e0b3e5..e103a1440 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,7 +8,6 @@ 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 @@ -37,13 +36,12 @@ import kotlinx.coroutines.delay import kotlin.time.Duration.Companion.seconds @Composable -fun BasePreferenceWidget( +internal 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 @@ -53,7 +51,7 @@ fun BasePreferenceWidget( .highlightBackground(highlighted) .focusOutline() .sizeIn(minHeight = minHeight) - .combinedClickable(enabled = onClick != null, onLongClick = onLongClick, onClick = { onClick?.invoke() }) + .clickable(enabled = onClick != null, onClick = { onClick?.invoke() }) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { @@ -92,13 +90,11 @@ fun BasePreferenceWidget( @Composable internal fun Modifier.highlightBackground(highlighted: Boolean): Modifier { var highlightFlag by remember { mutableStateOf(false) } - LaunchedEffect(highlighted) { + LaunchedEffect(Unit) { if (highlighted) { highlightFlag = true delay(3.seconds) highlightFlag = false - } else { - highlightFlag = false } } val highlight by animateColorAsState( @@ -126,6 +122,6 @@ internal fun Modifier.highlightBackground(highlighted: Boolean): Modifier { } internal val TrailingWidgetBuffer = 16.dp -val PrefsHorizontalPadding = 16.dp +internal 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 deleted file mode 100644 index 5e5e3d240..000000000 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.jvm.kt +++ /dev/null @@ -1,26 +0,0 @@ -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