From 9fa1540e85bfeea0f4ec1bd37bd67f460d351b0e Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Fri, 16 Oct 2026 21:59:03 +0200
Subject: [PATCH 1/6] fix
---
.../utils/downloader/DownloadManager.kt | 13 +++++++++----
.../utils/downloader/DownloadObjects.kt | 4 +++-
app/src/main/res/values/strings.xml | 3 +++
3 files changed, 15 insertions(+), 5 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt
index adefedd20..5923a4a7d 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt
@@ -531,7 +531,7 @@ object VideoDownloadManager {
/** This class handles the notifications, as well as the relevant key */
data class DownloadMetaData(
private val id: Int?,
- private val linkHash : Int,
+ private val linkHash: Int,
var bytesDownloaded: Long = 0,
var bytesWritten: Long = 0,
@@ -543,7 +543,7 @@ object VideoDownloadManager {
private val createNotificationCallback: (CreateNotificationMetadata) -> Unit,
private var internalType: DownloadType = DownloadType.IsPending,
- val isHLS : Boolean,
+ val isHLS: Boolean,
// how many segments that we have downloaded
var hlsProgress: Int = 0,
// how many segments that exist
@@ -1045,7 +1045,7 @@ object VideoDownloadManager {
)
)
- // too short file, treat it as a invalid link
+ // too short file, treat it as an invalid link
if (items.totalLength != null && items.totalLength < minimumSize) {
fileStream.closeQuietly()
metadata.onDelete()
@@ -1241,13 +1241,15 @@ object VideoDownloadManager {
metadata.type = DownloadType.IsDone
return@withContext DOWNLOAD_SUCCESS
} catch (e: IOException) {
- // some sort of IO error, this should not happened
+ // some sort of IO error, this usually happens when storage access is revoked
// we just rethrow it
+ showToast(R.string.error_file_io_toast)
logError(e)
throw e
} catch (t: Throwable) {
// some sort of network error, will error
logError(t)
+ showToast(R.string.error_file_unknown_toast)
// note that when failing we don't want to delete the file,
// only user interaction has that power
metadata.type = DownloadType.IsFailed
@@ -1818,6 +1820,9 @@ object VideoDownloadManager {
isCompleted = true
break
} else if (!connectionResult.tryNext || index >= item.links.lastIndex) {
+ if (connectionResult == DOWNLOAD_INVALID_INPUT) {
+ showToast(R.string.error_file_invalid_toast)
+ }
isFailed = true
break
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt
index 946e7ecf9..b1436d171 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt
@@ -215,7 +215,9 @@ object DownloadObjects {
}
fun delete(): Boolean {
- return file.delete() == true
+ return runCatching {
+ file.delete()
+ }.getOrNull() == true
}
val resume: Boolean get() = fileLength > 0L
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f050ad993..ca7cfff0f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -83,6 +83,9 @@
Network stream
Open local video
Error Loading Links
+ Error occurred when writing to the file. Try setting a new download directory.
+ Error occurred when downloading the file. Try again.
+ Download source is invalid. Try another source.
Links Reloaded
Internal Storage
Dub
From 5cddd5b364fcd5f1988914d46446bf2814ec3e07 Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Sun, 18 Oct 2026 00:04:39 +0000
Subject: [PATCH 2/6] Update theintrodb to v4
Closes #3264
---
.../cloudstream3/utils/videoskip/TheIntroDBSkip.kt | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt
index 3fd050f1a..642a3bae8 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt
@@ -32,10 +32,10 @@ class TheIntroDBSkip : SkipAPI() {
?: return null
val url = if (data.isMovie()) {
- "$mainUrl/v2/media?$idSuffix"
+ "$mainUrl/v4/media?$idSuffix"
} else {
val season = episode.season ?: return null
- "$mainUrl/v2/media?$idSuffix&season=$season&episode=${episode.episode}"
+ "$mainUrl/v4/media?$idSuffix&season=$season&episode=${episode.episode}"
}
val root = app.get(url).parsed()
return arrayOf(
@@ -43,7 +43,7 @@ class TheIntroDBSkip : SkipAPI() {
root.credits to SkipType.Credits,
root.recap to SkipType.Recap,
root.preview to SkipType.Preview
- ).map { (list, type) ->
+ ).flatMap { (list, type) ->
list.map { stamp ->
SkipStamp(
type,
@@ -51,12 +51,11 @@ class TheIntroDBSkip : SkipAPI() {
stamp.endMs ?: episodeDurationMs
)
}
- }.flatten()
+ }
}
@Serializable
data class Root(
- @JsonProperty("tmdb_id") @SerialName("tmdb_id") val tmdbId: Long,
@JsonProperty("type") @SerialName("type") val type: String,
@JsonProperty("intro") @SerialName("intro") val intro: List = emptyList(),
@JsonProperty("recap") @SerialName("recap") val recap: List = emptyList(),
From 4c4c6796ff6a972e8f1339675b349e8c53194577 Mon Sep 17 00:00:00 2001
From: Void <254280517+voidece@users.noreply.github.com>
Date: Sun, 18 Oct 2026 05:37:35 +0530
Subject: [PATCH 3/6] fix: skip notification permission request while biometric
lock is enabled (#3256)
---
app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt | 3 +++
1 file changed, 3 insertions(+)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
index 4ce09bd44..8daf168c1 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
@@ -52,6 +52,7 @@ import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
import com.lagradost.cloudstream3.ui.settings.extensions.PluginAdapter
import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod
@@ -266,7 +267,9 @@ object CommonActivity {
}
// Ask for notification permissions on Android 13
+ // Not while biometric locked, the dialog cancels the biometric prompt
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ !isAuthEnabled(componentActivity) &&
ContextCompat.checkSelfPermission(
componentActivity,
Manifest.permission.POST_NOTIFICATIONS
From eb962b4fb7b1387b8de42ec58000cee0a01fe741 Mon Sep 17 00:00:00 2001
From: Osten <11805592+LagradOst@users.noreply.github.com>
Date: Sun, 18 Oct 2026 19:45:11 +0200
Subject: [PATCH 4/6] Fixes some minor issue with the +10 button (#3266)
---
.../lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt
index 1c7086d12..d3340cb57 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt
@@ -887,7 +887,6 @@ class PlayerGestureHelper(private val playerView: PlayerView) {
val wasShowing = playerView.callbacks?.isUIShowing() ?: false
// Only expose the parent chain when controls are currently hidden.
- val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false
val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true
if (!wasShowing) {
playerView.playerCenterMenu?.isGone = false
@@ -906,7 +905,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) {
resetRewindText()
// Restore parent chain only if we changed it and controls are still hidden.
if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) {
- playerView.playerCenterMenu?.isGone = prevCenterMenuGone
+ playerView.playerCenterMenu?.isGone = true
playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible
rewHolder.alpha = 0f
}
@@ -927,7 +926,6 @@ class PlayerGestureHelper(private val playerView: PlayerView) {
val ffwdText = playerView.exoFfwdText
val wasShowing = playerView.callbacks?.isUIShowing() ?: false
- val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false
val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true
if (!wasShowing) {
playerView.playerCenterMenu?.isGone = false
@@ -945,7 +943,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) {
ffwdText?.post {
resetFastForwardText()
if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) {
- playerView.playerCenterMenu?.isGone = prevCenterMenuGone
+ playerView.playerCenterMenu?.isGone = true
playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible
ffwdHolder.alpha = 0f
}
From dc9d1f0f9ec69c5fdd76d8c6e5bb47de5572fee8 Mon Sep 17 00:00:00 2001
From: Osten <11805592+LagradOst@users.noreply.github.com>
Date: Wed, 21 Oct 2026 20:55:09 +0200
Subject: [PATCH 5/6] Update extentions screen to Compose (#3258)
---
.../lagradost/cloudstream3/MainActivity.kt | 4 +-
.../cloudstream3/plugins/PluginManager.kt | 2 +-
.../cloudstream3/plugins/RepositoryManager.kt | 17 +-
.../cloudstream3/ui/account/AccountScreen.kt | 6 +-
.../cloudstream3/ui/settings/ApkUpdater.kt | 52 +
.../ui/settings/SettingsFragmentScreen.kt | 46 +-
.../ui/settings/SettingsUpdatesScreen.kt | 4 +-
.../extensions/AndroidRepositoryManager.kt | 282 +++
.../settings/extensions/ExtensionsFragment.kt | 2 +-
.../extensions/ExtensionsFragment2.kt | 6 +
.../settings/extensions/ExtensionsScreen.kt | 1922 +++++++++++++++++
.../extensions/ExtensionsViewModel.kt | 15 +-
.../cloudstream3/utils/AppContextUtils.kt | 3 +-
.../res/drawable/arrow_circle_down_24px.xml | 10 +
app/src/main/res/drawable/codeberg.xml | 51 +
.../main/res/drawable/delete_sweep_24px.xml | 10 +
.../res/drawable/disabled_by_default_24px.xml | 10 +
app/src/main/res/drawable/error_24px.xml | 10 +
.../main/res/drawable/extension_off_24px.xml | 10 +
app/src/main/res/drawable/pastebin.xml | 5 +
.../res/drawable/volunteer_activism_24px.xml | 10 +
app/src/main/res/drawable/warning_24px.xml | 10 +
.../main/res/navigation/mobile_navigation.xml | 2 +-
app/src/main/res/values/strings.xml | 1 +
.../cloudstream3/mvvm/ArchComponentExt.kt | 2 +-
.../cloudstream4/ClipboardHelper.android.kt | 23 +
.../lagradost/cloudstream4/ClipboardHelper.kt | 6 +
.../cloudstream4/DevicePreferenceStore.kt | 1 +
.../lagradost/cloudstream4/compose/Button.kt | 26 +
.../lagradost/cloudstream4/compose/Chip.kt | 15 +-
.../lagradost/cloudstream4/compose/Colors.kt | 18 +
.../lagradost/cloudstream4/compose/Dialog.kt | 8 +-
.../cloudstream4/compose/LazyList.kt | 26 +
.../cloudstream4/compose/TextField.kt | 5 +
.../cloudstream4/state/ImmutableState.kt | 72 +-
.../viewmodels/ExtensionsViewModel2.kt | 1077 +++++++++
.../kotlin/com/mihon/material/AppBar.kt | 6 +-
.../settings/widget/BasePreferenceWidget.kt | 12 +-
.../cloudstream4/ClipboardHelper.jvm.kt | 26 +
39 files changed, 3774 insertions(+), 39 deletions(-)
create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt
create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment2.kt
create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt
create mode 100644 app/src/main/res/drawable/arrow_circle_down_24px.xml
create mode 100644 app/src/main/res/drawable/codeberg.xml
create mode 100644 app/src/main/res/drawable/delete_sweep_24px.xml
create mode 100644 app/src/main/res/drawable/disabled_by_default_24px.xml
create mode 100644 app/src/main/res/drawable/error_24px.xml
create mode 100644 app/src/main/res/drawable/extension_off_24px.xml
create mode 100644 app/src/main/res/drawable/pastebin.xml
create mode 100644 app/src/main/res/drawable/volunteer_activism_24px.xml
create mode 100644 app/src/main/res/drawable/warning_24px.xml
create mode 100644 shared/src/androidMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.android.kt
create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.kt
create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/LazyList.kt
create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt
create mode 100644 shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.jvm.kt
diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
index 00e18158b..c13b2600c 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
@@ -1379,7 +1379,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
}
//Automatically download not existing plugins, using mode specified.
- val autoDownloadPlugin = AutoDownloadMode.getEnum(
+ /*val autoDownloadPlugin = AutoDownloadMode.getEnum(
settingsManager.getInt(
getString(R.string.auto_download_plugins_key),
0
@@ -1390,7 +1390,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
this@MainActivity,
autoDownloadPlugin
)
- }
+ }*/
}
ioSafe {
diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt
index 6054bbfaf..7ea736416 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt
@@ -590,7 +590,7 @@ object PluginManager {
/**
* @return True if successful, false if not
* */
- private suspend fun loadPlugin(context: Context, file: File, data: PluginData): Boolean {
+ internal suspend fun loadPlugin(context: Context, file: File, data: PluginData): Boolean {
val fileName = file.nameWithoutExtension
val filePath = file.absolutePath
currentlyLoading = fileName
diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt
index 3879ddca4..910d84735 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt
@@ -161,7 +161,7 @@ object RepositoryManager {
suspend fun parseRepository(url: String): Repository? {
return safeAsync {
// Take manifestVersion and such into account later
- app.get(convertRawGitUrl(url), cacheTime = 5, cacheUnit = TimeUnit.MINUTES)
+ app.get(convertRawGitUrl(url), cacheTime = 15, cacheUnit = TimeUnit.MINUTES)
.parsedSafe()
}
}
@@ -169,7 +169,7 @@ object RepositoryManager {
private suspend fun parsePlugins(pluginUrls: String): List {
// Take manifestVersion and such into account later
return try {
- app.get(convertRawGitUrl(pluginUrls), cacheTime = 5, cacheUnit = TimeUnit.MINUTES)
+ app.get(convertRawGitUrl(pluginUrls), cacheTime = 15, cacheUnit = TimeUnit.MINUTES)
.parsed>().toList()
} catch (t: Throwable) {
logError(t)
@@ -247,9 +247,16 @@ object RepositoryManager {
private val repoLock = Mutex()
suspend fun addRepository(repository: RepositoryData) {
repoLock.withLock {
- val currentRepos = getRepositories()
- // No duplicates
- setKey(REPOSITORIES_KEY, (currentRepos + repository).distinctBy { it.url })
+ // No duplicates, but we want the new repository instead of the old one
+ var currentRepos = getRepositories()
+ val index = currentRepos.indexOfFirst { it.url == repository.url }
+ if(index == -1) {
+ currentRepos += repository
+ } else {
+ /** We want to keep the ordering here, as that is the addition order */
+ currentRepos[index] = repository
+ }
+ setKey(REPOSITORIES_KEY, currentRepos)
}
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountScreen.kt
index 3d80f3742..056d3224e 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountScreen.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountScreen.kt
@@ -143,7 +143,7 @@ object AccountScreen {
.padding(contentPadding),
horizontalAlignment = Alignment.End
) {
- val (edit, accounts) = FocusRequester.createRefs()
+ val (edit, accounts) = remember { FocusRequester.createRefs() }
IconButton(
onClick = {
@@ -318,7 +318,7 @@ object AccountScreen {
)
}
- val (focusRequester) = FocusRequester.createRefs()
+ val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
@@ -746,7 +746,7 @@ object AccountScreen {
val innerPadding by animateDpAsState(
targetValue = if (hasFocus || isLayout(PHONE or EMULATOR)) 0.dp else MaterialTheme.padding.extraSmall,
)
- val (focusRequester) = FocusRequester.createRefs()
+ val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
if (selected) {
focusRequester.requestFocus()
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt
index 89280f2b0..34fea2938 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt
@@ -189,6 +189,58 @@ object ApkUpdater : AppUpdater {
}
}
+ /**
+ * Returns true if we have a non-null digest that matches a digest on the specified existing file
+ * */
+ suspend fun isFileDigest(file: File, digest: DigestPair?): Boolean {
+ if (digest == null) {
+ return false
+ }
+ try {
+ val fileDigest = calculateDigest(
+ digestAlgorithm = digest.algorithm,
+ readStream = file.inputStream()
+ )
+ return fileDigest != null && digest.digest.contentEquals(fileDigest)
+ } catch (_: Throwable) {
+ // File not found/security/read issues, not very useful errors
+ }
+ return false
+ }
+
+ /**
+ * Calculates the digest of a stream with the specified algorithm, use transfer instead if
+ * you already download something.
+ *
+ * Will throw read errors
+ * Will return null if the algorithm is null or does not exist/has some error
+ * */
+ @Throws
+ suspend fun calculateDigest(digestAlgorithm: String?, readStream: InputStream) =
+ withContext(Dispatchers.IO) {
+ if (digestAlgorithm == null) return@withContext null
+
+ val md =
+ safe {
+ MessageDigest.getInstance(digestAlgorithm)
+ } ?: return@withContext null
+
+ val context = currentCoroutineContext()
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ var read: Int
+ while ((readStream.read(buffer, 0, DEFAULT_BUFFER_SIZE)
+ .also { read = it }) >= 0
+ ) {
+ try {
+ md.update(buffer, 0, read)
+ } catch (_: Throwable) {
+ return@withContext null
+ }
+ context.ensureActive()
+ }
+ return@withContext safe { md.digest() }
+ }
+
@Throws
suspend fun legacyDownloader(
activity: Activity,
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt
index c906b0982..305b8ec28 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.verticalScroll
@@ -30,7 +31,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
-import androidx.compose.material3.rememberSearchBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
@@ -47,6 +47,11 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
@@ -288,25 +293,54 @@ object SettingsFragmentScreen : Screen {
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
- fun SettingsSearch(textFieldState : TextFieldState) {
+ fun SettingsSearch(textFieldState: TextFieldState) {
var hasFocus by remember { mutableStateOf(false) }
+ var isWritable by remember { mutableStateOf(false) }
val focusProgress by animateFloatAsState(targetValue = if (hasFocus) 1.0f else 0.0f)
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
+ val isTv = isLayout(TV)
TextField(
state = textFieldState,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Search,
- // This option is bugged on compose, making it unable to open at all
- // showKeyboardOnFocus = false
+ /**
+ * We do not want to pull up the keyboard when navigating to the item on TV, therefore
+ * we listen to when the TV user presses some key to "extra" focus it
+ *
+ * showKeyboardOnFocus is somewhat bugged, so need to change this to true when you
+ * press it, as otherwise no keyboard will show even with keyboardController
+ * */
+ showKeyboardOnFocus = if (isTv) isWritable else true
),
+ lineLimits = TextFieldLineLimits.SingleLine,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp - 12.dp * focusProgress)
- .focusOutline(enabled = isLayout(TV), CircleShape)
+ .focusOutline(enabled = isTv, CircleShape)
.onFocusChanged { newFocus ->
hasFocus = newFocus.hasFocus
- }.focusRequester(focusRequester),
+ /** Loss of focus -> readonly */
+ if (!newFocus.hasFocus) {
+ isWritable = false
+ }
+ }
+ .focusRequester(focusRequester)
+ .onPreviewKeyEvent { keyEvent ->
+ if (isWritable || !isTv || !hasFocus || keyEvent.type != KeyEventType.KeyUp) {
+ return@onPreviewKeyEvent false
+ }
+
+ /** Focus + press once -> writable */
+ return@onPreviewKeyEvent when (keyEvent.key) {
+ Key.Enter, Key.DirectionCenter, Key.NumPadEnter, Key.Spacebar -> {
+ isWritable = true
+ true
+ }
+
+ else -> false
+ }
+ },
placeholder = {
Text(text = stringResource(R.string.search_hint))
},
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt
index 3336b6127..3ce3ad6fb 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt
@@ -178,7 +178,7 @@ object SettingsUpdatesScreen : SearchableSettings {
preference = settings.plugins.autoUpdate,
),
- Preference.PreferenceItem.ListPreference(
+ /*Preference.PreferenceItem.ListPreference(
title = stringResource(R.string.automatic_plugin_download),
subtitle = "%s\n" + stringResource(R.string.automatic_plugin_download_summary),
icon = painterResource(R.drawable.extention_renew2),
@@ -197,7 +197,7 @@ object SettingsUpdatesScreen : SearchableSettings {
)
}
}
- ),
+ ),*/
Preference.PreferenceItem.TextPreference(
title = stringResource(R.string.test_extensions),
subtitle = stringResource(R.string.test_extensions_summary),
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt
new file mode 100644
index 000000000..3fbc67a74
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/AndroidRepositoryManager.kt
@@ -0,0 +1,282 @@
+package com.lagradost.cloudstream3.ui.settings.extensions
+
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
+import com.lagradost.cloudstream3.CommonActivity
+import com.lagradost.cloudstream3.ErrorLoadingException
+import com.lagradost.cloudstream3.InternalAPI
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.TvType
+import com.lagradost.cloudstream3.app
+import com.lagradost.cloudstream3.cmap
+import com.lagradost.cloudstream3.plugins.PLUGIN_VERSION_NOT_SET
+import com.lagradost.cloudstream3.plugins.PluginData
+import com.lagradost.cloudstream3.plugins.PluginManager
+import com.lagradost.cloudstream3.plugins.PluginManager.deletePlugin
+import com.lagradost.cloudstream3.plugins.PluginManager.getPluginPath
+import com.lagradost.cloudstream3.plugins.PluginManager.unloadPlugin
+import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES
+import com.lagradost.cloudstream3.plugins.RepositoryManager.addRepository
+import com.lagradost.cloudstream3.plugins.RepositoryManager.convertRawGitUrl
+import com.lagradost.cloudstream3.plugins.RepositoryManager.parseRepoUrl
+import com.lagradost.cloudstream3.plugins.RepositoryManager.removeRepository
+import com.lagradost.cloudstream3.plugins.SitePlugin
+import com.lagradost.cloudstream3.ui.settings.ApkUpdater
+import com.lagradost.cloudstream3.ui.settings.ApkUpdater.isFileDigest
+import com.lagradost.cloudstream4.viewmodels.DigestPair
+import com.lagradost.cloudstream4.viewmodels.Plugin
+import com.lagradost.cloudstream4.viewmodels.PluginDownloadSource
+import com.lagradost.cloudstream4.viewmodels.PluginInfo
+import com.lagradost.cloudstream4.viewmodels.PluginStatus
+import com.lagradost.cloudstream4.viewmodels.RepositoryInfo
+import com.lagradost.cloudstream4.viewmodels.RepositoryManager
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.nio.file.AtomicMoveNotSupportedException
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import java.util.concurrent.TimeUnit
+import kotlin.uuid.Uuid
+
+@InternalAPI
+object AndroidRepositoryManager : RepositoryManager {
+ fun RepositoryInfo.toData() =
+ RepositoryData(
+ name = name,
+ url = url,
+ iconUrl = iconUrl,
+ description = description
+ )
+
+ fun RepositoryData.toInfo() =
+ RepositoryInfo(
+ name = name,
+ url = url,
+ iconUrl = iconUrl,
+ description = description
+ )
+
+ override fun getRepositories(): List {
+ return ((getKey>(REPOSITORIES_KEY)
+ ?: emptyArray()) + PREBUILT_REPOSITORIES).map { info ->
+ info.toInfo()
+ }
+ }
+
+ override suspend fun getPlugins(
+ repository: RepositoryInfo,
+ repositoryUuid: Uuid
+ ): Pair> =
+ withContext(Dispatchers.IO) {
+ val activity =
+ CommonActivity.activity ?: throw ErrorLoadingException("No activity found")
+
+ val url = parseRepoUrl(repository.url)
+ if (url.isNullOrBlank()) {
+ throw ErrorLoadingException(activity.getString(R.string.error_invalid_url))
+ }
+
+ val parsedRepository = app.get(
+ url = convertRawGitUrl(url),
+ cacheTime = 15,
+ cacheUnit = TimeUnit.MINUTES
+ ).parsed()
+
+ /**
+ * No sane repository should be able to DDoS the app when navigating to settings, so
+ * we limit this to a maximum of 10
+ *
+ * Note that this list is on all verified repos = 1, as pluginLists is a list of lists
+ * */
+ require(parsedRepository.pluginLists.size <= 10)
+
+ /** Add this as a new repository -> replaces the old data with new icon,name and description */
+ addRepository(
+ RepositoryData(
+ iconUrl = parsedRepository.iconUrl,
+ url = url,
+ name = parsedRepository.name,
+ description = parsedRepository.description
+ )
+ )
+
+ val plugins = parsedRepository.pluginLists.cmap { url ->
+ /** This should really be a list of size ~ 1 */
+ app.get(
+ url = convertRawGitUrl(url),
+ cacheTime = 15,
+ cacheUnit = TimeUnit.MINUTES
+ ).parsed>()
+ }.flatMap { plugins ->
+ plugins.map { plugin ->
+ val file = getPluginPath(
+ context = activity,
+ internalName = plugin.internalName,
+ repositoryUrl = url
+ )
+ val language = plugin.language?.trim()
+ Plugin(
+ info = PluginInfo(
+ name = plugin.name,
+ status = plugin.status,
+ internalName = plugin.internalName,
+ version = plugin.version,
+ tvTypes = plugin.tvTypes?.mapNotNull { runCatching { TvType.valueOf(it) }.getOrNull() }
+ ?.toPersistentList()
+ ?: persistentListOf(),
+ authors = plugin.authors.toPersistentList(),
+ iconUrl = plugin.iconUrl,
+ language = if (language.isNullOrBlank()) {
+ null
+ } else {
+ language
+ },
+ description = plugin.description,
+ repositoryUrl = plugin.repositoryUrl
+ ),
+ source = PluginDownloadSource(
+ size = plugin.fileSize,
+ repositoryUrl = url,
+ pluginUrl = plugin.url,
+ internalName = plugin.internalName,
+ digest = DigestPair.parse(plugin.fileHash)
+ ),
+ status = PluginStatus.Idle,
+ downloadLocation = if (file.exists()) {
+ file.absolutePath
+ } else {
+ null
+ },
+ repository = repositoryUuid,
+ )
+ }
+ }
+
+ val info = RepositoryInfo(
+ iconUrl = parsedRepository.iconUrl,
+ url = url,
+ name = parsedRepository.name,
+ description = parsedRepository.description,
+ )
+ info to plugins
+ }
+
+ override suspend fun downloadPlugin(
+ source: PluginDownloadSource,
+ downloadProgress: (Long, Long?) -> Unit
+ ): String = withContext(Dispatchers.IO) {
+ val context = CommonActivity.activity ?: throw ErrorLoadingException("No activity found")
+ val file = getPluginPath(
+ context = context,
+ internalName = source.internalName,
+ repositoryUrl = source.repositoryUrl
+ )
+
+ /**
+ * Before we do anything that needs a network connection,
+ * we actually check if we need to update it
+ *
+ * Because if the file digest is correct, then why do we need to do a network connection?
+ * */
+ if (isFileDigest(file, source.digest)) {
+ return@withContext file.absolutePath
+ }
+
+ val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir)
+
+ tempFile.outputStream().use { writeStream ->
+ app.get(convertRawGitUrl(source.pluginUrl)).body.use { body ->
+ val readStream = body.byteStream()
+ ApkUpdater.transfer(
+ writeStream = writeStream,
+ readStream = readStream,
+ length = source.size,
+ downloadProgress = downloadProgress,
+ digest = source.digest
+ )
+ }
+ }
+
+ // Always load the plugin
+ unloadPlugin(file.absolutePath)
+
+ file.delete() // delete if we somehow already have this downloaded?
+ file.parentFile?.mkdirs() // make the directories as Files.move needs this to work
+
+ try {
+ Files.move(
+ tempFile.toPath(),
+ file.toPath(),
+ StandardCopyOption.REPLACE_EXISTING,
+ StandardCopyOption.ATOMIC_MOVE
+ )
+ } catch (_: AtomicMoveNotSupportedException) {
+ Files.move(
+ tempFile.toPath(),
+ file.toPath(),
+ StandardCopyOption.REPLACE_EXISTING
+ )
+ }
+
+ val result = PluginManager.loadPlugin(
+ context = context,
+ file = file,
+ data = PluginData(
+ internalName = source.internalName,
+ url = source.pluginUrl,
+ isOnline = true,
+ filePath = file.absolutePath,
+ version = PLUGIN_VERSION_NOT_SET
+ )
+ )
+ if (!result) {
+ throw ErrorLoadingException("Unable to load plugin ${source.internalName}")
+ }
+
+ return@withContext file.absolutePath
+ }
+
+ override fun getLocalPlugins(localUuid: Uuid): List {
+ val plugins = PluginManager.getPluginsLocal()
+ return plugins.map { plugin ->
+ Plugin(
+ info = PluginInfo(
+ name = plugin.internalName,
+ internalName = plugin.internalName,
+ version = plugin.version,
+ tvTypes = persistentListOf(),
+ authors = persistentListOf(),
+ status = 1,
+ iconUrl = null,
+ language = null,
+ description = null,
+ repositoryUrl = null
+ ),
+ source = null,
+ status = PluginStatus.Idle,
+ downloadLocation = plugin.filePath,
+ repository = localUuid,
+ )
+ }
+ }
+
+ override suspend fun deletePlugin(location: String) {
+ deletePlugin(File(location))
+ }
+
+ override suspend fun deleteRepository(repository: RepositoryInfo) {
+ val context = CommonActivity.activity ?: throw ErrorLoadingException("No activity found")
+ removeRepository(
+ context,
+ repository.toData()
+ )
+ }
+
+ override suspend fun updateRepositoryInfo(repository: RepositoryInfo) {
+ addRepository(
+ repository.toData()
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt
index 107227497..4d6567856 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt
@@ -302,7 +302,7 @@ class ExtensionsFragment : BaseFragment(
val fixedName = if (!name.isNullOrBlank()) name
else repository.name
- val newRepo = RepositoryData(repository.iconUrl, fixedName, url)
+ val newRepo = RepositoryData(repository.iconUrl, fixedName, url, repository.description)
RepositoryManager.addRepository(newRepo)
extensionViewModel.loadStats()
extensionViewModel.loadRepositories()
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment2.kt
new file mode 100644
index 000000000..7041887df
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment2.kt
@@ -0,0 +1,6 @@
+package com.lagradost.cloudstream3.ui.settings.extensions
+
+import com.lagradost.cloudstream3.utils.BaseComposeFragment
+import com.lagradost.cloudstream4.compose.Screen
+
+class ExtensionsFragment2 : BaseComposeFragment(), Screen by ExtensionsScreen
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt
new file mode 100644
index 000000000..3725a99da
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsScreen.kt
@@ -0,0 +1,1922 @@
+package com.lagradost.cloudstream3.ui.settings.extensions
+
+import android.annotation.SuppressLint
+import android.text.format.Formatter.formatShortFileSize
+import android.util.Log
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.focusGroup
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.clearText
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.FloatingActionButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ProvideTextStyle
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
+import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.platform.Clipboard
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringArrayResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.DialogProperties
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.findNavController
+import coil3.compose.AsyncImage
+import com.fasterxml.jackson.core.JacksonException
+import com.lagradost.cloudstream3.BuildConfig
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser
+import com.lagradost.cloudstream3.CommonActivity.activity
+import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.TvType
+import com.lagradost.cloudstream3.plugins.PluginManager
+import com.lagradost.cloudstream3.ui.settings.SettingsFragmentScreen
+import com.lagradost.cloudstream3.ui.settings.SettingsProvidersScreen.toStringRes
+import com.lagradost.cloudstream3.ui.settings.extensions.RepoAdapter.Companion.SHAREABLE_REPO_SEPARATOR
+import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName
+import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji
+import com.lagradost.cloudstream3.utils.UIHelper.navigate
+import com.lagradost.cloudstream4.compose.ActionDialog
+import com.lagradost.cloudstream4.compose.BlackButton
+import com.lagradost.cloudstream4.compose.BlackTextField
+import com.lagradost.cloudstream4.compose.IsScrolling
+import com.lagradost.cloudstream4.compose.LogBoxBlack
+import com.lagradost.cloudstream4.compose.LogBoxWhite
+import com.lagradost.cloudstream4.compose.LogText
+import com.lagradost.cloudstream4.compose.MultiSelectDialog
+import com.lagradost.cloudstream4.compose.PHONE
+import com.lagradost.cloudstream4.compose.RoundedShape
+import com.lagradost.cloudstream4.compose.Screen
+import com.lagradost.cloudstream4.compose.TV
+import com.lagradost.cloudstream4.compose.WhiteButton
+import com.lagradost.cloudstream4.compose.WhiteFilterChip
+import com.lagradost.cloudstream4.compose.WhiteIconButton
+import com.lagradost.cloudstream4.compose.focusOutline
+import com.lagradost.cloudstream4.compose.isLayout
+import com.lagradost.cloudstream4.compose.rounded
+import com.lagradost.cloudstream4.generated.resources.Res
+import com.lagradost.cloudstream4.generated.resources.no_results_found
+import com.lagradost.cloudstream4.getPlainText
+import com.lagradost.cloudstream4.rememberAppSettings
+import com.lagradost.cloudstream4.setPlainText
+import com.lagradost.cloudstream4.state.LogLevel
+import com.lagradost.cloudstream4.state.ObserveEffect
+import com.lagradost.cloudstream4.state.SearchableData
+import com.lagradost.cloudstream4.state.SortByName
+import com.lagradost.cloudstream4.theme.CloudStreamPreviewTheme
+import com.lagradost.cloudstream4.viewmodels.AddRepositoryDialog
+import com.lagradost.cloudstream4.viewmodels.DerivedRepositoryState
+import com.lagradost.cloudstream4.viewmodels.ExtensionsAction
+import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.CancelPluginDownloader
+import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.CancelPluginsUpdate
+import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.ClearPluginSearch
+import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.DeletePlugin
+import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.DeleteRepository
+import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.DownloadPlugin
+import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.DownloadRepositoryWithDialog
+import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.FilterByInverseLanguages
+import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.FilterByTypes
+import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.Init
+import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.ReloadRepository
+import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.Search
+import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.SelectPlugin
+import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.SelectRepository
+import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.SetRepositoryDialog
+import com.lagradost.cloudstream4.viewmodels.ExtensionsAction.UpdateAllPlugins
+import com.lagradost.cloudstream4.viewmodels.ExtensionsEffect
+import com.lagradost.cloudstream4.viewmodels.ExtensionsState
+import com.lagradost.cloudstream4.viewmodels.ExtensionsViewModel2
+import com.lagradost.cloudstream4.viewmodels.FilterPlugin
+import com.lagradost.cloudstream4.viewmodels.Plugin
+import com.lagradost.cloudstream4.viewmodels.PluginStatus
+import com.lagradost.cloudstream4.viewmodels.PluginsUpdatingDialog
+import com.lagradost.cloudstream4.viewmodels.Repository
+import com.lagradost.cloudstream4.viewmodels.RepositoryInfo
+import com.lagradost.cloudstream4.viewmodels.RepositoryStatus
+import com.mihon.material.AppBar
+import com.mihon.material.padding
+import com.mihon.presentation.LocalBackPress
+import com.mihon.presentation.secondaryItemAlpha
+import com.mihon.presentation.settings.LocalPreferenceHighlighted
+import com.mihon.presentation.settings.LocalPreferenceMinHeight
+import com.mihon.presentation.settings.widget.BasePreferenceWidget
+import com.mihon.presentation.settings.widget.PrefsHorizontalPadding
+import com.mihon.presentation.settings.widget.TextPreferenceWidget
+import kotlinx.collections.immutable.PersistentList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.persistentMapOf
+import kotlinx.collections.immutable.toPersistentSet
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.serialization.SerializationException
+import kotlin.math.roundToInt
+import kotlin.time.Duration.Companion.seconds
+import kotlin.uuid.Uuid
+
+@Composable
+@PreviewLightDark
+fun Preview() {
+ CloudStreamPreviewTheme {
+ val uuid1 = Uuid.random()
+ val uuid2 = Uuid.random()
+ val uuid3 = Uuid.random()
+ ExtensionsScreen.Content(
+ state = ExtensionsState(
+ //addDialog = AddRepositoryDialog(error = Throwable()),
+ /*updateAllPluginsDialog = PluginsUpdatingDialog(
+ total = 5,
+ downloaded = 4,
+ failed = 0
+ ),*/
+ repositories = SearchableData.from(
+ data = persistentMapOf(
+ uuid1 to Repository(
+ uuid = uuid1,
+ info = RepositoryInfo(
+ name = "Cloudstream providers repository",
+ description = "hello",
+ url = "https://raw.githubusercontent.com/recloudstream/extensions/refs/heads/master/repo.json",
+ iconUrl = null
+ ),
+ status = RepositoryStatus.Loading,
+ sortingOrder = 0,
+ ),
+ uuid2 to Repository(
+ uuid = uuid2,
+ info = RepositoryInfo(
+ name = "codeberg test",
+ url = "https://codeberg.org/",
+ iconUrl = null
+ ),
+ status = RepositoryStatus.Loading,
+ sortingOrder = 1,
+ ),
+ uuid3 to Repository(
+ uuid = uuid3,
+ info = RepositoryInfo(
+ name = "parsebin test",
+ url = "https://pastebin.com/",
+ iconUrl = null
+ ),
+ status = RepositoryStatus.Loading,
+ sortingOrder = 2,
+ )
+ ),
+ sortedBy = SortByName { it.info.name }
+ )
+ ),
+ onAction = {}
+ )
+ }
+}
+
+
+object ExtensionsScreen : Screen {
+ @Composable
+ override fun Content() {
+ val settings = rememberAppSettings()
+ val viewModel =
+ viewModel {
+ ExtensionsViewModel2(
+ manager = AndroidRepositoryManager,
+ settings = settings
+ )
+ }
+ LaunchedEffect(Unit) { // TODO fix this to only do if necessary
+ viewModel.onAction(Init)
+ }
+ val state by viewModel.state.collectAsState()
+ val highLight = remember { mutableStateOf(null) }
+ ObserveEffect(viewModel.effect) { effect ->
+ when (effect) {
+ is ExtensionsEffect.RepositoryAdded -> {
+ highLight.value = effect.uuid
+ }
+ }
+ }
+ Content(state = state, onAction = viewModel::onAction, highLightState = highLight)
+ }
+
+ @Composable
+ fun Content(
+ state: ExtensionsState,
+ onAction: (ExtensionsAction) -> Unit,
+ highLightState: MutableState = remember { mutableStateOf(null) },
+ ) {
+ val (topFocusRequester, bottomFocusRequester) = remember { FocusRequester.createRefs() }
+ val fabExpanded = remember { mutableStateOf(true) }
+
+ com.mihon.material.Scaffold(
+ // TODO @WindowInsets remove this when we have converted everything to compose
+ contentWindowInsets = WindowInsets(0, 0, 0, 0),
+ topBar = {
+ AppBar(
+ modifier = Modifier
+ .focusRequester(topFocusRequester)
+ .focusProperties {
+ down = bottomFocusRequester
+ },
+ title = if (state.pluginFilter.repository != null) {
+ state.repositories[state.pluginFilter.repository]?.info?.name
+ /** Should never happen */
+ ?: stringResource(R.string.extensions)
+ } else if (state.searchPlugins) {
+ stringResource(R.string.search)
+ } else {
+ stringResource(R.string.extensions)
+ },
+ navigateUp = LocalBackPress.current,
+ actions = {
+ TopActionRow(state = state, onAction = onAction)
+ },
+ scrollBehavior = it,
+ )
+ },
+ bottomBar = {
+ BottomBar(state = state, onAction = onAction)
+ },
+ floatingActionButton = {
+ if (isLayout(PHONE) && !state.searchPlugins) {
+ FloatingActionButton(state = state, onAction = onAction, expanded = fabExpanded)
+ }
+ },
+ content = { contentPadding ->
+ Content(
+ contentPadding = contentPadding,
+ state = state,
+ onAction = onAction,
+ highLightState = highLightState,
+ fabExpanded = fabExpanded,
+ focusRequester = bottomFocusRequester,
+ topBarFocusRequester = topFocusRequester
+ )
+ },
+ )
+ }
+
+ @Composable
+ fun TopActionRow(state: ExtensionsState, onAction: (ExtensionsAction) -> Unit) {
+ val scope = rememberCoroutineScope()
+ val clipboard = LocalClipboard.current
+
+ var extraDialog by remember { mutableStateOf(false) }
+ if (!state.searchPlugins) {
+ if (isLayout(TV)) {
+ WhiteIconButton(
+ onClick = {
+ scope.launch {
+ openRepositoryDialog(clipboard = clipboard, onAction = onAction)
+ }
+ }) {
+ Icon(
+ tint = MaterialTheme.colorScheme.onBackground,
+ painter = painterResource(R.drawable.add_24px),
+ contentDescription = stringResource(R.string.add_repository)
+ )
+ }
+ }
+
+ WhiteIconButton(
+ onClick = {
+ extraDialog = true
+ }) {
+ Icon(
+ tint = MaterialTheme.colorScheme.onBackground,
+ painter = painterResource(R.drawable.ic_baseline_more_vert_24),
+ contentDescription = stringResource(R.string.extensions)
+ )
+ }
+ }
+
+ DropdownMenu(expanded = extraDialog, onDismissRequest = {
+ extraDialog = false
+ }, shape = RoundedShape()) {
+ DropdownMenuItem(text = {
+ Text(text = stringResource(R.string.add_repository))
+ }, onClick = {
+ scope.launch {
+ openRepositoryDialog(clipboard = clipboard, onAction = onAction)
+ extraDialog = false
+ }
+ })
+ DropdownMenuItem(text = {
+ Text(text = stringResource(R.string.category_provider_test))
+ }, onClick = {
+ extraDialog = false
+ activity?.navigate(R.id.navigation_test_providers)
+ })
+ DropdownMenuItem(text = {
+ Text(text = stringResource(R.string.installed_plugins))
+ }, onClick = {
+ extraDialog = false
+ openInstalledPlugins()
+ })
+ DropdownMenuItem(text = {
+ Text(text = stringResource(R.string.update_plugins_manually))
+ }, onClick = {
+ extraDialog = false
+ onAction(UpdateAllPlugins)
+ })
+
+ /**
+ * Easier to debug the system if we have this.
+ * No need to translate this IMPO
+ * */
+ if (BuildConfig.DEBUG) {
+ DropdownMenuItem(text = {
+ Text(text = "Delete all repositories")
+ }, onClick = {
+ extraDialog = false
+ onAction(ExtensionsAction.DeleteAllRepositories)
+ })
+ }
+ }
+
+ var languageDialog by remember { mutableStateOf(false) }
+ var deleteDialog by remember { mutableStateOf(false) }
+ val derivedRepositoryState = state.derivedRepositoryState
+
+ val activeRepository = state.pluginFilter.repository
+ if (state.searchPlugins && activeRepository != null && derivedRepositoryState.notDownloaded != derivedRepositoryState.total) {
+ WhiteIconButton(onClick = {
+ deleteDialog = true
+ }) {
+ Icon(
+ tint = MaterialTheme.colorScheme.onBackground,
+ painter = painterResource(R.drawable.delete_sweep_24px),
+ contentDescription = stringResource(R.string.delete_repository)
+ )
+ }
+ }
+
+ /** We only show the langauge filter button if we are searching plugins and there is languages to pick from*/
+ if (state.searchPlugins && derivedRepositoryState.languageCount.isNotEmpty()) {
+ WhiteIconButton(onClick = {
+ languageDialog = true
+ }) {
+ Icon(
+ tint = MaterialTheme.colorScheme.onBackground,
+ painter = painterResource(R.drawable.plugin_lang),
+ contentDescription = stringResource(R.string.extension_language)
+ )
+ }
+ }
+
+ if (deleteDialog && activeRepository != null) {
+ ActionDialog(
+ title = stringResource(R.string.delete_files),
+ text = stringResource(R.string.delete_repository_plugins),
+ icon = painterResource(R.drawable.delete_sweep_24px),
+ dismiss = {
+ deleteDialog = false
+ },
+ confirm = {
+ onAction(ExtensionsAction.DeletePluginsInRepository(activeRepository))
+ deleteDialog = false
+ },
+ confirmText = stringResource(R.string.delete),
+ dismissText = stringResource(R.string.cancel),
+ )
+ }
+
+ if (languageDialog) {
+ val entries =
+ derivedRepositoryState.languageCount.keys.sortedBy { lang ->
+ fromTagToLanguageName(lang) ?: lang
+ }
+
+ MultiSelectDialog(
+ title = stringResource(R.string.extension_language),
+ entries = entries.associateWith { lang ->
+ if (lang == "") {
+ stringResource(R.string.no_data)
+ } else {
+ (getNameNextToFlagEmoji(
+ lang
+ ) ?: lang)
+ }
+ },
+ properties = DialogProperties(
+ usePlatformDefaultWidth = true,
+ ),
+ confirmText = stringResource(R.string.ok),
+ confirm = { selection ->
+ val removed =
+ derivedRepositoryState.languageCount.keys.subtract(selection)
+ onAction(
+ FilterByInverseLanguages(
+ removed.toPersistentSet()
+ )
+ )
+ languageDialog = false
+ },
+ dismissText = stringResource(R.string.cancel),
+ dismiss = {
+ languageDialog = false
+ },
+ selectedKeys = derivedRepositoryState.languageCount.keys.subtract(
+ state.pluginFilter.removeLanguages
+ ),
+ )
+ }
+ }
+
+ /** To make the UI even remotely usable, we batch the types */
+ val contentTypes: PersistentList>> = persistentListOf(
+ R.string.movies_singular to persistentListOf(TvType.Movie),
+ R.string.tv_series_singular to persistentListOf(TvType.TvSeries),
+ R.string.anime_singular to persistentListOf(TvType.Anime, TvType.AnimeMovie, TvType.OVA),
+ R.string.cartoons_singular to persistentListOf(TvType.Cartoon),
+ R.string.asian_drama_singular to persistentListOf(TvType.AsianDrama),
+ R.string.live_singular to persistentListOf(TvType.Live),
+ R.string.documentaries_singular to persistentListOf(TvType.Documentary),
+ R.string.torrent_singular to persistentListOf(TvType.Torrent),
+ R.string.audio_singular to persistentListOf(
+ TvType.Audio,
+ TvType.AudioBook,
+ TvType.Podcast,
+ TvType.Music
+ ),
+ R.string.nsfw_singular to persistentListOf(TvType.NSFW),
+ R.string.others to persistentListOf(
+ TvType.CustomMedia,
+ TvType.Video,
+ TvType.Others
+ ),
+ )
+
+ fun openInstalledPlugins() {
+ val activity = activity ?: return
+ activity.findNavController(R.id.nav_host_fragment).navigate(
+ R.id.navigation_settings_extensions_to_navigation_settings_plugins,
+ PluginsFragment.newLocalInstance(
+ activity.getString(R.string.extensions),
+ )
+ )
+ }
+
+ @SuppressLint("UnrememberedMutableState")
+ @Composable
+ fun Content(
+ contentPadding: PaddingValues,
+ state: ExtensionsState,
+ onAction: (ExtensionsAction) -> Unit,
+ highLightState: MutableState,
+ fabExpanded: MutableState,
+ topBarFocusRequester: FocusRequester,
+ focusRequester: FocusRequester,
+ ) {
+ AddDialog(dialog = state.addDialog, onAction = onAction)
+ UpdateDialog(dialog = state.updateAllPluginsDialog, onAction = onAction)
+
+ val lazyRepoState = rememberLazyListState()
+ val textState = rememberTextFieldState()
+ val lazyState = if (state.searchPlugins) {
+ rememberLazyListState()
+ } else {
+ lazyRepoState
+ }
+
+ BackHandler(enabled = state.searchPlugins) {
+ textState.clearText()
+ onAction(ClearPluginSearch)
+ }
+
+ /** Fab scrolling, this works but is currently a bit too fast IMPO */
+ lazyRepoState.IsScrolling(up = {
+ fabExpanded.value = true
+ }, down = {
+ fabExpanded.value = false
+ })
+
+ /**
+ * We are using mihons highlight here, meaning that we scroll for a few sec
+ * and then remove the highlight to prevent backpresses from rehighlighting it.
+ * The highlight is never in the viewmodel as it is a view issue.
+ * */
+ var highLight by highLightState
+ LaunchedEffect(highLight) {
+ if (highLight == null) return@LaunchedEffect
+ val list = if (state.searchPlugins) {
+ state.plugins.sorted
+ } else {
+ state.repositories.sorted
+ }
+ val index = list.indexOfFirst { uuid -> uuid == highLight }
+ if (index != -1) {
+ lazyRepoState.animateScrollToItem(
+ /** We have some extra items here */
+ index + 2
+ )
+ }
+ delay(5.0.seconds)
+ highLight = null
+ }
+
+ /**
+ * This may look a bit weird, but it is very jarring to snap the UI like this
+ * (because we want to have 2 different scrolls)
+ *
+ * The user expects a transition, so we fade it using animateTo + remember(repository),
+ * NOT snapTo(0.0f) as snapTo is 1 recomposition too slow!
+ * */
+ val itemAlpha = remember(state.pluginFilter.repository) { Animatable(0f) }
+ LaunchedEffect(state.pluginFilter.repository) {
+ itemAlpha.snapTo(0.0f)
+ itemAlpha.animateTo(1f)
+ }
+
+ LaunchedEffect(textState.text) {
+ onAction(Search(textState.text.toString()))
+ }
+
+ LazyColumn(
+ state = lazyState,
+ contentPadding = contentPadding,
+ modifier = Modifier
+ .fillMaxSize()
+ .focusGroup()
+ .focusRequester(focusRequester)
+ .focusProperties {
+ up = topBarFocusRequester
+ },
+ ) {
+ item(key = "header") {
+ Spacer(modifier = Modifier.height(MaterialTheme.padding.medium))
+ SettingsFragmentScreen.SettingsSearch(textState)
+ }
+ if (state.searchPlugins) {
+ item(key = "filter") {
+ Spacer(modifier = Modifier.height(MaterialTheme.padding.small))
+
+ /**
+ * In the case of a filter applied to the repository,
+ * we want to use the associated derived state to calculate what types this
+ * repository has
+ * */
+ val derivedState =
+ state.pluginFilter.repository?.let(state.plugins.state.repositories::get)
+ ?: state.plugins.state.sumOfRepositories
+
+ FilterRow(
+ filter = state.pluginFilter,
+ derivedState = derivedState,
+ onAction = onAction
+ )
+ }
+
+ items(items = state.plugins.sorted, key = { uuid -> uuid }) { uuid ->
+ val plugin = state.plugins[uuid]!!
+ val isHighlighted = uuid == highLight
+ CompositionLocalProvider(
+ LocalPreferenceHighlighted provides isHighlighted,
+ ) {
+ Plugin(
+ plugin = plugin,
+ onAction = onAction,
+ modifier = Modifier
+ .animateItem()
+ .graphicsLayer { alpha = itemAlpha.value },
+ onClick = if (state.pluginFilter.repository != null) {
+ null
+ } else {
+ {
+ onAction(SelectPlugin(plugin.uuid))
+ highLightState.value = plugin.uuid
+ }
+ }
+ )
+ }
+ }
+
+ val repository = state.pluginFilter.repository
+ val status = state.repositories[repository]?.status
+ if (state.plugins.sorted.isEmpty()) {
+ when (status) {
+ is RepositoryStatus.Failure -> {
+ item {
+ ErrorButton(status.cause)
+ }
+ }
+
+ RepositoryStatus.Loading -> {
+ item {
+ LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.onBackground,
+ trackColor = MaterialTheme.colorScheme.surfaceVariant,
+ )
+ }
+ }
+
+ RepositoryStatus.Success, null -> {
+ val derivedState =
+ repository?.let(state.plugins.state.repositories::get)
+
+ item {
+ Text(
+ text =
+ if (repository != null && (derivedState == null || derivedState.total == 0)) {
+ /**
+ * If we have a repository filter on,
+ * but it does not contain anything then show this
+ * instead as we do not really filter it but
+ * the repo is simply empty. This should mostly be used
+ * for plugin authors
+ * */
+ stringResource(R.string.no_plugins_found_error)
+ } else {
+ org.jetbrains.compose.resources.stringResource(Res.string.no_results_found)
+ },
+ modifier = Modifier
+ .secondaryItemAlpha()
+ .fillMaxWidth()
+ .padding(20.dp),
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ }
+ }
+ } else {
+ if (state.repositories.sorted.isEmpty()) {
+ item {
+ Text(
+ text = stringResource(R.string.blank_repo_message),
+ modifier = Modifier
+ .secondaryItemAlpha()
+ .fillMaxWidth()
+ .padding(20.dp),
+ textAlign = TextAlign.Center
+ )
+ }
+ item {
+ val scope = rememberCoroutineScope()
+ val clipboard = LocalClipboard.current
+ val minHeight = LocalPreferenceMinHeight.current
+ Row(
+ modifier = Modifier
+ .focusOutline()
+ .sizeIn(minHeight = minHeight)
+ .combinedClickable(onClick = {
+ scope.launch {
+ openRepositoryDialog(
+ clipboard = clipboard,
+ onAction = onAction
+ )
+ }
+ })
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.add_24px),
+ tint = MaterialTheme.colorScheme.onBackground,
+ contentDescription = stringResource(R.string.add_repository)
+ )
+ Spacer(modifier = Modifier.width(MaterialTheme.padding.medium))
+ Text(
+ text = stringResource(R.string.add_repository),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ } else {
+ item(key = "items top padding") {
+ Spacer(modifier = Modifier.height(MaterialTheme.padding.small))
+ }
+ }
+
+ items(items = state.repositories.sorted, key = { uuid -> uuid }) { uuid ->
+ val repository = state.repositories[uuid]!!
+ val isHighlighted = uuid == highLight
+ val derivedState = state.plugins.state.repositories[uuid]
+
+ CompositionLocalProvider(
+ LocalPreferenceHighlighted provides isHighlighted,
+ ) {
+ Repository(
+ repository = repository,
+ onAction = onAction,
+ derivedState = derivedState,
+ modifier = Modifier
+ .animateItem()
+ .graphicsLayer { alpha = itemAlpha.value }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun FilterRow(
+ filter: FilterPlugin,
+ derivedState: DerivedRepositoryState,
+ onAction: (ExtensionsAction) -> Unit,
+ ) {
+ val defaultFocusRequester = remember { FocusRequester() }
+
+ LazyRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusGroup()
+ .focusRestorer(defaultFocusRequester),
+ horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
+ contentPadding = PaddingValues(horizontal = MaterialTheme.padding.small)
+ ) {
+ var first = true
+ for ((text, types) in contentTypes) {
+ if (types.none(derivedState::containsType)) {
+ continue
+ }
+ val modifier = if (first) {
+ first = false
+ Modifier.focusRequester(defaultFocusRequester)
+ } else {
+ Modifier
+ }
+ item(key = text) {
+ FilterSelection(
+ modifier = modifier,
+ filter = filter,
+ text = text,
+ types = types,
+ onAction = onAction
+ )
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun FilterSelection(
+ filter: FilterPlugin,
+ text: Int,
+ types: PersistentList,
+ onAction: (ExtensionsAction) -> Unit,
+ modifier: Modifier = Modifier,
+ ) {
+ val selected =
+ filter.tvTypes.contains(types[0])
+
+ WhiteFilterChip(
+ modifier = modifier,
+ selected = selected,
+ label = stringResource(text),
+ ) {
+ onAction(
+ FilterByTypes(
+ if (selected) {
+ filter.tvTypes.removingAll(types)
+ } else {
+ filter.tvTypes.addingAll(types)
+ }
+ )
+ )
+ }
+ }
+
+ @Composable
+ fun ErrorDialog(
+ error: Throwable,
+ tag: String,
+ confirmButton: @Composable ((Modifier) -> Unit),
+ onDismissRequest: () -> Unit
+ ) {
+ val message = error.toString()
+ val stackTrace = error.stackTraceToString()
+ .lines()
+ .mapNotNull { line ->
+ val trimmed = line.trim()
+ if (!trimmed.startsWith("at ")) return@mapNotNull null
+ // pull "File.kt:42" out of "at com.pkg.Class.method(File.kt:42)"
+ Regex("""\(([^)]+)\)$""").find(trimmed)?.groupValues?.get(1)
+ ?.replace(":", " ")
+ }
+ .joinToString("\n")
+
+ val (logFocusRequester, dismissFocusRequester) = remember { FocusRequester.createRefs() }
+ val isTv = isLayout(TV)
+ LaunchedEffect(Unit) {
+ if (isTv) {
+ dismissFocusRequester.requestFocus()
+ }
+ }
+
+ AlertDialog(
+ containerColor = MaterialTheme.colorScheme.background,
+ onDismissRequest = onDismissRequest,
+ confirmButton = {
+ confirmButton(Modifier)
+ },
+ dismissButton = {
+ BlackButton(
+ text = stringResource(R.string.dismiss),
+ onClick = onDismissRequest,
+ modifier = Modifier
+ .focusRequester(dismissFocusRequester)
+ )
+ },
+ text = {
+ val clipboard = LocalClipboard.current
+ val scope = rememberCoroutineScope()
+ Column(
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .focusRequester(logFocusRequester)
+ .focusProperties {
+ down = dismissFocusRequester
+ }) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ LogBoxWhite(LogLevel.Error.identifier)
+ LogBoxBlack(tag)
+ }
+ LogText(
+ level = LogLevel.Error,
+ message = message,
+ onClick = {
+ scope.launch {
+ clipboard.setPlainText("Error", message)
+ }
+ },
+ )
+ if (stackTrace.isNotBlank()) {
+ LogText(
+ level = LogLevel.Error,
+ message = stackTrace,
+ onClick = {
+ scope.launch {
+ clipboard.setPlainText("Stacktrace", stackTrace)
+ }
+ })
+ }
+ }
+ },
+ title = {
+ Text(text = stringResource(R.string.error))
+ })
+ }
+
+ @Composable
+ fun Plugin(
+ plugin: Plugin,
+ onAction: (ExtensionsAction) -> Unit,
+ modifier: Modifier,
+ onClick: (() -> Unit)?,
+ ) {
+ val description = plugin.info.description
+ var extendedDialog by remember { mutableStateOf(false) }
+ val isTv = isLayout(TV)
+ val isPluginDisabledByAuthor = plugin.info.status == PROVIDER_STATUS_DOWN
+
+ if (extendedDialog) {
+ val dismissFocusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) {
+ if (isTv) {
+ dismissFocusRequester.requestFocus()
+ }
+ }
+
+ AlertDialog(
+ containerColor = MaterialTheme.colorScheme.background,
+ onDismissRequest = {
+ extendedDialog = false
+ },
+ confirmButton = {
+ val repository = plugin.info.repositoryUrl
+ if (repository != null) {
+ /** TODO filter this based on trusted urls? */
+ WhiteButton(text = stringResource(R.string.result_open_in_browser)) {
+ openBrowser(repository)
+ }
+ }
+ },
+ dismissButton = {
+ BlackButton(
+ modifier = Modifier.focusRequester(dismissFocusRequester),
+ text = stringResource(R.string.dismiss),
+ onClick = { extendedDialog = false }
+ )
+ },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small)) {
+ ProvideTextStyle(
+ LocalTextStyle.current.copy(
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ ) {
+ val roundedBackground =
+ Modifier
+ .background(
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = RoundedShape()
+ )
+ .padding(MaterialTheme.padding.extraSmall)
+
+ val description = plugin.info.description
+ if (description.isNullOrBlank()) {
+ InfoRow(
+ modifier = roundedBackground,
+ text = R.string.extension_description,
+ content = stringResource(R.string.no_data)
+ )
+ } else {
+ Text(
+ text = stringResource(R.string.extension_description),
+ modifier = roundedBackground
+ )
+ Text(text = description)
+ }
+
+ if (plugin.info.authors.isNotEmpty()) {
+ InfoRow(
+ modifier = roundedBackground,
+ text = R.string.extension_authors,
+ content = plugin.info.authors.joinToString()
+ )
+ }
+ InfoRow(
+ modifier = roundedBackground,
+ text = R.string.extension_version,
+ content = plugin.info.version.toString()
+ )
+ InfoRow(
+ modifier = roundedBackground,
+ text = R.string.extension_status,
+ content = stringArrayResource(R.array.extension_statuses).getOrNull(
+ plugin.info.status
+ ) ?: stringResource(R.string.no_data)
+ )
+ val size = plugin.source?.size
+ if (size != null) {
+ InfoRow(
+ modifier = roundedBackground,
+ text = R.string.extension_size,
+ content = formatShortFileSize(LocalContext.current, size)
+ )
+ }
+ if (plugin.info.tvTypes.isNotEmpty()) {
+ InfoRow(
+ modifier = roundedBackground,
+ text = R.string.extension_types,
+ content =
+ @Suppress("SimplifiableCallChain")
+ plugin.info.tvTypes.map { stringResource(it.toStringRes()) }
+ .joinToString())
+ }
+ InfoRow(
+ modifier = roundedBackground,
+ text = R.string.extension_language,
+ content = plugin.info.language?.let { language ->
+ getNameNextToFlagEmoji(language) ?: language
+ } ?: stringResource(R.string.no_data))
+ }
+ }
+ },
+ title = {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RepositoryIcon(
+ iconUrl = plugin.info.iconUrl,
+ defaultIcon = if (isPluginDisabledByAuthor) {
+ painterResource(R.drawable.extension_off_24px)
+ } else {
+ painterResource(R.drawable.extension_24px)
+ }
+ )
+ Text(text = plugin.info.name)
+ }
+ })
+ }
+
+ val (backgroundFocusRequester, buttonsFocusRequester) =
+ remember { FocusRequester.createRefs() }
+
+
+ BasePreferenceWidget(
+ modifier = modifier
+ .focusRequester(backgroundFocusRequester)
+ .focusProperties {
+ end = buttonsFocusRequester
+ },
+ title =
+ if (isPluginDisabledByAuthor) {
+ stringResource(R.string.single_plugin_disabled, plugin.info.name)
+ } else {
+ plugin.info.name
+ },
+ subcomponent =
+ {
+ Column(modifier = Modifier.padding(horizontal = PrefsHorizontalPadding)) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small)
+ ) {
+ val language = plugin.info.language
+ if (!language.isNullOrBlank()) {
+ Text(
+ text = getNameNextToFlagEmoji(language) ?: language,
+ modifier = Modifier
+ .secondaryItemAlpha(),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ Text(
+ text = "v${plugin.info.version}",
+ modifier = Modifier
+ .secondaryItemAlpha(),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ val sizeBytes = plugin.source?.size
+ if (sizeBytes != null) {
+ Text(
+ text = formatShortFileSize(LocalContext.current, sizeBytes),
+ modifier = Modifier
+ .secondaryItemAlpha(),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ if (plugin.info.tvTypes.contains(TvType.NSFW)) {
+ Text(
+ text = "18+",
+ style = MaterialTheme.typography.bodySmall,
+ color = colorResource(R.color.adultColor)
+ )
+ }
+ }
+ if (!description.isNullOrBlank()) {
+ Text(
+ text = description,
+ modifier = Modifier
+ .secondaryItemAlpha(),
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ },
+ icon = {
+ RepositoryIcon(
+ iconUrl = plugin.info.iconUrl,
+ defaultIcon =
+ if (isPluginDisabledByAuthor) {
+ painterResource(R.drawable.extension_off_24px)
+ } else {
+ painterResource(R.drawable.extension_24px)
+ }
+ )
+ },
+ onClick = onClick ?: {
+ extendedDialog = true
+ },
+ onLongClick = {
+ extendedDialog = true
+ },
+ widget = {
+ PluginWidget(
+ plugin = plugin,
+ onAction = onAction,
+ returnFocusRequester = backgroundFocusRequester,
+ modifier = Modifier
+ .focusRequester(buttonsFocusRequester)
+ .focusProperties {
+ start = backgroundFocusRequester
+ }
+ .focusGroup()
+ )
+ },
+ )
+ }
+
+ @Composable
+ fun PluginWidget(
+ plugin: Plugin,
+ onAction: (ExtensionsAction) -> Unit,
+ modifier: Modifier,
+ returnFocusRequester: FocusRequester,
+ ) {
+ var errorDialog by remember { mutableStateOf(null) }
+ val errorCause = errorDialog
+ if (errorCause != null) {
+ ErrorDialog(
+ error = errorCause,
+ tag = stringResource(
+ R.string.plugin_load_fail,
+ plugin.info.name
+ ),
+ confirmButton = { modifier ->
+ if (plugin.downloadLocation != null) {
+ WhiteButton(
+ modifier = modifier,
+ text = stringResource(R.string.delete_plugin),
+ onClick = {
+ errorDialog = null
+ onAction(DeletePlugin(plugin.uuid))
+ })
+ } else {
+ WhiteButton(
+ modifier = modifier,
+ text = stringResource(R.string.reload_error),
+ onClick = {
+ errorDialog = null
+ onAction(DownloadPlugin(plugin.uuid))
+ })
+ }
+ },
+ onDismissRequest = {
+ errorDialog = null
+ }
+ )
+ }
+
+ val isPluginDisabledByAuthor = plugin.info.status == PROVIDER_STATUS_DOWN
+ val isDownloadDisabled = isPluginDisabledByAuthor || plugin.source == null
+ Row(
+ modifier = modifier
+ ) {
+ val cs3Plugin = plugin.downloadLocation?.let { location ->
+ (plugin.source?.pluginUrl?.let(PluginManager.urlPlugins::get)
+ ?: (PluginManager.plugins[location])) as? com.lagradost.cloudstream3.plugins.Plugin
+ }
+ val settings = cs3Plugin?.openSettings
+
+ val (button1FocusRequester, button2FocusRequester) = remember {
+ FocusRequester.createRefs()
+ }
+
+ if (settings != null) {
+ val context = LocalContext.current
+ WhiteIconButton(
+ modifier = Modifier
+ .focusRequester(button1FocusRequester)
+ .focusProperties {
+ start = returnFocusRequester
+ end = button2FocusRequester
+ },
+ onClick = {
+ try {
+ settings.invoke(context)
+ } catch (e: Throwable) {
+ Log.e(
+ "PluginAdapter",
+ "Failed to open ${plugin.info.name} settings: ${
+ Log.getStackTraceString(e)
+ }"
+ )
+ }
+ }) {
+ Icon(
+ painter = painterResource(R.drawable.ic_baseline_tune_24),
+ contentDescription = null
+ )
+ }
+ }
+
+ WhiteIconButton(
+ modifier = Modifier
+ .focusRequester(button2FocusRequester)
+ .focusProperties {
+ start = if (settings == null) {
+ returnFocusRequester
+ } else {
+ button1FocusRequester
+ }
+ },
+ onClick = {
+ when (val status = plugin.status) {
+ is PluginStatus.Downloading -> {
+ onAction(CancelPluginDownloader(plugin.uuid))
+ }
+
+ is PluginStatus.Failed -> {
+ errorDialog = status.cause
+ }
+
+ PluginStatus.Idle -> {
+ if (plugin.downloadLocation != null) {
+ onAction(DeletePlugin(plugin.uuid))
+ } else if (isDownloadDisabled) {
+ /** The author has disabled the plugin, so do nothing here */
+ } else {
+ onAction(DownloadPlugin(plugin.uuid))
+ }
+ }
+ }
+ },
+ ) {
+ when (val status = plugin.status) {
+ is PluginStatus.Downloading -> {
+ val total = status.total
+ if (total != null) {
+ CircularProgressIndicator(
+ progress = {
+ status.progress.toFloat() / total.toFloat()
+ },
+ modifier = Modifier
+ .size(IconButtonDefaults.smallContainerSize())
+ .padding(MaterialTheme.padding.small),
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ } else {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(IconButtonDefaults.smallContainerSize())
+ .padding(MaterialTheme.padding.small),
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ }
+ }
+
+ is PluginStatus.Failed -> {
+ Icon(
+ painter = painterResource(R.drawable.warning_24px),
+ contentDescription = stringResource(R.string.error)
+ )
+ }
+
+ PluginStatus.Idle -> {
+ if (plugin.downloadLocation != null) {
+ Icon(
+ painter = painterResource(R.drawable.delete_24px),
+ contentDescription = stringResource(R.string.delete_plugin)
+ )
+ } else if (isDownloadDisabled) {
+ Icon(
+ painter = painterResource(R.drawable.visibility_off_24px),
+ contentDescription = stringResource(
+ R.string.single_plugin_disabled,
+ plugin.info.name
+ )
+ )
+ } else {
+ Icon(
+ painter = painterResource(R.drawable.arrow_circle_down_24px),
+ contentDescription = stringResource(R.string.download)
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun InfoRow(
+ modifier: Modifier,
+ text: Int,
+ content: String,
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(text),
+ modifier = modifier
+ )
+ Text(text = content)
+ }
+ }
+
+ @Composable
+ fun UpdateDialog(dialog: PluginsUpdatingDialog?, onAction: (ExtensionsAction) -> Unit) {
+ if (dialog == null) {
+ return
+ }
+ val finished = dialog.progress >= dialog.total
+
+ AlertDialog(
+ containerColor = MaterialTheme.colorScheme.background,
+ onDismissRequest = {
+ onAction(CancelPluginsUpdate)
+ },
+ confirmButton = {
+ },
+ dismissButton = {
+ BlackButton(
+ text = if (finished) {
+ stringResource(R.string.dismiss)
+ } else {
+ stringResource(R.string.cancel)
+ }, onClick = {
+ onAction(CancelPluginsUpdate)
+ })
+ },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small)) {
+ if (dialog.failed > 0) {
+ Text(
+ text =
+ stringResource(R.string.plugins_not_downloaded, dialog.failed)
+ )
+ }
+ if (!finished) {
+ LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.onBackground,
+ trackColor = MaterialTheme.colorScheme.surfaceVariant,
+ progress = {
+ if (dialog.total == 0) {
+ 1.0f
+ } else {
+ dialog.progress.toFloat() / dialog.total.toFloat()
+ }
+ }
+ )
+ }
+ }
+ },
+ title = {
+ Text(text = stringResource(R.string.plugins_updated, dialog.downloaded))
+ })
+ }
+
+ @Composable
+ fun AddDialog(dialog: AddRepositoryDialog?, onAction: (ExtensionsAction) -> Unit) {
+ if (dialog == null) {
+ return
+ }
+
+ val nextFocusRequester = remember { FocusRequester() }
+
+ AlertDialog(
+ containerColor = MaterialTheme.colorScheme.background,
+ onDismissRequest = {
+ onAction(SetRepositoryDialog(null))
+ },
+ confirmButton = {
+ WhiteButton(text = stringResource(R.string.add_repository), onClick = {
+ onAction(DownloadRepositoryWithDialog(url = dialog.url))
+ }, modifier = Modifier.focusRequester(nextFocusRequester))
+ },
+ dismissButton = {
+ BlackButton(text = stringResource(R.string.cancel), onClick = {
+ onAction(SetRepositoryDialog(null))
+ })
+ },
+ text = {
+ Column {
+ BlackTextField(
+ value = dialog.url,
+ placeHolder = stringResource(R.string.repository_url_hint),
+ keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ onAction(DownloadRepositoryWithDialog(url = dialog.url))
+ },
+ onNext = {
+ nextFocusRequester.requestFocus()
+ }
+ ),
+ singleLine = true
+ ) { newValue ->
+ onAction(
+ SetRepositoryDialog(
+ dialog.copy(
+ url = newValue,
+ error = null
+ )
+ )
+ )
+ }
+ Crossfade(dialog.loading) { loading ->
+ if (loading) {
+ Column {
+ Spacer(modifier = Modifier.height(MaterialTheme.padding.medium))
+ LinearProgressIndicator(
+ modifier = Modifier.width(TextFieldDefaults.MinWidth),
+ color = MaterialTheme.colorScheme.onBackground,
+ trackColor = MaterialTheme.colorScheme.surfaceVariant,
+ )
+ }
+ } else {
+ ErrorButton(dialog.error)
+ }
+ }
+ }
+ },
+ title = {
+ Text(text = stringResource(R.string.add_repository))
+ })
+ }
+
+ @Composable
+ fun ErrorButton(error: Throwable?) {
+ var shownError by remember { mutableStateOf(false) }
+
+ if (shownError && error != null) {
+ ErrorDialog(
+ error = error,
+ tag = stringResource(R.string.error_invalid_url),
+ confirmButton = {},
+ onDismissRequest = {
+ shownError = false
+ })
+ }
+
+ AnimatedVisibility(error != null, enter = fadeIn(), exit = fadeOut()) {
+ if (error == null) {
+ return@AnimatedVisibility
+ }
+
+ val message = error.message
+ TextPreferenceWidget(
+ modifier = Modifier
+ .rounded()
+ .width(TextFieldDefaults.MinWidth),
+ title = when {
+ error is JacksonException -> {
+ stringResource(R.string.no_repository_found_error)
+ }
+
+ error is SerializationException -> {
+ stringResource(R.string.no_repository_found_error)
+ }
+
+ message != null -> {
+ message
+ }
+
+ else -> stringResource(R.string.error_invalid_data)
+ },
+ icon = painterResource(R.drawable.warning_24px),
+ iconTint = LocalContentColor.current
+ ) {
+ shownError = true
+ }
+ }
+ }
+
+ @Composable
+ fun RepositoryIcon(iconUrl: String?, defaultIcon: Painter) {
+ if (iconUrl != null) {
+ AsyncImage(
+ model = remapUrl(url = iconUrl, size = 24.dp),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ error = defaultIcon
+ )
+ } else {
+ Icon(
+ painter = defaultIcon,
+ tint = MaterialTheme.colorScheme.onBackground,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+
+ @Composable
+ fun FloatingActionButton(
+ state: ExtensionsState,
+ expanded: MutableState,
+ onAction: (ExtensionsAction) -> Unit
+ ) {
+ val clipboard = LocalClipboard.current
+ val scope = rememberCoroutineScope()
+
+ ExtendedFloatingActionButton(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = MaterialTheme.colorScheme.onBackground,
+ elevation = FloatingActionButtonDefaults.elevation(
+ defaultElevation = 0.dp,
+ pressedElevation = 0.dp,
+ focusedElevation = 0.dp,
+ hoveredElevation = 0.dp
+ ),
+ expanded = expanded.value,
+ text = {
+ Text(stringResource(R.string.add_repository))
+ },
+ icon = {
+ Icon(
+ painter = painterResource(R.drawable.add_24px),
+ contentDescription = stringResource(R.string.add_repository)
+ )
+ },
+ onClick = {
+ scope.launch {
+ openRepositoryDialog(clipboard, onAction)
+ }
+ })
+ }
+
+ suspend fun openRepositoryDialog(
+ clipboard: Clipboard,
+ onAction: (ExtensionsAction) -> Unit
+ ) {
+ val copiedText = clipboard.getPlainText()?.trim() ?: ""
+
+ /** text is of format : */
+ val url = if (copiedText.contains(SHAREABLE_REPO_SEPARATOR)) {
+ val (_, url) = copiedText.split(
+ SHAREABLE_REPO_SEPARATOR,
+ limit = 2
+ )
+ url.trim()
+ }
+ /** Can this realistically be an url? */
+ else if (copiedText.length < 200 && !copiedText.contains("\n")) {
+ copiedText
+ }
+ /** Otherwise we just do a clear */
+ else {
+ ""
+ }
+ onAction(
+ SetRepositoryDialog(
+ AddRepositoryDialog(url = url)
+ )
+ )
+ }
+
+ @Composable
+ fun BottomBar(
+ state: ExtensionsState,
+ onAction: (ExtensionsAction) -> Unit
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ .clickable(
+ enabled = isLayout(PHONE),
+ onClick = {
+ openInstalledPlugins()
+ })
+ .padding(MaterialTheme.padding.small)
+ ) {
+ Text(stringResource(R.string.extensions))
+ Spacer(modifier = Modifier.height(MaterialTheme.padding.small))
+ val shape = RoundedCornerShape(3.dp)
+ val size = 10.dp
+ Row(modifier = Modifier.clip(shape)) {
+ Box(
+ modifier = Modifier
+ .height(size)
+ .background(color = MaterialTheme.colorScheme.onBackground)
+ .weight(
+ maxOf(
+ state.plugins.state.downloadedAndEnabled.toFloat(),
+ 0.001f
+ )
+ )
+ )
+ Box(
+ modifier = Modifier
+ .height(size)
+ .background(color = MaterialTheme.colorScheme.primary)
+ .weight(
+ maxOf(
+ state.plugins.state.downloadedAndDisabled.toFloat(),
+ 0.001f
+ )
+ )
+ )
+ Box(
+ modifier = Modifier
+ .height(size)
+ .background(color = MaterialTheme.colorScheme.onSurfaceVariant)
+ .weight(maxOf(state.plugins.state.notDownloaded.toFloat(), 0.02f))
+ )
+ }
+ Spacer(modifier = Modifier.height(MaterialTheme.padding.small))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(
+ MaterialTheme.padding.extraSmall
+ )
+ ) {
+ DownloadBox(
+ text = stringResource(
+ R.string.plugins_downloaded,
+ state.plugins.state.downloadedAndEnabled
+ ), color = MaterialTheme.colorScheme.onBackground
+ )
+ DownloadBox(
+ text = stringResource(
+ R.string.plugins_disabled,
+ state.plugins.state.downloadedAndDisabled
+ ), color = MaterialTheme.colorScheme.primary
+ )
+ DownloadBox(
+ text = stringResource(
+ R.string.plugins_not_downloaded,
+ state.plugins.state.notDownloaded
+ ), color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ @Composable
+ fun Repository(
+ modifier: Modifier,
+ repository: Repository,
+ derivedState: DerivedRepositoryState?,
+ onAction: (ExtensionsAction) -> Unit
+ ) {
+ var deleteDialog by remember { mutableStateOf(false) }
+
+ if (deleteDialog) {
+ ActionDialog(
+ icon = painterResource(R.drawable.delete_24px),
+ title = stringResource(R.string.delete_repository),
+ text = stringResource(R.string.delete_repository_plugins),
+ dismiss = {
+ deleteDialog = false
+ },
+ confirm = {
+ onAction(DeleteRepository(repository.uuid))
+ deleteDialog = false
+ },
+ confirmText = stringResource(R.string.delete),
+ dismissText = stringResource(R.string.cancel),
+ )
+ }
+
+ val clipboard = LocalClipboard.current
+ val scope = rememberCoroutineScope()
+ val copyTextLabel = stringResource(R.string.repo_copy_label)
+
+ val (backgroundFocusRequester, buttonFocusRequester) = remember { FocusRequester.createRefs() }
+
+ BasePreferenceWidget(
+ modifier = modifier
+ .focusRequester(backgroundFocusRequester)
+ .focusProperties {
+ end = buttonFocusRequester
+ },
+ title = repository.info.name,
+ subcomponent = {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
+ modifier = Modifier
+ .padding(horizontal = PrefsHorizontalPadding)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(
+ MaterialTheme.padding.extraSmall
+ ),
+ modifier = Modifier.defaultMinSize(minHeight = 14.dp)
+ ) {
+ if (derivedState != null) {
+ Text(
+ text = stringResource(
+ R.string.plugins_downloaded,
+ derivedState.downloadedAndEnabled
+ )
+ /**
+ Not very good concatenation translation here tbh,
+ but we do not want to add translation keys
+ */
+ + "/${derivedState.total}",
+ style = MaterialTheme.typography.labelMedium
+ )
+ if (derivedState.downloadedAndDisabled > 0) {
+ Text(
+ text = stringResource(
+ R.string.plugins_disabled,
+ derivedState.downloadedAndDisabled
+ ),
+ style = MaterialTheme.typography.labelMedium
+ )
+ }
+ } else if (repository.status == RepositoryStatus.Loading) {
+ LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.onBackground,
+ trackColor = MaterialTheme.colorScheme.surfaceVariant,
+ )
+ } else {
+ Text(
+ text = stringResource(
+ R.string.plugin_load_fail,
+ repository.info.name
+ ),
+ style = MaterialTheme.typography.labelMedium
+ )
+ }
+ }
+ Text(
+ text = repository.info.description ?: repository.info.url,
+ modifier = Modifier.secondaryItemAlpha(),
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 10,
+ )
+ }
+ },
+ icon = {
+ RepositoryIcon(
+ iconUrl = repository.info.iconUrl,
+ painterResource(
+ when {
+ repository.info.url.contains("codeberg") -> R.drawable.codeberg
+ repository.info.url.contains("pastebin") -> R.drawable.pastebin
+ else -> R.drawable.ic_github_logo
+ }
+ )
+ )
+ },
+ onClick = {
+ onAction(SelectRepository(repository.uuid))
+ },
+ onLongClick = {
+ val shareableRepoData =
+ "${repository.info.name}$SHAREABLE_REPO_SEPARATOR\n ${repository.info.url}"
+
+ scope.launch {
+ clipboard.setPlainText(
+ label = copyTextLabel,
+ text = shareableRepoData
+ )
+ }
+ },
+ widget = {
+ when (val reason = repository.status) {
+ is RepositoryStatus.Failure -> {
+ var errorDialog by remember { mutableStateOf(false) }
+ WhiteIconButton(
+ onClick = {
+ errorDialog = true
+ },
+ modifier = Modifier
+ .focusRequester(buttonFocusRequester)
+ .focusProperties {
+ start = backgroundFocusRequester
+ })
+ {
+ Icon(
+ painter = painterResource(R.drawable.warning_24px),
+ contentDescription = null
+ )
+ }
+ if (errorDialog) {
+ ErrorDialog(
+ error = reason.cause,
+ tag = stringResource(R.string.error_invalid_data),
+ confirmButton = { modifier ->
+ WhiteButton(
+ text = stringResource(R.string.reload_error),
+ modifier = modifier
+ ) {
+ onAction(ReloadRepository(repository.uuid))
+ errorDialog = false
+ }
+ WhiteButton(
+ text = stringResource(R.string.delete_repository),
+ modifier = modifier
+ ) {
+ deleteDialog = true
+ }
+ },
+ onDismissRequest = {
+ errorDialog = false
+ })
+ }
+ }
+
+ RepositoryStatus.Loading, RepositoryStatus.Success -> {
+ WhiteIconButton(
+ onClick = {
+ deleteDialog = true
+ },
+ modifier = Modifier
+ .focusRequester(buttonFocusRequester)
+ .focusProperties {
+ start = backgroundFocusRequester
+ }
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.delete_24px),
+ contentDescription = null
+ )
+ /*CircularProgressIndicator(
+ modifier = Modifier
+ .size(IconButtonDefaults.smallContainerSize())
+ .padding(MaterialTheme.padding.small),
+ color = MaterialTheme.colorScheme.onBackground,
+ )*/
+ }
+ }
+ }
+ },
+ )
+ }
+
+ @Composable
+ fun DownloadBox(
+ text: String,
+ color: Color,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(10.dp)
+ .background(
+ color = color,
+ shape = RoundedCornerShape(3.dp)
+ )
+ )
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelMedium
+ )
+ }
+
+ @Composable
+ fun remapUrl(url: String, size: Dp): String {
+ tailrec fun findClosestBase2(target: Int, current: Int = 16, max: Int = 512): Int {
+ if (current >= max) return max
+ if (current >= target) return current
+ return findClosestBase2(target, current * 2, max)
+ }
+
+ val pxValue = with(LocalDensity.current) { maxOf(1, size.toPx().roundToInt()) }
+ return url.replace(
+ "%size%",
+ "${findClosestBase2(pxValue, 16, 512)}"
+ ).replace(
+ "%exact_size%",
+ "$pxValue"
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt
index d30962598..2fe63a59b 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt
@@ -23,8 +23,21 @@ data class RepositoryData(
@JsonProperty("iconUrl") @SerialName("iconUrl") val iconUrl: String?,
@JsonProperty("name") @SerialName("name") val name: String,
@JsonProperty("url") @SerialName("url") val url: String,
+ @JsonProperty("description") @SerialName("description") val description: String? = null,
) {
- constructor(name: String, url: String): this(null, name, url)
+ constructor(name: String, url: String) : this(
+ iconUrl = null,
+ name = name,
+ url = url,
+ description = null
+ )
+
+ constructor(iconUrl: String?, name: String, url: String) : this(
+ iconUrl = iconUrl,
+ name = name,
+ url = url,
+ description = null
+ )
}
const val REPOSITORIES_KEY = "REPOSITORIES_KEY"
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt
index e0661721a..2e3829120 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt
@@ -527,7 +527,8 @@ object AppContextUtils {
val data = RepositoryData(
repo.iconUrl ?: "",
repo.name,
- url
+ url,
+ repo.description
)
RepositoryManager.addRepository(data)
main {
diff --git a/app/src/main/res/drawable/arrow_circle_down_24px.xml b/app/src/main/res/drawable/arrow_circle_down_24px.xml
new file mode 100644
index 000000000..03edb80d2
--- /dev/null
+++ b/app/src/main/res/drawable/arrow_circle_down_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/codeberg.xml b/app/src/main/res/drawable/codeberg.xml
new file mode 100644
index 000000000..9ac01ce95
--- /dev/null
+++ b/app/src/main/res/drawable/codeberg.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/delete_sweep_24px.xml b/app/src/main/res/drawable/delete_sweep_24px.xml
new file mode 100644
index 000000000..220f68509
--- /dev/null
+++ b/app/src/main/res/drawable/delete_sweep_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/disabled_by_default_24px.xml b/app/src/main/res/drawable/disabled_by_default_24px.xml
new file mode 100644
index 000000000..bc935c293
--- /dev/null
+++ b/app/src/main/res/drawable/disabled_by_default_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/error_24px.xml b/app/src/main/res/drawable/error_24px.xml
new file mode 100644
index 000000000..31feeeca1
--- /dev/null
+++ b/app/src/main/res/drawable/error_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/extension_off_24px.xml b/app/src/main/res/drawable/extension_off_24px.xml
new file mode 100644
index 000000000..6386d44ee
--- /dev/null
+++ b/app/src/main/res/drawable/extension_off_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/pastebin.xml b/app/src/main/res/drawable/pastebin.xml
new file mode 100644
index 000000000..9a2d5cd97
--- /dev/null
+++ b/app/src/main/res/drawable/pastebin.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/volunteer_activism_24px.xml b/app/src/main/res/drawable/volunteer_activism_24px.xml
new file mode 100644
index 000000000..f2e719d3a
--- /dev/null
+++ b/app/src/main/res/drawable/volunteer_activism_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/warning_24px.xml b/app/src/main/res/drawable/warning_24px.xml
new file mode 100644
index 000000000..ab1606dc9
--- /dev/null
+++ b/app/src/main/res/drawable/warning_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml
index d60abc203..b29fc5127 100644
--- a/app/src/main/res/navigation/mobile_navigation.xml
+++ b/app/src/main/res/navigation/mobile_navigation.xml
@@ -196,7 +196,7 @@
%d downloads queued
Live
+ Installed plugins
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt
index 73de880db..af2f7ab93 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt
@@ -128,7 +128,7 @@ fun Throwable.getAllMessages(): String {
}
fun Throwable.getStackTracePretty(showMessage: Boolean = true): String {
- val prefix = if (showMessage) this.message?.let { "\n$it" } ?: "" else ""
+ val prefix = if (showMessage) this.message?.let { "$it\n" } ?: "" else ""
return prefix + this.stackTraceToString()
.lines()
.mapNotNull { line ->
diff --git a/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.android.kt b/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.android.kt
new file mode 100644
index 000000000..016019918
--- /dev/null
+++ b/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.android.kt
@@ -0,0 +1,23 @@
+package com.lagradost.cloudstream4
+
+import android.content.ClipData
+import androidx.compose.ui.platform.ClipEntry
+import androidx.compose.ui.platform.Clipboard
+
+actual suspend fun Clipboard.getPlainText(): String? =
+ getClipEntry()?.clipData?.getItemAt(0)?.text?.toString()
+
+
+actual suspend fun Clipboard.setPlainText(
+ label: String,
+ text: String
+) {
+ setClipEntry(
+ ClipEntry(
+ ClipData.newPlainText(
+ label,
+ text
+ )
+ )
+ )
+}
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.kt
new file mode 100644
index 000000000..4b432877e
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.kt
@@ -0,0 +1,6 @@
+package com.lagradost.cloudstream4
+
+import androidx.compose.ui.platform.Clipboard
+
+expect suspend fun Clipboard.setPlainText(label: String, text: String)
+expect suspend fun Clipboard.getPlainText(): String?
diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt
index 3b2ae59c4..cad05c422 100644
--- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt
+++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt
@@ -34,6 +34,7 @@ class AppSettings internal constructor(
class PluginPreferences(preferences: PreferenceStore) {
val autoUpdate = preferences.getBoolean("auto_update_plugins", true)
+ /** Not used anymore, as a user should never auto install all plugins in a repository */
val autoDownload = preferences.getInt("auto_download_plugins_key2", 0)
}
diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Button.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Button.kt
index 348a90422..dcf8511d3 100644
--- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Button.kt
+++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Button.kt
@@ -2,10 +2,15 @@ package com.lagradost.cloudstream4.compose
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonColors
+import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -15,6 +20,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
import com.lagradost.cloudstream4.generated.resources.Res
import com.lagradost.cloudstream4.generated.resources.check
@@ -49,6 +55,26 @@ fun BlackButton(
)
}
+@Composable
+fun WhiteIconButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ colors: IconButtonColors = IconButtonDefaults.iconButtonColors(),
+ interactionSource: MutableInteractionSource? = null,
+ shape: Shape = IconButtonDefaults.standardShape,
+ content: @Composable () -> Unit,
+) =
+ IconButton(
+ onClick = onClick,
+ modifier = modifier.focusOutline(shape = CircleShape),
+ enabled = enabled,
+ colors = colors,
+ interactionSource = interactionSource,
+ shape = shape,
+ content = content
+ )
+
@Composable
fun BaseButton(
text: String, onClick: () -> Unit, buttonColors: ButtonColors,
diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Chip.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Chip.kt
index 9dbc7561e..1238ed649 100644
--- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Chip.kt
+++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Chip.kt
@@ -22,6 +22,7 @@ import org.jetbrains.compose.resources.painterResource
fun WhiteFilterChip(
selected: Boolean,
label: String,
+ modifier: Modifier = Modifier,
enabled: Boolean = true,
onClick: () -> Unit,
) {
@@ -31,16 +32,24 @@ fun WhiteFilterChip(
border = FilterChipDefaults.filterChipBorder(
enabled = enabled,
selected = selected,
- borderColor = MaterialTheme.colorScheme.surfaceVariant,
+ borderColor = if (hasFocus) {
+ MaterialTheme.colorScheme.onBackground
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant
+ },
disabledBorderColor = MaterialTheme.colorScheme.surfaceVariant,
selectedBorderColor = Color.Transparent,
disabledSelectedBorderColor = MaterialTheme.colorScheme.surfaceVariant,
),
- modifier = Modifier.onFocusChanged { newFocus ->
+ modifier = modifier.onFocusChanged { newFocus ->
hasFocus = canHaveFocus && newFocus.hasFocus
},
selected = selected,
- colors = Colors.whiteChip,
+ colors = if (hasFocus) {
+ Colors.whiteChipFocused
+ } else {
+ Colors.whiteChip
+ },
label = {
Text(label)
},
diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt
index c15c66b23..f65a1a625 100644
--- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt
+++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt
@@ -38,6 +38,24 @@ object Colors {
disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ selectedContainerColor = MaterialTheme.colorScheme.onBackground,
+ selectedLabelColor = MaterialTheme.colorScheme.background,
+ selectedLeadingIconColor = MaterialTheme.colorScheme.background,
+ selectedTrailingIconColor = MaterialTheme.colorScheme.background,
+ )
+ internal val whiteChipFocused
+ @Composable @ReadOnlyComposable get() = SelectableChipColors(
+ containerColor = Color.Transparent,
+ labelColor = MaterialTheme.colorScheme.onBackground,
+ leadingIconColor = MaterialTheme.colorScheme.onBackground,
+ trailingIconColor = MaterialTheme.colorScheme.onBackground,
+
+ disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
+ disabledSelectedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
+ disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
+
selectedContainerColor = MaterialTheme.colorScheme.onBackground,
selectedLabelColor = MaterialTheme.colorScheme.background,
selectedLeadingIconColor = MaterialTheme.colorScheme.background,
diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Dialog.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Dialog.kt
index a944889a5..357cc26d0 100644
--- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Dialog.kt
+++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Dialog.kt
@@ -1,12 +1,16 @@
package com.lagradost.cloudstream4.compose
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -63,7 +67,9 @@ fun ActionDialog(
}
}
},
- text = { Text(text = text) },
+ text = {
+ Text(text = text, modifier = Modifier.verticalScroll(rememberScrollState()))
+ },
confirmButton = {
WhiteButton(text = confirmText, onClick = confirm)
},
diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/LazyList.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/LazyList.kt
new file mode 100644
index 000000000..4a81bb9d0
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/LazyList.kt
@@ -0,0 +1,26 @@
+package com.lagradost.cloudstream4.compose
+
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.grid.LazyGridState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+
+@Composable
+fun LazyGridState.IsScrolling(up : Runnable, down : Runnable) {
+ LaunchedEffect(lastScrolledBackward) {
+ if(lastScrolledBackward) up.run()
+ }
+ LaunchedEffect(lastScrolledForward) {
+ if(lastScrolledForward) down.run()
+ }
+}
+@Suppress("FrequentlyChangingValue")
+@Composable
+fun LazyListState.IsScrolling(up : Runnable, down : Runnable) {
+ LaunchedEffect(firstVisibleItemIndex) {
+ if(lastScrolledBackward) up.run()
+ }
+ LaunchedEffect(firstVisibleItemIndex) {
+ if(lastScrolledForward) down.run()
+ }
+}
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/TextField.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/TextField.kt
index 81f421671..08e21a4c7 100644
--- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/TextField.kt
+++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/TextField.kt
@@ -1,5 +1,6 @@
package com.lagradost.cloudstream4.compose
+import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
@@ -12,6 +13,8 @@ fun BlackTextField(
placeHolder : String,
modifier: Modifier = Modifier,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
+ singleLine: Boolean = false,
onValueChange: (String) -> Unit,
) {
TextField(
@@ -19,9 +22,11 @@ fun BlackTextField(
value = value,
onValueChange = onValueChange,
keyboardOptions = keyboardOptions,
+ keyboardActions = keyboardActions,
placeholder = {
Text(text = placeHolder)
},
+ singleLine = singleLine,
shape = RoundedShape(),
colors = Colors.blackTextField,
)
diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt
index c20f2a16a..e506fbabb 100644
--- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt
+++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/state/ImmutableState.kt
@@ -35,9 +35,10 @@ data class FilterByQuery(
override fun invoke(value: Value): Boolean {
if (query.isEmpty()) return onEmpty
val transformed = transform(value)
- return Levenshtein.ratio(
+ val matchAgainst = if (ignoreCase) transformed.lowercase() else transformed
+ return Levenshtein.partialRatio(
query,
- if (ignoreCase) transformed.lowercase() else transformed
+ matchAgainst
) > ratio
}
@@ -68,6 +69,30 @@ data class FilterByQuery(
}
}
+/** Sorting by closest match first */
+@Immutable
+data class SortByMatching(
+ val query: String,
+ val ignoreCase: Boolean = true,
+ val transform: (Value) -> String,
+) : Comparator {
+ override fun compare(p0: Value?, p1: Value?): Int {
+ if (p0 == p1) return 0
+ if (p0 == null) return -1
+ if (p1 == null) return 1
+ return score(p1).compareTo(score(p0))
+ }
+
+ fun score(p0: Value) : Int {
+ val transformed = transform(p0)
+ val matchAgainst = if (ignoreCase) transformed.lowercase() else transformed
+ return Levenshtein.partialRatio(
+ query,
+ matchAgainst
+ )
+ }
+}
+
/** Default sorting by name (Ascending A->Z), use `rev()` if Z->A is desired */
@Immutable
data class SortByName(
@@ -122,6 +147,8 @@ fun PersistentMap.edit(key: K, updater: V.() -> V): PersistentMap, Value> {
/** State = {} */
@@ -137,6 +164,9 @@ interface DerivedState, Value> {
/**
* For state that updated a lot we might need to calculate some accumulation, then
* this is used to more ergonomically and efficiently do a `.filter {}` or `.count {}`.
+ *
+ * Note that the derived state is calculated *before* the filtering, and as such the filterBy has
+ * no effect on the state.
*/
@Immutable
data class SearchableDataState>(
@@ -149,6 +179,9 @@ data class SearchableDataState>(
val filteredBy get() = internalData.filteredBy
val sortedBy get() = internalData.sortedBy
+ @CheckResult
+ operator fun get(key : Key?) : Value? = internalData[key]
+
@CheckResult
fun sortingBy(newSorting: Comparator): SearchableDataState =
copy(internalData = internalData.sortingBy(newSorting))
@@ -157,6 +190,13 @@ data class SearchableDataState>(
fun filteringBy(newFilter: ((Value) -> Boolean)?): SearchableDataState =
copy(internalData = internalData.filteringBy(newFilter))
+ @CheckResult
+ fun searchBy(
+ newSorting: Comparator,
+ newFilter: ((Value) -> Boolean)?
+ ): SearchableDataState =
+ copy(internalData = internalData.searchBy(newSorting = newSorting, newFilter = newFilter))
+
@CheckResult
fun updating(
key: Key,
@@ -273,6 +313,9 @@ data class SearchableData(
* */
val sortedBy: Comparator,
) {
+ @CheckResult
+ operator fun get(key : Key?) : Value? = key?.let(data::get)
+
@CheckResult
fun sortingBy(newSorting: Comparator): SearchableData {
if (newSorting == sortedBy) {
@@ -294,6 +337,28 @@ data class SearchableData(
return copy(filteredBy = newFilter, filtered = newFiltered, sorted = newSorted)
}
+ @CheckResult
+ fun searchBy(
+ newSorting: Comparator,
+ newFilter: ((Value) -> Boolean)?
+ ): SearchableData {
+ if (newFilter == filteredBy && newSorting == sortedBy) {
+ return this
+ }
+ val newFiltered = if (newFilter == filteredBy) {
+ filtered
+ } else {
+ filter(data = data, filteredBy = newFilter)
+ }
+ val newSorted = sort(data = data, filtered = newFiltered, sortedBy = newSorting)
+ return copy(
+ filteredBy = newFilter,
+ sortedBy = newSorting,
+ filtered = newFiltered,
+ sorted = newSorted
+ )
+ }
+
@CheckResult
fun updating(
key: Key,
@@ -341,8 +406,7 @@ data class SearchableData(
} else if (newFiltered.size < filtered.size) {
/** Shown -> Hidden, If the update filtered out the item then just remove it from the list */
return copy(data = newData, filtered = newFiltered, sorted = sorted.removing(key))
- } else
- {
+ } else {
/** Hidden -> Shown, we only need to insert it to the sorted list */
return copy(
data = newData, filtered = newFiltered, sorted = addItemAt(
diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt
new file mode 100644
index 000000000..41c58b212
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/viewmodels/ExtensionsViewModel2.kt
@@ -0,0 +1,1077 @@
+package com.lagradost.cloudstream4.viewmodels
+
+import androidx.annotation.CheckResult
+import androidx.compose.runtime.Immutable
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.lagradost.cloudstream3.InternalAPI
+import com.lagradost.cloudstream3.TvType
+import com.lagradost.cloudstream3.utils.Levenshtein
+import com.lagradost.cloudstream4.AppSettings
+import com.lagradost.cloudstream4.state.ActionHandler
+import com.lagradost.cloudstream4.state.DefaultEffectContainer
+import com.lagradost.cloudstream4.state.DefaultStateContainer
+import com.lagradost.cloudstream4.state.DerivedState
+import com.lagradost.cloudstream4.state.EffectContainer
+import com.lagradost.cloudstream4.state.MultiActiveQuery
+import com.lagradost.cloudstream4.state.SearchableData
+import com.lagradost.cloudstream4.state.SearchableDataState
+import com.lagradost.cloudstream4.state.SingleActiveQuery
+import com.lagradost.cloudstream4.state.SortByName
+import com.lagradost.cloudstream4.state.StateContainer
+import com.lagradost.cloudstream4.state.UniqueItem
+import com.lagradost.cloudstream4.state.sortByInt
+import com.lagradost.cloudstream4.viewmodels.ExtensionsEffect.RepositoryAdded
+import kotlinx.collections.immutable.PersistentList
+import kotlinx.collections.immutable.PersistentMap
+import kotlinx.collections.immutable.PersistentSet
+import kotlinx.collections.immutable.mutate
+import kotlinx.collections.immutable.persistentMapOf
+import kotlinx.collections.immutable.persistentSetOf
+import kotlinx.collections.immutable.puttingAll
+import kotlinx.collections.immutable.toPersistentMap
+import kotlinx.collections.immutable.toPersistentSet
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlin.concurrent.atomics.ExperimentalAtomicApi
+import kotlin.uuid.Uuid
+
+@Immutable
+data class RepositoryInfo(
+ val iconUrl: String?,
+ val name: String,
+ val url: String,
+ val description: String? = null,
+)
+
+@Immutable
+data class Repository(
+ val info: RepositoryInfo,
+ val status: RepositoryStatus,
+ /**
+ * To avoid the issue of people using ! to sort repositories higher up,
+ * we use the added insertion order.
+ *
+ * We can add an alphabetical sort later,
+ * but the default should not prioritize on name
+ * */
+ val sortingOrder: Int,
+ override val uuid: Uuid
+) : UniqueItem
+
+@Immutable
+sealed class RepositoryStatus {
+ object Loading : RepositoryStatus()
+ object Success : RepositoryStatus()
+ data class Failure(val cause: Throwable) : RepositoryStatus()
+}
+
+@Immutable
+data class Plugin(
+ val info: PluginInfo,
+ val source: PluginDownloadSource?,
+ val status: PluginStatus,
+ val downloadLocation: String?,
+ val repository: Uuid,
+ override val uuid: Uuid = Uuid.random()
+) : UniqueItem
+
+@Immutable
+data class PluginInfo(
+ val name: String,
+ val internalName: String,
+ val version: Int,
+ val tvTypes: PersistentList,
+ val authors: PersistentList,
+ val status: Int,
+ val iconUrl: String?,
+ val language: String?,
+ val description: String?,
+ /**
+ * This is different from source.repositoryUrl,
+ * as this may be a different url than the repo we got this from */
+ val repositoryUrl: String?,
+)
+
+@Immutable
+sealed class PluginStatus {
+ object Idle : PluginStatus()
+ data class Downloading(val progress: Long, val total: Long?) : PluginStatus()
+ data class Failed(val cause: Throwable) : PluginStatus()
+}
+
+@Immutable
+data class PluginDownloadSource(
+ val size: Long?,
+ val internalName: String,
+ val pluginUrl: String,
+ val repositoryUrl: String,
+ val digest: DigestPair?,
+)
+
+@Immutable
+data class PluginSortBy(
+ val query: String,
+ val ignoreCase: Boolean = true,
+) : Comparator {
+ override fun compare(p0: Plugin?, p1: Plugin?): Int {
+ if (p0 == p1) return 0
+ if (p0 == null) return -1
+ if (p1 == null) return 1
+ return score(p1).compareTo(score(p0))
+ }
+
+ fun score(plugin: Plugin): Int {
+ val score1 = Levenshtein.partialRatio(
+ plugin.info.name.lowercase(),
+ query
+ )
+ if (score1 > 80) {
+ return score1
+ }
+ val score2 = plugin.info.description?.take(64)?.lowercase()
+ ?.let { description ->
+ Levenshtein.partialRatio(
+ description,
+ query
+ )
+ } ?: 0
+ return maxOf(score1, score2)
+ }
+}
+
+@Immutable
+data class PluginFilterByQuery(
+ val query: String,
+ private val ratio: Int = 80,
+) : Function1 {
+ override fun invoke(value: Plugin): Boolean {
+ if (query.isEmpty()) return true
+ val score1 = Levenshtein.partialRatio(
+ value.info.name.lowercase(),
+ query
+ )
+
+ if (score1 > ratio) return true
+ val score2 = value.info.description?.take(64)?.lowercase()
+ ?.let { description ->
+ Levenshtein.partialRatio(
+ description,
+ query
+ )
+ }
+
+ return score2 != null && score2 > ratio
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (other === this) return true
+ val other = other as? PluginFilterByQuery ?: return false
+ if (query != other.query) return false
+ if (ratio != other.ratio) return false
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = ratio
+ result = 31 * result + query.hashCode()
+ return result
+ }
+
+ @CheckResult
+ fun update(newQuery: String): PluginFilterByQuery {
+ val lowerCaseQuery = newQuery.lowercase()
+ return if (lowerCaseQuery == query) {
+ this
+ } else {
+ copy(query = lowerCaseQuery)
+ }
+ }
+}
+
+@Immutable
+data class FilterPlugin(
+ val tvTypes: PersistentSet = TvType.entries.toSet().toPersistentSet(),
+ val repository: Uuid? = null,
+ val query: PluginFilterByQuery = PluginFilterByQuery(query = ""),
+ /** We filter by the inverse, as that makes is easy to initalize it as all = {} */
+ val removeLanguages: PersistentSet = persistentSetOf(),
+) : Function1 {
+ override fun invoke(plugin: Plugin): Boolean =
+ /** Filter by repository */
+ (repository == null || (plugin.repository == repository)) &&
+ /** Filter by type */
+ (if (plugin.info.tvTypes.isEmpty()) {
+ /**
+ * This is a strange edge-case, but if no tv-types are recognized they
+ * automatically should be treated as "Other" to avoid always filtering them
+ */
+ tvTypes.contains(TvType.Others)
+ } else {
+ plugin.info.tvTypes.any(tvTypes::contains)
+ }) &&
+ /** Filter by language */
+ !(plugin.info.language?.let(removeLanguages::contains)
+ ?: removeLanguages.contains("")) &&
+ /** Filter by query */
+ query(plugin)
+}
+
+@Immutable
+data class DerivedRepositoryState(
+ /**
+ * This is ***readonly***!
+ *
+ * TvType.ordinal -> Int, we use an IntArray to prevent boxing and such.
+ *
+ * We do not win very much having a persistantList/persitantMap here even if it is a bit more
+ * "correct".
+ * */
+ private val countOfTypes: IntArray = IntArray(TvType.entries.size),
+ val downloadedAndEnabled: Int = 0,
+ val downloadedAndDisabled: Int = 0,
+ val notDownloaded: Int = 0,
+ /**
+ * Map of strictly positive values corresponding with the count of each language
+ * where null/blank -> ""
+ *
+ * This is for the language filter, to only showcase relevant languages
+ * */
+ val languageCount: PersistentMap = persistentMapOf()
+) : DerivedState {
+ val total get() = downloadedAndDisabled + downloadedAndEnabled + notDownloaded
+
+ fun containsType(type: TvType): Boolean = countOfTypes[type.ordinal] > 0
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is DerivedRepositoryState) return false
+ if (downloadedAndEnabled != other.downloadedAndEnabled) return false
+ if (downloadedAndDisabled != other.downloadedAndDisabled) return false
+ if (notDownloaded != other.notDownloaded) return false
+ if (!countOfTypes.contentEquals(other.countOfTypes)) return false
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = downloadedAndEnabled
+ result = 31 * result + downloadedAndDisabled
+ result = 31 * result + notDownloaded
+ result = 31 * result + countOfTypes.contentHashCode()
+ return result
+ }
+
+ override fun clearing(): DerivedRepositoryState = DerivedRepositoryState()
+
+ override fun adding(value: Plugin): DerivedRepositoryState {
+ val isDownloaded = value.downloadLocation != null
+ val isDisabled = value.info.status == 0
+ val isEnabled = !isDisabled
+ val isNotDownloaded = !isDownloaded
+
+ val newCountOfTypes = countOfTypes.copyOf()
+ for (type in value.info.tvTypes) {
+ newCountOfTypes[type.ordinal] = newCountOfTypes[type.ordinal] + 1
+ }
+ if (value.info.tvTypes.isEmpty()) {
+ newCountOfTypes[TvType.Others.ordinal] += 1
+ }
+ val language = if (value.info.language.isNullOrBlank()) "" else value.info.language
+ val count = languageCount[language]?.plus(1) ?: 1
+ val newLanguageCount = languageCount.putting(language, count)
+ return copy(
+ languageCount = newLanguageCount,
+ downloadedAndEnabled = downloadedAndEnabled + if (isDownloaded && isEnabled) 1 else 0,
+ downloadedAndDisabled = downloadedAndDisabled + if (isDownloaded && isDisabled) 1 else 0,
+ notDownloaded = notDownloaded + if (isNotDownloaded) 1 else 0,
+ countOfTypes = newCountOfTypes
+ )
+ }
+
+ override fun removing(value: Plugin): DerivedRepositoryState {
+ val isDownloaded = value.downloadLocation != null
+ val isDisabled = value.info.status == 0
+ val isEnabled = !isDisabled
+ val isNotDownloaded = !isDownloaded
+
+ val newCountOfTypes = countOfTypes.copyOf()
+ for (type in value.info.tvTypes) {
+ newCountOfTypes[type.ordinal] = newCountOfTypes[type.ordinal] + 1
+ }
+ val language = if (value.info.language.isNullOrBlank()) "" else value.info.language
+ val count = languageCount[language]?.minus(1) ?: 0
+ val newLanguageCount = if (count <= 0) {
+ languageCount.removing(language)
+ } else {
+ languageCount.putting(language, count)
+ }
+ return copy(
+ languageCount = newLanguageCount,
+ downloadedAndEnabled = downloadedAndEnabled - if (isDownloaded && isEnabled) 1 else 0,
+ downloadedAndDisabled = downloadedAndDisabled - if (isDownloaded && isDisabled) 1 else 0,
+ notDownloaded = notDownloaded - if (isNotDownloaded) 1 else 0,
+ countOfTypes = newCountOfTypes
+ )
+ }
+}
+
+@Immutable
+data class DerivedPluginsState(
+ val sumOfRepositories: DerivedRepositoryState = DerivedRepositoryState(),
+ val repositories: PersistentMap = persistentMapOf(),
+) : DerivedState {
+ /** This is ***readonly***! Do not mutate */
+ val downloadedAndEnabled: Int get() = sumOfRepositories.downloadedAndEnabled
+ val downloadedAndDisabled: Int get() = sumOfRepositories.downloadedAndDisabled
+ val notDownloaded: Int get() = sumOfRepositories.notDownloaded
+
+ companion object {
+ val zero = DerivedRepositoryState()
+ }
+
+ override fun clearing(): DerivedPluginsState = DerivedPluginsState()
+
+ override fun adding(value: Plugin): DerivedPluginsState {
+ val current = repositories[value.repository] ?: zero
+ val newRepository = current.adding(value)
+
+ return copy(
+ sumOfRepositories = sumOfRepositories.adding(value),
+ repositories = repositories.putting(value.repository, newRepository)
+ )
+ }
+
+ override fun removing(value: Plugin): DerivedPluginsState {
+ val current = repositories[value.repository] ?: zero
+ val newRepository = current.removing(value)
+
+ return copy(
+ sumOfRepositories = sumOfRepositories.removing(value),
+ repositories = repositories.putting(value.repository, newRepository)
+ )
+ }
+}
+
+@Immutable
+data class ExtensionsState(
+ val repositories: SearchableData = SearchableData.from(
+ data = persistentMapOf(),
+ sortedBy = sortByInt(Repository::sortingOrder)
+ ),
+ val plugins: SearchableDataState = SearchableDataState.from(
+ data = persistentMapOf(),
+ sortedBy = SortByName { it.info.name },
+ state = DerivedPluginsState()
+ ),
+ val pluginFilter: FilterPlugin = FilterPlugin(),
+ //val repoFilter: FilterByQuery = FilterByQuery(query = "") { it.info.name },
+ val addDialog: AddRepositoryDialog? = null,
+ val searchPlugins: Boolean = false,
+ val updateAllPluginsDialog: PluginsUpdatingDialog? = null,
+) {
+ val derivedRepositoryState: DerivedRepositoryState =
+ pluginFilter.repository?.let(plugins.state.repositories::get)
+ ?: plugins.state.sumOfRepositories
+}
+
+@Immutable
+data class PluginsUpdatingDialog(
+ val total: Int,
+ val downloaded: Int,
+ val failed: Int,
+) {
+ val progress get() = downloaded + failed
+}
+
+@Immutable
+data class AddRepositoryDialog(
+ val url: String = "",
+ val loading: Boolean = false,
+ val error: Throwable? = null,
+)
+
+sealed class ExtensionsEffect {
+ data class RepositoryAdded(val repository: RepositoryInfo, val uuid: Uuid) : ExtensionsEffect()
+}
+
+@InternalAPI
+interface RepositoryManager {
+ /** Returns an ***ordered*** list of installed repositories */
+ fun getRepositories(): List
+
+ @Throws
+ suspend fun getPlugins(
+ repository: RepositoryInfo,
+ repositoryUuid: Uuid
+ ): Pair>
+
+ fun getLocalPlugins(localUuid: Uuid): List
+
+ @Throws
+ suspend fun deletePlugin(location: String)
+
+ @Throws
+ suspend fun deleteRepository(repository: RepositoryInfo)
+
+ @Throws
+ suspend fun downloadPlugin(
+ source: PluginDownloadSource,
+ downloadProgress: (Long, Long?) -> Unit,
+ ): String
+
+ suspend fun updateRepositoryInfo(repository: RepositoryInfo)
+}
+
+@Immutable
+sealed class ExtensionsAction {
+ object Init : ExtensionsAction()
+ data class DeleteRepository(val uuid: Uuid) : ExtensionsAction()
+ data class DeletePluginsInRepository(val uuid: Uuid) : ExtensionsAction()
+ data class DeletePlugin(val uuid: Uuid) : ExtensionsAction()
+ data class DownloadPlugin(val uuid: Uuid) : ExtensionsAction()
+
+ //data class DownloadRepository(val repository: RepositoryInfo) : ExtensionsAction()
+ data class DownloadRepositoryWithDialog(val url: String) : ExtensionsAction()
+ data class SelectRepository(val uuid: Uuid?) : ExtensionsAction()
+ data class SelectPlugin(val uuid: Uuid) : ExtensionsAction()
+ data class ReloadRepository(val uuid: Uuid) : ExtensionsAction()
+ data class Search(val query: String) : ExtensionsAction()
+ data class FilterByTypes(val types: PersistentSet) : ExtensionsAction()
+ data class FilterByInverseLanguages(val removeLanguages: PersistentSet) :
+ ExtensionsAction()
+
+ data class SetRepositoryDialog(val dialog: AddRepositoryDialog?) : ExtensionsAction()
+ object ClearPluginSearch : ExtensionsAction()
+ object UpdateAllPlugins : ExtensionsAction()
+ object DeleteAllRepositories : ExtensionsAction()
+ object CancelPluginsUpdate : ExtensionsAction()
+ data class CancelPluginDownloader(val uuid: Uuid) : ExtensionsAction()
+}
+
+/**
+ * Here viewmodel acts as a view state holder that keeps everything up to date,
+ * and RepositoryManager is invoked to handle the platform specific features
+ *
+ * UI -(onAction)-> Viewmodel -(invokes)-> RepositoryManager
+ * <---(state)---/ <--(callback)--/
+ * */
+class ExtensionsViewModel2(
+ private val settings: AppSettings,
+ private val manager: RepositoryManager,
+ private val localUuid: Uuid = Uuid.random(),
+) : ViewModel(), StateContainer by DefaultStateContainer(ExtensionsState()),
+ EffectContainer by DefaultEffectContainer(),
+ ActionHandler {
+ private val localRepository = Repository(
+ info = RepositoryInfo(
+ iconUrl = null,
+ name = "Local plugins",
+ url = "https://github.com/recloudstream/cloudstream",
+ description = "Locally installed plugins"
+ ),
+ status = RepositoryStatus.Success,
+ uuid = localUuid,
+ sortingOrder = -1,
+ )
+ private val repositoryDispatcher = MultiActiveQuery(dispatcher = Dispatchers.IO)
+ private val downloadDispatcher = MultiActiveQuery(dispatcher = Dispatchers.IO)
+ private val addRepositoryDispatcher = SingleActiveQuery(dispatcher = Dispatchers.IO)
+ private val updatePluginsDispatcher = SingleActiveQuery(dispatcher = Dispatchers.IO)
+
+ @OptIn(ExperimentalAtomicApi::class)
+ override fun onAction(action: ExtensionsAction) {
+ when (action) {
+ ExtensionsAction.UpdateAllPlugins -> {
+ viewModelScope.launch {
+ updateAllPlugins()
+ }
+ }
+
+ ExtensionsAction.ClearPluginSearch -> {
+ updateState {
+ val newPluginFilter = pluginFilter.copy(repository = null)
+ copy(
+ searchPlugins = false,
+ pluginFilter = newPluginFilter,
+ plugins = plugins.filteringBy(newPluginFilter),
+ )
+ }
+ }
+
+ ExtensionsAction.Init -> {
+ viewModelScope.launch {
+ init()
+ }
+ }
+
+ ExtensionsAction.CancelPluginsUpdate -> {
+ viewModelScope.launch {
+ updatePluginsDispatcher.launch {
+ updateState {
+ copy(updateAllPluginsDialog = null)
+ }
+ }
+ }
+ }
+
+ ExtensionsAction.DeleteAllRepositories -> {
+ val repositories = state.value.repositories.data.values
+ viewModelScope.launch {
+ for (repository in repositories) {
+ deleteRepository(repository)
+ }
+ }
+ }
+
+ is ExtensionsAction.CancelPluginDownloader -> {
+ viewModelScope.launch {
+ downloadDispatcher.cancelOne(action.uuid)
+ }
+ }
+
+ is ExtensionsAction.ReloadRepository -> {
+ val uuid = action.uuid
+ val current = state.value.repositories[uuid] ?: return
+ viewModelScope.launch {
+ updateState {
+ copy(repositories = repositories.updating(uuid) { copy(status = RepositoryStatus.Loading) })
+ }
+ loadRepository(uuid = uuid, repository = current.info)
+ }
+ }
+
+ is ExtensionsAction.SetRepositoryDialog -> {
+ updateState {
+ copy(addDialog = action.dialog)
+ }
+ }
+
+ is ExtensionsAction.DeletePluginsInRepository -> {
+ viewModelScope.launch {
+ val allPlugins = state.value.plugins
+ for (plugin in allPlugins.data.values) {
+ if (plugin.repository == action.uuid) {
+ deletePlugin(plugin)
+ }
+ }
+ }
+ }
+
+ is ExtensionsAction.DeleteRepository -> {
+ val repository = state.value.repositories[action.uuid] ?: return
+ viewModelScope.launch {
+ deleteRepository(repository)
+ }
+ }
+
+ is ExtensionsAction.SelectRepository -> {
+ updateState {
+ val newPluginFilter = pluginFilter.copy(repository = action.uuid)
+ copy(
+ plugins = plugins.filteringBy(newPluginFilter),
+ pluginFilter = newPluginFilter,
+ searchPlugins = true,
+ )
+ }
+ }
+
+ is ExtensionsAction.SelectPlugin -> {
+ updateState {
+ val plugin = plugins[action.uuid] ?: return@updateState this
+ val newPluginFilter = pluginFilter.copy(repository = plugin.repository)
+ copy(
+ plugins = plugins.filteringBy(newPluginFilter),
+ pluginFilter = newPluginFilter,
+ searchPlugins = true,
+ )
+ }
+ }
+
+ is ExtensionsAction.FilterByInverseLanguages -> {
+ updateState {
+ val newPluginFilter =
+ pluginFilter.copy(removeLanguages = action.removeLanguages)
+ copy(
+ plugins = plugins.filteringBy(newPluginFilter),
+ pluginFilter = newPluginFilter
+ )
+ }
+ }
+
+ is ExtensionsAction.Search -> {
+ updateState {
+ val newPluginFilter =
+ pluginFilter.copy(query = pluginFilter.query.update(action.query))
+ val activeQuery = action.query.isNotEmpty()
+ copy(
+ plugins = plugins.searchBy(
+ newSorting = if (activeQuery) {
+ PluginSortBy(query = action.query.lowercase())
+ } else {
+ SortByName { it.info.name }
+ },
+ newFilter = newPluginFilter
+ ),
+ pluginFilter = newPluginFilter,
+ searchPlugins = pluginFilter.repository != null || activeQuery,
+ )
+ }
+ }
+
+ is ExtensionsAction.DeletePlugin -> {
+ val plugin = state.value.plugins[action.uuid] ?: return
+ viewModelScope.launch {
+ deletePlugin(plugin)
+ }
+ }
+
+ is ExtensionsAction.DownloadPlugin -> {
+ val plugin = state.value.plugins[action.uuid] ?: return
+ viewModelScope.launch {
+ downloadDispatcher.launch(plugin.uuid) {
+ downloadPlugin(plugin)
+ }
+ }
+ }
+
+ is ExtensionsAction.DownloadRepositoryWithDialog -> {
+ viewModelScope.launch {
+ downloadRepositoryWithDialog(action)
+ }
+ }
+
+ is ExtensionsAction.FilterByTypes -> {
+ updateState {
+ val newPluginFilter = pluginFilter.copy(tvTypes = action.types)
+ copy(
+ plugins = plugins.filteringBy(newPluginFilter),
+ pluginFilter = newPluginFilter
+ )
+ }
+ }
+ }
+ }
+
+ private suspend fun onRepositoryAdded(
+ repositoryUuid: Uuid,
+ newRepositoryInfo: RepositoryInfo,
+ pluginsFetched: List
+ ) {
+ manager.updateRepositoryInfo(newRepositoryInfo)
+ updateState {
+ /**
+ * The unique id of a plugin is the repository + source,
+ * if source does not exist because it is an internal plugin,
+ * then use the download location
+ * */
+ fun uniqueId(plugin: Plugin) =
+ plugin.repository to (plugin.source?.pluginUrl ?: plugin.downloadLocation)
+
+ val oldPlugins = plugins.data.values.associate { plugin ->
+ uniqueId(plugin) to plugin.uuid
+ }
+
+ /**
+ * NSFW is a unique setting that we filter here, instead of the regular filter
+ * to avoid issues where the UI says something else that what we can see
+ *
+ * Moreover, we only filter remove plugins, as you should always have access to "local"
+ * plugins
+ * */
+ val includeNSFW =
+ settings.provider.preferredMedia.get().contains(TvType.NSFW.ordinal.toString())
+ val filteredPluginsFetched = if (includeNSFW) {
+ pluginsFetched
+ } else {
+ pluginsFetched.filterNot { it.info.tvTypes.contains(TvType.NSFW) }
+ }
+
+ /** Reuse the uuid if possible, to avoid duplicates and avoid clearing UI */
+ val newPluginsFetched = filteredPluginsFetched.associate { plugin ->
+ val newUuid = oldPlugins[uniqueId(plugin)] ?: plugin.uuid
+ newUuid to if (newUuid != plugin.uuid) {
+ plugin.copy(uuid = newUuid)
+ } else {
+ plugin
+ }
+ }
+
+ val newPlugins = plugins.data.mutate { edit ->
+ for ((uuid, plugin) in plugins.data) {
+ if (plugin.repository == repositoryUuid) {
+ edit.remove(uuid)
+ }
+ }
+ edit.putAll(newPluginsFetched)
+ }
+
+ /**
+ * Order = `current ?: max + 1 ?: 0` as that will keep the insertion order
+ * even if we can remove repos in the middle of the process.
+ *
+ * e.g. 0,1,2 -> 0,1,2,3
+ * 0,2 -> 0,2,3
+ *
+ * But after reloading, 0,2,3 will become 0,1,2 again
+ * */
+ val sortingOrder =
+ repositories[repositoryUuid]?.sortingOrder ?: repositories.data.values.maxOfOrNull(
+ Repository::sortingOrder
+ )?.plus(1) ?: 0
+
+ copy(
+ plugins = plugins.setTo(newPlugins),
+ repositories = repositories.adding(
+ repositoryUuid, Repository(
+ status = RepositoryStatus.Success,
+ info = newRepositoryInfo,
+ uuid = repositoryUuid,
+ sortingOrder = sortingOrder,
+ )
+ )
+ )
+ }
+ }
+
+ private suspend fun deleteRepository(repository: Repository) {
+ /** Cancel repositoryDispatcher loading to prevent race */
+ repositoryDispatcher.launch(repository.uuid) {
+ updateState {
+ copy(repositories = repositories.updating(repository.uuid) {
+ copy(status = RepositoryStatus.Loading)
+ })
+ }
+ runCatching { manager.deleteRepository(repository.info) }.onSuccess {
+ /** -> Remove all visual state of this plugin */
+ updateState {
+ copy(
+ repositories = repositories.removing(repository.uuid),
+ plugins = plugins.setTo(plugins.data.filter { it.value.repository != repository.uuid }
+ .toPersistentMap())
+ )
+ }
+ }.onFailure { e ->
+ /** -> Unable to remove, so we just show the error */
+ updateState {
+ copy(repositories = repositories.updating(repository.uuid) {
+ copy(status = RepositoryStatus.Failure(e))
+ })
+ }
+ }
+ }
+ }
+
+ private suspend fun deletePlugin(plugin: Plugin) {
+ val downloadLocation = plugin.downloadLocation ?: return
+ downloadDispatcher.launch(plugin.uuid) {
+ runCatching { manager.deletePlugin(downloadLocation) }.onFailure { t ->
+ updateState {
+ copy(plugins = plugins.updating(plugin.uuid) {
+ copy(status = PluginStatus.Failed(t))
+ })
+ }
+ }.onSuccess {
+ updateState {
+ copy(plugins = plugins.updating(plugin.uuid) {
+ copy(status = PluginStatus.Idle, downloadLocation = null)
+ })
+ }
+ }
+ }
+ }
+
+ private suspend fun downloadRepositoryWithDialog(action: ExtensionsAction.DownloadRepositoryWithDialog) {
+ val trimmedUrl = action.url.trim()
+ val uuid = Uuid.random()
+
+ val found =
+ state.value.repositories.data.values.firstOrNull { it.info.url == trimmedUrl }
+ if (found != null) {
+ postEffect { RepositoryAdded(found.info, found.uuid) }
+ updateState {
+ copy(addDialog = null)
+ }
+ return
+ }
+ updateState {
+ copy(
+ addDialog = AddRepositoryDialog(
+ /** Not a bug, the dialog should not get trimmed */
+ url = action.url,
+ loading = true,
+ error = null
+ )
+ )
+ }
+ val repository = RepositoryInfo(
+ iconUrl = null,
+ name = "Pending",
+ url = trimmedUrl,
+ description = null
+ )
+ addRepositoryDispatcher.launch {
+ runCatching {
+ manager.getPlugins(
+ repository,
+ uuid
+ )
+ }.onFailure { t ->
+ /** Cancellation means that we restarted this, so the caller updates the status */
+ if (!isActive) {
+ return@launch
+ }
+ updateState {
+ copy(
+ addDialog = AddRepositoryDialog(
+ /** Not a bug, the dialog should not get trimmed */
+ url = action.url,
+ loading = false,
+ error = t
+ )
+ )
+ }
+ }.onSuccess { (newRepositoryInfo, pluginsFetched) ->
+ val found =
+ state.value.repositories.data.values.firstOrNull { it.info.url == newRepositoryInfo.url }
+ if (found != null) {
+ // Shortcodes can have a different url from the input
+ updateState {
+ copy(addDialog = null)
+ }
+ postEffect {
+ RepositoryAdded(
+ found.info,
+ found.uuid
+ )
+ }
+ return@launch
+ }
+ onRepositoryAdded(
+ repositoryUuid = uuid,
+ newRepositoryInfo = newRepositoryInfo,
+ pluginsFetched = pluginsFetched
+ )
+ updateState {
+ copy(addDialog = null)
+ }
+ postEffect { RepositoryAdded(newRepositoryInfo, uuid) }
+ }
+ }
+ }
+
+ private suspend fun updateAllPlugins() = updatePluginsDispatcher.launch {
+ val plugins = state.value.plugins.data.values.filter { it.downloadLocation != null }
+ val updateMutex = Mutex()
+ var failed = 0
+ var downloaded = 0
+ val total = plugins.size
+
+ updateState {
+ copy(
+ updateAllPluginsDialog = PluginsUpdatingDialog(
+ total = total,
+ failed = 0,
+ downloaded = 0
+ )
+ )
+ }
+
+ for (plugin in plugins) {
+ downloadDispatcher.launch(plugin.uuid) {
+ val isSuccess = downloadPlugin(plugin)
+ if (!isActive) {
+ return@launch
+ }
+
+ updateMutex.withLock {
+ if (isSuccess) {
+ downloaded += 1
+ } else {
+ failed += 1
+ }
+ updateState {
+ copy(
+ updateAllPluginsDialog = PluginsUpdatingDialog(
+ total = total,
+ failed = failed,
+ downloaded = downloaded
+ )
+ )
+ }
+ }
+ }
+ }
+
+ if (!isActive) {
+ return@launch
+ }
+
+ updateMutex.withLock {
+ updateState {
+ copy(
+ updateAllPluginsDialog = PluginsUpdatingDialog(
+ total = total,
+ failed = failed,
+ downloaded = downloaded
+ )
+ )
+ }
+ }
+ }
+
+ private suspend fun downloadPlugin(plugin: Plugin): Boolean {
+ val source = plugin.source ?: return false
+ runCatching {
+ manager.downloadPlugin(
+ source = source,
+ downloadProgress = { downloadedBytes, totalBytes ->
+ updateState {
+ copy(plugins = plugins.updating(plugin.uuid) {
+ copy(
+ status = PluginStatus.Downloading(
+ total = totalBytes,
+ progress = downloadedBytes
+ )
+ )
+ })
+ }
+ }
+ )
+ }.onFailure { t ->
+ updateState {
+ copy(plugins = plugins.updating(plugin.uuid) {
+ copy(status = PluginStatus.Failed(t))
+ })
+ }
+ return false
+ }.onSuccess { downloadLocation ->
+ updateState {
+ copy(plugins = plugins.updating(plugin.uuid) {
+ copy(status = PluginStatus.Idle, downloadLocation = downloadLocation)
+ })
+ }
+ return true
+ }
+ return false
+ }
+
+ private suspend fun loadRepository(uuid: Uuid, repository: RepositoryInfo) {
+ repositoryDispatcher.launch(uuid) {
+ runCatching {
+ manager.getPlugins(
+ repository,
+ uuid
+ )
+ }.onFailure { t ->
+ /** Cancellation means that we restarted this, so the caller updates the status */
+ if (!isActive) {
+ return@launch
+ }
+ updateState {
+ copy(repositories = repositories.updating(uuid) {
+ copy(status = RepositoryStatus.Failure(t))
+ })
+ }
+ }.onSuccess { (newRepositoryInfo, pluginsFetched) ->
+ onRepositoryAdded(
+ repositoryUuid = uuid,
+ newRepositoryInfo = newRepositoryInfo,
+ pluginsFetched = pluginsFetched
+ )
+ }
+ }
+ }
+
+ private suspend fun init() {
+ val repos = manager.getRepositories()
+
+ updateState {
+ val oldRepositories = repositories.data.values.associate { repository ->
+ repository.info.url to repository.uuid
+ }
+
+ val newRepositories =
+ persistentMapOf().puttingAll(repos.mapIndexed { index, info ->
+ val uuid = oldRepositories[info.url] ?: Uuid.random()
+ uuid to Repository(
+ info = info,
+ status = RepositoryStatus.Loading,
+ uuid = uuid,
+ sortingOrder = index
+ )
+ })
+
+ val oldLocalRepository = repositories[localUuid]
+
+ /**
+ * Clear the old repositories if they are deleted from outside the context,
+ * but include the local repository
+ * */
+ val newRepositoriesWithLocal = if (oldLocalRepository != null) {
+ newRepositories.putting(localUuid, oldLocalRepository)
+ } else {
+ newRepositories
+ }
+
+ copy(repositories = repositories.setTo(newData = newRepositoriesWithLocal))
+ }
+
+ repositoryDispatcher.launch(localUuid) {
+ val pluginsFetched = manager.getLocalPlugins(localUuid)
+ updateState {
+ val oldPlugins = plugins.data.values.associate { it.downloadLocation to it.uuid }
+
+ /** Reuse the uuid if possible, to avoid duplicates and avoid clearing UI */
+ val newPluginsFetched = pluginsFetched.associate { plugin ->
+ require(plugin.downloadLocation != null) {
+ "All local plugins must have a download path"
+ }
+
+ val newUuid = oldPlugins[plugin.downloadLocation] ?: plugin.uuid
+ newUuid to if (newUuid != plugin.uuid) {
+ plugin.copy(uuid = newUuid)
+ } else {
+ plugin
+ }
+ }
+
+ /**
+ * Remove ALL the old ones instead of putting as something might have been removed
+ * (this is *very* *very* unlikely, but more correct
+ * */
+ val newPlugins = plugins.data.mutate { edit ->
+ for ((uuid, plugin) in plugins.data) {
+ if (plugin.repository == localUuid) {
+ edit.remove(uuid)
+ }
+ }
+ edit.putAll(newPluginsFetched)
+ }
+
+ copy(
+ plugins =
+ if (newPlugins != plugins) {
+ plugins.setTo(newPlugins)
+ } else {
+ plugins
+ },
+ repositories =
+ /** Only show local plugins if those are actually used */
+ if (newPluginsFetched.isEmpty()) {
+ repositories.removing(localUuid)
+ } else {
+ repositories.adding(localUuid, localRepository)
+ }
+ )
+ }
+ }
+
+ val repositories = state.value.repositories.data
+ for ((uuid, repository) in repositories) {
+ loadRepository(uuid, repository.info)
+ }
+ }
+}
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt b/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt
index e54441595..0af8eaf40 100644
--- a/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt
+++ b/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -21,6 +20,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
+import com.lagradost.cloudstream4.compose.WhiteIconButton
import com.lagradost.cloudstream4.generated.resources.Res
import com.lagradost.cloudstream4.generated.resources.action_bar_up_description
import com.lagradost.cloudstream4.generated.resources.arrow_back
@@ -104,7 +104,7 @@ fun AppBar(
windowInsets = WindowInsets(0, 0, 0, 0),
navigationIcon = {
if (isActionMode) {
- IconButton(onClick = onCancelActionMode) {
+ WhiteIconButton(onClick = onCancelActionMode) {
Icon(
painter = painterResource(Res.drawable.close),
contentDescription = stringResource(Res.string.cancel),
@@ -112,7 +112,7 @@ fun AppBar(
}
} else {
navigateUp?.let {
- IconButton(onClick = it) {
+ WhiteIconButton(onClick = it) {
UpIcon(navigationIcon = navigationIcon)
}
}
diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/BasePreferenceWidget.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/BasePreferenceWidget.kt
index e103a1440..467e0b3e5 100644
--- a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/BasePreferenceWidget.kt
+++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/BasePreferenceWidget.kt
@@ -8,6 +8,7 @@ import androidx.compose.animation.core.repeatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -36,12 +37,13 @@ import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
@Composable
-internal fun BasePreferenceWidget(
+fun BasePreferenceWidget(
modifier: Modifier = Modifier,
title: String? = null,
subcomponent: @Composable (ColumnScope.() -> Unit)? = null,
icon: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
+ onLongClick: (() -> Unit)? = null,
widget: @Composable (() -> Unit)? = null,
) {
val highlighted = LocalPreferenceHighlighted.current
@@ -51,7 +53,7 @@ internal fun BasePreferenceWidget(
.highlightBackground(highlighted)
.focusOutline()
.sizeIn(minHeight = minHeight)
- .clickable(enabled = onClick != null, onClick = { onClick?.invoke() })
+ .combinedClickable(enabled = onClick != null, onLongClick = onLongClick, onClick = { onClick?.invoke() })
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -90,11 +92,13 @@ internal fun BasePreferenceWidget(
@Composable
internal fun Modifier.highlightBackground(highlighted: Boolean): Modifier {
var highlightFlag by remember { mutableStateOf(false) }
- LaunchedEffect(Unit) {
+ LaunchedEffect(highlighted) {
if (highlighted) {
highlightFlag = true
delay(3.seconds)
highlightFlag = false
+ } else {
+ highlightFlag = false
}
}
val highlight by animateColorAsState(
@@ -122,6 +126,6 @@ internal fun Modifier.highlightBackground(highlighted: Boolean): Modifier {
}
internal val TrailingWidgetBuffer = 16.dp
-internal val PrefsHorizontalPadding = 16.dp
+val PrefsHorizontalPadding = 16.dp
internal val PrefsVerticalPadding = 16.dp
internal val TitleFontSize = 16.sp
diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.jvm.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.jvm.kt
new file mode 100644
index 000000000..5e5e3d240
--- /dev/null
+++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/ClipboardHelper.jvm.kt
@@ -0,0 +1,26 @@
+package com.lagradost.cloudstream4
+
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.platform.ClipEntry
+import androidx.compose.ui.platform.Clipboard
+import androidx.compose.ui.platform.asAwtTransferable
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.awt.datatransfer.DataFlavor
+import java.awt.datatransfer.StringSelection
+
+@OptIn(ExperimentalComposeUiApi::class)
+actual suspend fun Clipboard.getPlainText(): String? = withContext(Dispatchers.IO) {
+ val transferable = this@getPlainText.getClipEntry()?.asAwtTransferable
+ if (transferable != null && transferable.isDataFlavorSupported(DataFlavor.stringFlavor))
+ transferable.getTransferData(DataFlavor.stringFlavor) as? String
+ else null
+}
+
+@OptIn(ExperimentalComposeUiApi::class)
+actual suspend fun Clipboard.setPlainText(
+ label: String,
+ text: String
+) {
+ this.setClipEntry(ClipEntry(StringSelection(text)))
+}
\ No newline at end of file
From ad44a64f0cd660433ffc84994369c13336ae4015 Mon Sep 17 00:00:00 2001
From: Osten <11805592+LagradOst@users.noreply.github.com>
Date: Wed, 21 Oct 2026 22:51:31 +0200
Subject: [PATCH 6/6] Fixed backup frequency (#3271)
---
.../ui/settings/SettingsUpdatesScreen.kt | 13 +++++++++----
1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt
index 3ce3ad6fb..cc867ae25 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt
@@ -13,17 +13,15 @@ import androidx.compose.ui.res.integerArrayResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
-import com.lagradost.cloudstream3.AutoDownloadMode
import com.lagradost.cloudstream3.BuildConfig
import com.lagradost.cloudstream3.CloudStreamApp
import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.MainActivityScreen
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
-import com.lagradost.cloudstream3.plugins.PluginManager
+import com.lagradost.cloudstream3.services.BackupWorkManager
import com.lagradost.cloudstream3.utils.BackupUtils
import com.lagradost.cloudstream3.utils.BackupUtils.restorePrompt
-import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.InAppUpdater.installPreReleaseIfNeeded
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream4.AppSettings
@@ -147,7 +145,14 @@ object SettingsUpdatesScreen : SearchableSettings {
entries = integerArrayResource(R.array.periodic_work_values).zip(
stringArrayResource(R.array.periodic_work_names)
).toMap(),
- preference = settings.backup.frequency
+ preference = settings.backup.frequency,
+ onValueChanged = { value ->
+ BackupWorkManager.enqueuePeriodicWork(
+ CloudStreamApp.context,
+ value.toLong()
+ )
+ return@ListPreference true
+ }
),
Preference.PreferenceItem.TextPreference(
title = stringResource(R.string.backup_path_title),