Compare commits

...

5 commits

Author SHA1 Message Date
Osten
ad44a64f0c
Fixed backup frequency (#3271) 2026-10-21 22:51:31 +02:00
Osten
dc9d1f0f9e
Update extentions screen to Compose (#3258) 2026-10-21 20:55:09 +02:00
Osten
eb962b4fb7
Fixes some minor issue with the +10 button (#3266) 2026-10-18 19:45:11 +02:00
Void
4c4c6796ff
fix: skip notification permission request while biometric lock is enabled (#3256) 2026-10-18 00:07:35 +00:00
firelight
5cddd5b364
Update theintrodb to v4
Closes #3264
2026-10-18 00:04:39 +00:00
42 changed files with 3792 additions and 52 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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

View file

@ -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<Repository>()
}
}
@ -169,7 +169,7 @@ object RepositoryManager {
private suspend fun parsePlugins(pluginUrls: String): List<SitePlugin> {
// 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<Array<SitePlugin>>().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)
}
}

View file

@ -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()

View file

@ -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
}

View file

@ -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,

View file

@ -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))
},

View file

@ -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),
@ -178,7 +183,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 +202,7 @@ object SettingsUpdatesScreen : SearchableSettings {
)
}
}
),
),*/
Preference.PreferenceItem.TextPreference(
title = stringResource(R.string.test_extensions),
subtitle = stringResource(R.string.test_extensions_summary),

View file

@ -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<RepositoryInfo> {
return ((getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
?: emptyArray()) + PREBUILT_REPOSITORIES).map { info ->
info.toInfo()
}
}
override suspend fun getPlugins(
repository: RepositoryInfo,
repositoryUuid: Uuid
): Pair<RepositoryInfo, List<Plugin>> =
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<com.lagradost.cloudstream3.plugins.Repository>()
/**
* 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<Array<SitePlugin>>()
}.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<Plugin> {
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()
)
}
}

View file

@ -302,7 +302,7 @@ class ExtensionsFragment : BaseFragment<FragmentExtensionsBinding>(
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()

View file

@ -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

View file

@ -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"

View file

@ -527,7 +527,8 @@ object AppContextUtils {
val data = RepositoryData(
repo.iconUrl ?: "",
repo.name,
url
url,
repo.description
)
RepositoryManager.addRepository(data)
main {

View file

@ -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<Root>()
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<Stamp> = emptyList(),
@JsonProperty("recap") @SerialName("recap") val recap: List<Stamp> = emptyList(),

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M440,488L404,452Q393,441 376,441Q359,441 348,452Q337,463 337,480Q337,497 348,508L452,612Q464,624 480,624Q496,624 508,612L612,508Q623,497 623,480Q623,463 612,452Q601,441 584,441Q567,441 556,452L520,488L520,360Q520,343 508.5,331.5Q497,320 480,320Q463,320 451.5,331.5Q440,343 440,360L440,488ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>

View file

@ -0,0 +1,51 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512"
android:tint="#fff">
<path
android:fillAlpha="0.5"
android:pathData="m261.69,163.87a1.2,1.61 90,0 0,-1.56 1.43l71.1,266.49a184.55,184.55 0,0 0,83.36 -71.47l-151.49,-195.85a1.2,1.61 90,0 0,-1.41 -0.59z"
android:strokeWidth="7.79"
android:strokeAlpha="0.5"
android:strokeColor="#00000000"
android:strokeLineCap="butt"
android:strokeLineJoin="miter">
<aapt:attr name="android:fillColor">
<gradient
android:endX="380.34"
android:endY="400.66"
android:startX="261.69"
android:startY="163.87"
android:type="linear">
<item
android:color="#00FFFFFF"
android:offset="0" />
<item
android:color="#FF71C2FF"
android:offset="0.5" />
<item
android:color="#FF39AAFF"
android:offset="1" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#2185d0"
android:pathData="m257.53,77.74a184.55,184.55 0,0 0,-183.86 184.55,184.55 184.55,0 0,0 28.18,98.02l153.87,-198.93a2.15,2.88 90,0 1,4.99 0l153.88,198.94a184.55,184.55 0,0 0,28.18 -98.03,184.55 184.55,45 0,0 -184.55,-184.55 184.55,184.55 0,0 0,-0.69 0z"
android:strokeWidth="2.01453"
android:strokeColor="#00000000" />
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M200,760Q167,760 143.5,736.5Q120,713 120,680L120,320L120,320Q103,320 91.5,308.5Q80,297 80,280Q80,263 91.5,251.5Q103,240 120,240L240,240L240,220Q240,203 251.5,191.5Q263,180 280,180L360,180Q377,180 388.5,191.5Q400,203 400,220L400,240L520,240Q537,240 548.5,251.5Q560,263 560,280Q560,297 548.5,308.5Q537,320 520,320L520,320L520,680Q520,713 496.5,736.5Q473,760 440,760L200,760ZM640,720Q623,720 611.5,708.5Q600,697 600,680Q600,663 611.5,651.5Q623,640 640,640L720,640Q737,640 748.5,651.5Q760,663 760,680Q760,697 748.5,708.5Q737,720 720,720L640,720ZM640,560Q623,560 611.5,548.5Q600,537 600,520Q600,503 611.5,491.5Q623,480 640,480L800,480Q817,480 828.5,491.5Q840,503 840,520Q840,537 828.5,548.5Q817,560 800,560L640,560ZM640,400Q623,400 611.5,388.5Q600,377 600,360Q600,343 611.5,331.5Q623,320 640,320L840,320Q857,320 868.5,331.5Q880,343 880,360Q880,377 868.5,388.5Q857,400 840,400L640,400ZM200,320L200,680Q200,680 200,680Q200,680 200,680L440,680Q440,680 440,680Q440,680 440,680L440,320L200,320Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,536L596,652Q607,663 624,663Q641,663 652,652Q663,641 663,624Q663,607 652,596L536,480L652,364Q663,353 663,336Q663,319 652,308Q641,297 624,297Q607,297 596,308L480,424L364,308Q353,297 336,297Q319,297 308,308Q297,319 297,336Q297,353 308,364L424,480L308,596Q297,607 297,624Q297,641 308,652Q319,663 336,663Q353,663 364,652L480,536ZM200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM200,760L760,760Q760,760 760,760Q760,760 760,760L760,200Q760,200 760,200Q760,200 760,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760ZM200,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M508.5,668.5Q520,657 520,640Q520,623 508.5,611.5Q497,600 480,600Q463,600 451.5,611.5Q440,623 440,640Q440,657 451.5,668.5Q463,680 480,680Q497,680 508.5,668.5ZM508.5,508.5Q520,497 520,480L520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320L440,480Q440,497 451.5,508.5Q463,520 480,520Q497,520 508.5,508.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M800,686L720,606L720,576L765,554Q781,546 790.5,532Q800,518 800,500Q800,483 790.5,468.5Q781,454 765,446L720,425L720,240Q720,240 720,240Q720,240 720,240L528,240L518,172Q515,150 498.5,135Q482,120 460,120Q437,120 420.5,135Q404,150 401,172L391,240L354,240L274,160L322,160Q329,109 368,74.5Q407,40 460,40Q512,40 551,74.5Q590,109 598,160L720,160Q753,160 776.5,183.5Q800,207 800,240L800,374Q836,392 858,426Q880,460 880,500Q880,541 858,575Q836,609 800,626L800,686ZM720,760L800,840L200,840Q166,840 143,817Q120,794 120,760L120,635Q120,624 128,615.5Q136,607 147,605Q186,597 213,568.5Q240,540 240,500Q240,461 213,433Q186,405 147,396Q136,393 128,384.5Q120,376 120,365L120,160L200,240L200,326Q256,347 288,394Q320,441 320,500Q320,560 288,607Q256,654 200,675L200,760Q200,760 200,760Q200,760 200,760L720,760ZM577,463L577,463L577,463Q577,463 577,463Q577,463 577,463Q577,463 577,463Q577,463 577,463L577,463L577,463Q577,463 577,463Q577,463 577,463L577,463L577,463Q577,463 577,463Q577,463 577,463Q577,463 577,463Q577,463 577,463L577,463L577,463ZM386,500L386,500Q386,500 386,500Q386,500 386,500L386,500Q386,500 386,500Q386,500 386,500Q386,500 386,500Q386,500 386,500L386,500ZM848,904Q836,916 819.5,916Q803,916 791,904L55,169Q43,157 43.5,140.5Q44,124 56,112Q68,100 84.5,100Q101,100 113,112L848,848Q860,860 860,876Q860,892 848,904Z"/>
</vector>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M558,816Q558,816 558,816Q558,816 558,816L796,742Q791,733 781.5,726.5Q772,720 760,720L558,720Q531,720 515,718Q499,716 482,710L425,691Q409,686 402,671Q395,656 400,640Q405,624 419.5,616.5Q434,609 450,614L492,628Q509,633 530.5,636Q552,639 589,640L593,640Q595,640 596.5,640Q598,640 600,640Q600,629 593.5,619Q587,609 578,606L344,520Q344,520 344,520Q344,520 344,520L280,520L280,740L558,816ZM537,894L280,822L280,822Q272,848 248.5,864Q225,880 200,880L120,880Q87,880 63.5,856.5Q40,833 40,800L40,520Q40,487 63.5,463.5Q87,440 120,440L344,440Q351,440 358,441.5Q365,443 371,445L606,532Q639,544 659.5,574Q680,604 680,640L760,640Q810,640 845,673Q880,706 880,760Q880,782 868.5,794.5Q857,807 833,815L583,893Q572,897 560,897Q548,897 537,894ZM120,800Q120,800 120,800Q120,800 120,800L200,800Q200,800 200,800Q200,800 200,800L200,520Q200,520 200,520Q200,520 200,520L120,520Q120,520 120,520Q120,520 120,520L120,800ZM610.5,482.5Q596,477 584,466L474,358Q443,328 421.5,291.5Q400,255 400,212Q400,157 438.5,118.5Q477,80 532,80Q564,80 592,93.5Q620,107 640,130Q660,107 688,93.5Q716,80 748,80Q803,80 841.5,118.5Q880,157 880,212Q880,255 859,291.5Q838,328 807,358L696,466Q684,477 669.5,482.5Q655,488 640,488Q625,488 610.5,482.5ZM640,408L749,301Q768,282 784,260.5Q800,239 800,212Q800,190 785,175Q770,160 748,160Q734,160 721.5,165.5Q709,171 700,182L671,217Q659,231 640,231Q621,231 609,217L580,182Q571,171 558.5,165.5Q546,160 532,160Q510,160 495,175Q480,190 480,212Q480,239 496,260.5Q512,282 531,301L640,408Q640,408 640,408Q640,408 640,408ZM640,254Q640,254 640,254Q640,254 640,254L640,254Q640,254 640,254Q640,254 640,254Q640,254 640,254Q640,254 640,254Q640,254 640,254Q640,254 640,254L640,254Q640,254 640,254Q640,254 640,254L640,254Q640,254 640,254Q640,254 640,254Q640,254 640,254Q640,254 640,254Q640,254 640,254Q640,254 640,254Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M109,840Q98,840 89,834.5Q80,829 75,820Q70,811 69.5,800.5Q69,790 75,780L445,140Q451,130 460.5,125Q470,120 480,120Q490,120 499.5,125Q509,130 515,140L885,780Q891,790 890.5,800.5Q890,811 885,820Q880,829 871,834.5Q862,840 851,840L109,840ZM178,760L782,760L480,240L178,760ZM508.5,708.5Q520,697 520,680Q520,663 508.5,651.5Q497,640 480,640Q463,640 451.5,651.5Q440,663 440,680Q440,697 451.5,708.5Q463,720 480,720Q497,720 508.5,708.5ZM508.5,588.5Q520,577 520,560L520,440Q520,423 508.5,411.5Q497,400 480,400Q463,400 451.5,411.5Q440,423 440,440L440,560Q440,577 451.5,588.5Q463,600 480,600Q497,600 508.5,588.5ZM480,500L480,500L480,500Z"/>
</vector>

View file

@ -196,7 +196,7 @@
<fragment
android:id="@+id/navigation_settings_extensions"
android:name="com.lagradost.cloudstream3.ui.settings.extensions.ExtensionsFragment"
android:name="com.lagradost.cloudstream3.ui.settings.extensions.ExtensionsFragment2"
android:label="@string/title_settings"
app:enterAnim="@anim/enter_anim"
app:exitAnim="@anim/exit_anim"

View file

@ -813,4 +813,5 @@
<item quantity="other">%d downloads queued</item>
</plurals>
<string name="player_is_live">Live</string>
<string name="installed_plugins">Installed plugins</string>
</resources>

View file

@ -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 ->

View file

@ -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
)
)
)
}

View file

@ -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?

View file

@ -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)
}

View file

@ -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,

View file

@ -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)
},

View file

@ -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,

View file

@ -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)
},

View file

@ -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()
}
}

View file

@ -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,
)

View file

@ -35,9 +35,10 @@ data class FilterByQuery<Value>(
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<Value>(
}
}
/** Sorting by closest match first */
@Immutable
data class SortByMatching<Value>(
val query: String,
val ignoreCase: Boolean = true,
val transform: (Value) -> String,
) : Comparator<Value> {
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<Value>(
@ -122,6 +147,8 @@ fun <K, V> PersistentMap<K, V>.edit(key: K, updater: V.() -> V): PersistentMap<K
* 2. `updating` calls that need both the old and new value, but to store the derived state we need to
* store the old and new values outside the `updating` call.
* 3. `updateState` that might be called twice due to a race
*
* The derived state is calculated *before* the filtering.
* */
interface DerivedState<Self : DerivedState<Self, Value>, Value> {
/** State = {} */
@ -137,6 +164,9 @@ interface DerivedState<Self : DerivedState<Self, Value>, 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<Key, Value, State : DerivedState<State, Value>>(
@ -149,6 +179,9 @@ data class SearchableDataState<Key, Value, State : DerivedState<State, Value>>(
val filteredBy get() = internalData.filteredBy
val sortedBy get() = internalData.sortedBy
@CheckResult
operator fun get(key : Key?) : Value? = internalData[key]
@CheckResult
fun sortingBy(newSorting: Comparator<Value>): SearchableDataState<Key, Value, State> =
copy(internalData = internalData.sortingBy(newSorting))
@ -157,6 +190,13 @@ data class SearchableDataState<Key, Value, State : DerivedState<State, Value>>(
fun filteringBy(newFilter: ((Value) -> Boolean)?): SearchableDataState<Key, Value, State> =
copy(internalData = internalData.filteringBy(newFilter))
@CheckResult
fun searchBy(
newSorting: Comparator<Value>,
newFilter: ((Value) -> Boolean)?
): SearchableDataState<Key, Value, State> =
copy(internalData = internalData.searchBy(newSorting = newSorting, newFilter = newFilter))
@CheckResult
fun updating(
key: Key,
@ -273,6 +313,9 @@ data class SearchableData<Key, Value>(
* */
val sortedBy: Comparator<Value>,
) {
@CheckResult
operator fun get(key : Key?) : Value? = key?.let(data::get)
@CheckResult
fun sortingBy(newSorting: Comparator<Value>): SearchableData<Key, Value> {
if (newSorting == sortedBy) {
@ -294,6 +337,28 @@ data class SearchableData<Key, Value>(
return copy(filteredBy = newFilter, filtered = newFiltered, sorted = newSorted)
}
@CheckResult
fun searchBy(
newSorting: Comparator<Value>,
newFilter: ((Value) -> Boolean)?
): SearchableData<Key, Value> {
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<Key, Value>(
} 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(

View file

@ -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)
}
}

View file

@ -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

View file

@ -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)))
}