Unify CloudStream and Nuvio plugin management into a shared Plugins feature

Addons now only manages Stremio addons. CloudStream3 repo/plugin state
and the Android-only Nuvio scraper settings screen both move into a new
shared KMP feature (feature/plugins) with a real Android data source
combining PluginRepositoryManager and PluginManager, and an iOS stub
data source pending a follow-up Swift implementation.
This commit is contained in:
KhooLy 2026-07-20 03:44:09 +03:00
parent cfd7a4b32b
commit 114f36a590
21 changed files with 812 additions and 513 deletions

View file

@ -18,6 +18,7 @@ import com.fluxa.app.ui.catalog.AppContainer
import com.fluxa.app.ui.catalog.DetailViewModel
import com.fluxa.app.ui.catalog.HomeViewModel
import com.fluxa.app.ui.profile.AndroidProfileDataSource
import com.fluxa.app.ui.settings.AndroidPluginsDataSource
class AndroidFluxaPlatformServices(
homeViewModel: HomeViewModel,
@ -45,13 +46,17 @@ class AndroidFluxaPlatformServices(
override val detailDataSource = AndroidDetailDataSource(detailViewModel, activeProfile)
override val profileDataSource = AndroidProfileDataSource(profileManager)
override val addonStoreDataSource = AndroidAddonStoreDataSource(
pluginManager = AppContainer.pluginManager,
repository = AppContainer.repository,
profileManager = profileManager,
homeViewModel = homeViewModel,
activeProfile = activeProfile,
onProfileChanged = onActiveProfileChanged
)
override val pluginsDataSource = AndroidPluginsDataSource(
pluginRepositoryManager = AppContainer.pluginRepositoryManager,
pluginManager = AppContainer.pluginManager,
language = { activeProfile()?.language ?: "en" }
)
override val authDataSource = AndroidAuthDataSource(
authService = AppContainer.authService,
nuvioCoordinator = AppContainer.nuvioImportCoordinator,

View file

@ -78,6 +78,7 @@ class MainActivity : FragmentActivity() {
@Inject lateinit var profileManager: ProfileManager
@Inject lateinit var pluginManager: PluginManager
@Inject lateinit var pluginRepositoryManager: com.fluxa.app.plugins.PluginRepositoryManager
@Inject lateinit var stremioRepository: StremioRepository
@Inject lateinit var authService: StremioService
@Inject lateinit var nuvioImportCoordinator: com.fluxa.app.data.repository.NuvioAccountImportCoordinator
@ -145,7 +146,7 @@ class MainActivity : FragmentActivity() {
handleIntent(intent)
AppContainer.initialize(profileManager, pluginManager, stremioRepository, authService, nuvioImportCoordinator)
AppContainer.initialize(profileManager, pluginManager, pluginRepositoryManager, stremioRepository, authService, nuvioImportCoordinator)
setContent {
AppTheme {

View file

@ -7,15 +7,9 @@ import com.fluxa.app.data.local.ProfileManager
import com.fluxa.app.data.local.UserProfile
import com.fluxa.app.data.remote.AddonDescriptor
import com.fluxa.app.data.repository.StremioRepository
import com.fluxa.app.plugins.PluginManager
import com.fluxa.app.plugins.cloudstream.InstalledPlugin
import com.fluxa.app.plugins.cloudstream.PluginInfo
import com.fluxa.app.plugins.cloudstream.PluginRepositoryEntry
import com.fluxa.app.shared.feature.addonstore.AddonStoreDataSource
import com.fluxa.app.shared.feature.addonstore.AddonStoreInputType
import com.fluxa.app.shared.feature.addonstore.AddonStoreUiState
import com.fluxa.app.shared.feature.addonstore.CloudstreamPluginUiModel
import com.fluxa.app.shared.feature.addonstore.CloudstreamRepoUiModel
import com.fluxa.app.shared.feature.addonstore.InstalledAddonUiModel
import com.fluxa.app.ui.installLocalAddonForProfile
import com.fluxa.app.ui.moveLocalAddonForProfile
@ -27,7 +21,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
class AndroidAddonStoreDataSource(
private val pluginManager: PluginManager,
private val repository: StremioRepository,
private val profileManager: ProfileManager,
private val homeViewModel: HomeViewModel,
@ -41,12 +34,6 @@ class AndroidAddonStoreDataSource(
val isSubmittingInput: Boolean = false,
val inputError: String? = null,
val refreshingUrl: String? = null,
val openRepoUrl: String? = null,
val openRepoName: String? = null,
val openRepoPlugins: List<PluginInfo> = emptyList(),
val isLoadingRepoPlugins: Boolean = false,
val installingPluginKeys: Set<String> = emptySet(),
val repoDialogError: String? = null,
val profileRevision: Long = 0L,
val addedAddonName: String? = null
)
@ -56,23 +43,18 @@ class AndroidAddonStoreDataSource(
override fun detectInputType(text: String): AddonStoreInputType = when (FluxaCoreNative.addonStoreInputType(text)) {
"stremio_manifest" -> AddonStoreInputType.STREMIO_MANIFEST
"cloudstream_repo" -> AddonStoreInputType.CLOUDSTREAM_REPO
"search_query" -> AddonStoreInputType.SEARCH_QUERY
else -> AddonStoreInputType.UNKNOWN
}
override fun observeAddonStore(): Flow<AddonStoreUiState> = combine(
pluginManager.installedPlugins,
pluginManager.repositories,
installedUserAddons,
extras
) { installedPlugins, repos, userAddonsAndLoaded, ex ->
buildState(installedPlugins, repos, userAddonsAndLoaded.first, userAddonsAndLoaded.second, ex)
) { userAddonsAndLoaded, ex ->
buildState(userAddonsAndLoaded.first, userAddonsAndLoaded.second, ex)
}
private fun buildState(
installedPlugins: List<InstalledPlugin>,
repos: List<PluginRepositoryEntry>,
userAddons: List<AddonDescriptor>,
loaded: Boolean,
ex: Extras
@ -131,33 +113,14 @@ class AndroidAddonStoreDataSource(
)
}
val pluginModels = ex.openRepoPlugins.map { plugin ->
val key = pluginManager.pluginInstallKey(ex.openRepoUrl, plugin.internalName)
CloudstreamPluginUiModel(
internalName = plugin.internalName,
name = plugin.name,
description = plugin.description,
iconUrl = plugin.iconUrl,
typesLabel = plugin.getDisplayTypes(),
isInstalled = installedPlugins.any { pluginManager.pluginInstallKey(it.repositoryUrl, it.internalName) == key }
)
}
return AddonStoreUiState(
installedAddons = merged,
cloudstreamRepos = repos.map { CloudstreamRepoUiModel(name = it.name, url = it.url, iconUrl = it.iconUrl) },
accentColorArgb = profile?.safeAccentColorArgb?.toLong()?.and(0xffffffffL) ?: 0xFF4CAF50L,
isLoading = profile != null && !loaded,
inputText = ex.inputText,
inputDetectedType = ex.detectedType,
isSubmittingInput = ex.isSubmittingInput,
inputError = ex.inputError,
openRepoUrl = ex.openRepoUrl,
openRepoName = ex.openRepoName,
openRepoPlugins = pluginModels,
isLoadingRepoPlugins = ex.isLoadingRepoPlugins,
installingPluginKeys = ex.installingPluginKeys,
repoDialogError = ex.repoDialogError,
addedAddonName = ex.addedAddonName
)
}
@ -212,22 +175,6 @@ class AndroidAddonStoreDataSource(
}
}
}
AddonStoreInputType.CLOUDSTREAM_REPO -> {
extras.update { it.copy(isSubmittingInput = true, inputError = null) }
val normalizedUrl = FluxaCoreNative.normalizeCloudstreamRepoUrl(trimmed)
val result = pluginManager.addRepository(normalizedUrl)
extras.update {
if (result.isSuccess) {
it.copy(inputText = "", isSubmittingInput = false, detectedType = AddonStoreInputType.UNKNOWN)
} else {
it.copy(
isSubmittingInput = false,
inputError = result.exceptionOrNull()?.message
?: AppStrings.t(activeProfile()?.language ?: "en", "addons.repository_add_failed")
)
}
}
}
else -> Unit
}
}
@ -267,43 +214,6 @@ class AndroidAddonStoreDataSource(
extras.update { it.copy(refreshingUrl = null) }
}
override suspend fun openRepo(url: String) {
val repoName = pluginManager.repositories.value.find { it.url == url }?.name?.takeIf { it.isNotBlank() }
extras.update {
it.copy(openRepoUrl = url, openRepoName = repoName, isLoadingRepoPlugins = true, openRepoPlugins = emptyList(), repoDialogError = null)
}
val plugins = pluginManager.getPluginsFromRepository(url)
extras.update { it.copy(openRepoPlugins = plugins, isLoadingRepoPlugins = false) }
}
override suspend fun dismissRepoDialog() {
extras.update { it.copy(openRepoUrl = null, openRepoName = null, openRepoPlugins = emptyList(), repoDialogError = null) }
}
override suspend fun removeRepo(url: String) {
pluginManager.removeRepository(url)
}
override suspend fun toggleRepoPlugin(repoUrl: String, internalName: String) {
val key = pluginManager.pluginInstallKey(repoUrl, internalName)
val plugin = extras.value.openRepoPlugins.find { it.internalName == internalName } ?: return
val isInstalled = pluginManager.installedPlugins.value.any {
pluginManager.pluginInstallKey(it.repositoryUrl, it.internalName) == key
}
extras.update { it.copy(installingPluginKeys = it.installingPluginKeys + key, repoDialogError = null) }
if (isInstalled) {
pluginManager.uninstallPlugin(repoUrl, internalName)
} else {
val result = pluginManager.installPlugin(plugin, repoUrl)
if (result.isFailure) {
extras.update {
it.copy(repoDialogError = result.exceptionOrNull()?.message ?: AppStrings.t(activeProfile()?.language ?: "en", "auto.install_failed"))
}
}
}
extras.update { it.copy(installingPluginKeys = it.installingPluginKeys - key) }
}
override suspend fun dismissAddedAddonDialog() {
extras.update { it.copy(addedAddonName = null) }
}

View file

@ -5,12 +5,15 @@ import com.fluxa.app.data.remote.StremioService
import com.fluxa.app.data.repository.NuvioAccountImportCoordinator
import com.fluxa.app.data.repository.StremioRepository
import com.fluxa.app.plugins.PluginManager
import com.fluxa.app.plugins.PluginRepositoryManager
object AppContainer {
lateinit var profileManager: ProfileManager
private set
lateinit var pluginManager: PluginManager
private set
lateinit var pluginRepositoryManager: PluginRepositoryManager
private set
lateinit var repository: StremioRepository
private set
lateinit var authService: StremioService
@ -21,12 +24,14 @@ object AppContainer {
fun initialize(
profileManager: ProfileManager,
pluginManager: PluginManager,
pluginRepositoryManager: PluginRepositoryManager,
repository: StremioRepository,
authService: StremioService,
nuvioImportCoordinator: NuvioAccountImportCoordinator
) {
this.profileManager = profileManager
this.pluginManager = pluginManager
this.pluginRepositoryManager = pluginRepositoryManager
this.repository = repository
this.authService = authService
this.nuvioImportCoordinator = nuvioImportCoordinator

View file

@ -88,16 +88,6 @@ internal fun AppRoutesHost(
}
}
var showPluginsSettings by remember { mutableStateOf(false) }
if (showPluginsSettings) {
com.fluxa.app.ui.settings.PluginsSettingsRoute(
language = activeProfile?.language,
onBackRequested = { showPluginsSettings = false }
)
return
}
CompositionLocalProvider(
LocalHeroTrailerSurface provides { url, cues, onActiveSubtitleChanged, trailerModifier ->
HeroTrailerVideoSurface(url, cues, onActiveSubtitleChanged, trailerModifier)
@ -169,6 +159,7 @@ internal fun AppRoutesHost(
context.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)))
},
onAddonStoreBackRequested = navigateBackSafely,
onPluginsBackRequested = navigateBackSafely,
onAuthBackRequested = navigateBackSafely,
onAuthCompleted = { onNavigateToDestination(FluxaDestination.Home) },
authStartOnNuvio = authStartOnNuvio,
@ -236,7 +227,7 @@ internal fun AppRoutesHost(
settingsPopRequestId = settingsPopRequestId,
onSettingsCanPopChanged = onSettingsCanPopChanged,
onManageAddonsRequested = { onNavigateToDestination(FluxaDestination.AddonStore) },
onManagePluginsRequested = { showPluginsSettings = true },
onManagePluginsRequested = { onNavigateToDestination(FluxaDestination.Plugins) },
onConnectStremioRequested = {
val profile = activeProfile
if (profile?.authKey.isNullOrBlank()) {

View file

@ -0,0 +1,217 @@
package com.fluxa.app.ui.settings
import com.fluxa.app.common.AppStrings
import com.fluxa.app.core.rust.FluxaCoreNative
import com.fluxa.app.plugins.PluginManager
import com.fluxa.app.plugins.PluginRepositoryManager
import com.fluxa.app.plugins.PluginScraperUiModel as NuvioScraperUiModel
import com.fluxa.app.plugins.cloudstream.InstalledPlugin
import com.fluxa.app.plugins.cloudstream.PluginInfo
import com.fluxa.app.shared.feature.plugins.CloudstreamPluginUiModel
import com.fluxa.app.shared.feature.plugins.CloudstreamRepoUiModel
import com.fluxa.app.shared.feature.plugins.PluginRepositoryUiModel
import com.fluxa.app.shared.feature.plugins.PluginScraperSettingsUiState
import com.fluxa.app.shared.feature.plugins.PluginScraperUiModel
import com.fluxa.app.shared.feature.plugins.PluginSettingsFieldUiModel
import com.fluxa.app.shared.feature.plugins.PluginSettingsOptionUiModel
import com.fluxa.app.shared.feature.plugins.PluginsDataSource
import com.fluxa.app.shared.feature.plugins.PluginsUiState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
class AndroidPluginsDataSource(
private val pluginRepositoryManager: PluginRepositoryManager,
private val pluginManager: PluginManager,
private val language: () -> String
) : PluginsDataSource {
private data class Extras(
val scraperSettingsSheet: PluginScraperSettingsUiState? = null,
val isAddingCloudstreamRepo: Boolean = false,
val cloudstreamRepoError: String? = null,
val openRepoUrl: String? = null,
val openRepoName: String? = null,
val openRepoPlugins: List<PluginInfo> = emptyList(),
val isLoadingRepoPlugins: Boolean = false,
val installingPluginKeys: Set<String> = emptySet(),
val repoDialogError: String? = null
)
private val extras = MutableStateFlow(Extras())
private var latestNuvioScrapers: List<NuvioScraperUiModel> = emptyList()
override fun observePlugins(): Flow<PluginsUiState> = combine(
pluginRepositoryManager.state,
pluginManager.installedPlugins,
pluginManager.repositories,
extras
) { nuvio, installedPlugins, repos, ex ->
latestNuvioScrapers = nuvio.scrapers
PluginsUiState(
repositories = nuvio.repositories.map {
PluginRepositoryUiModel(
manifestUrl = it.manifestUrl,
name = it.name,
description = it.description,
scraperCount = it.scraperCount
)
},
scrapers = nuvio.scrapers.map {
PluginScraperUiModel(
id = it.id,
name = it.name,
repositoryUrl = it.repositoryUrl,
enabled = it.enabled,
supportedTypes = it.supportedTypes,
hasSettings = it.hasSettings,
settings = it.settings
)
},
addingRepositoryUrl = nuvio.addingRepositoryUrl,
repositoryError = nuvio.error,
scraperSettingsSheet = ex.scraperSettingsSheet,
cloudstreamRepos = repos.map { CloudstreamRepoUiModel(name = it.name, url = it.url, iconUrl = it.iconUrl) },
isAddingCloudstreamRepo = ex.isAddingCloudstreamRepo,
cloudstreamRepoError = ex.cloudstreamRepoError,
openRepoUrl = ex.openRepoUrl,
openRepoName = ex.openRepoName,
openRepoPlugins = mapCloudstreamPlugins(ex, installedPlugins),
isLoadingRepoPlugins = ex.isLoadingRepoPlugins,
installingPluginKeys = ex.installingPluginKeys,
repoDialogError = ex.repoDialogError
)
}
private fun mapCloudstreamPlugins(ex: Extras, installedPlugins: List<InstalledPlugin>): List<CloudstreamPluginUiModel> =
ex.openRepoPlugins.map { plugin ->
val key = pluginManager.pluginInstallKey(ex.openRepoUrl, plugin.internalName)
CloudstreamPluginUiModel(
internalName = plugin.internalName,
name = plugin.name,
description = plugin.description,
iconUrl = plugin.iconUrl,
typesLabel = plugin.getDisplayTypes(),
isInstalled = installedPlugins.any { pluginManager.pluginInstallKey(it.repositoryUrl, it.internalName) == key }
)
}
override suspend fun refresh() = Unit
override suspend fun addRepository(manifestUrl: String) {
pluginRepositoryManager.addRepository(manifestUrl)
}
override suspend fun removeRepository(manifestUrl: String) {
pluginRepositoryManager.removeRepository(manifestUrl)
}
override suspend fun refreshRepository(manifestUrl: String) {
pluginRepositoryManager.refreshRepository(manifestUrl)
}
override suspend fun toggleScraper(scraperId: String, enabled: Boolean) {
pluginRepositoryManager.toggleScraper(scraperId, enabled)
}
override suspend fun requestScraperSettings(scraperId: String) {
val scraper = latestNuvioScrapers.find { it.id == scraperId } ?: return
val sharedScraper = PluginScraperUiModel(
id = scraper.id,
name = scraper.name,
repositoryUrl = scraper.repositoryUrl,
enabled = scraper.enabled,
supportedTypes = scraper.supportedTypes,
hasSettings = scraper.hasSettings,
settings = scraper.settings
)
extras.update {
it.copy(scraperSettingsSheet = PluginScraperSettingsUiState(scraper = sharedScraper, loading = true, fields = emptyList()))
}
val fields = pluginRepositoryManager.getSettingsLayout(scraper).map { field ->
PluginSettingsFieldUiModel(
key = field.key,
type = field.type,
label = field.label,
description = field.description,
placeholder = field.placeholder,
isPassword = field.isPassword,
defaultValue = field.defaultValue,
defaultBoolean = field.defaultBoolean,
options = field.options.map { PluginSettingsOptionUiModel(label = it.label, value = it.value) }
)
}
extras.update { current ->
val sheet = current.scraperSettingsSheet
if (sheet?.scraper?.id == scraperId) {
current.copy(scraperSettingsSheet = sheet.copy(loading = false, fields = fields))
} else {
current
}
}
}
override suspend fun dismissScraperSettings() {
extras.update { it.copy(scraperSettingsSheet = null) }
}
override suspend fun saveScraperSettings(scraperId: String, values: Map<String, Any?>) {
pluginRepositoryManager.updateScraperSettings(scraperId, values)
extras.update { it.copy(scraperSettingsSheet = null) }
}
override suspend fun addCloudstreamRepository(url: String) {
extras.update { it.copy(isAddingCloudstreamRepo = true, cloudstreamRepoError = null) }
val normalizedUrl = FluxaCoreNative.normalizeCloudstreamRepoUrl(url)
val result = pluginManager.addRepository(normalizedUrl)
extras.update {
if (result.isSuccess) {
it.copy(isAddingCloudstreamRepo = false)
} else {
it.copy(
isAddingCloudstreamRepo = false,
cloudstreamRepoError = result.exceptionOrNull()?.message
?: AppStrings.t(language(), "addons.repository_add_failed")
)
}
}
}
override suspend fun openRepo(url: String) {
val repoName = pluginManager.repositories.value.find { it.url == url }?.name?.takeIf { it.isNotBlank() }
extras.update {
it.copy(openRepoUrl = url, openRepoName = repoName, isLoadingRepoPlugins = true, openRepoPlugins = emptyList(), repoDialogError = null)
}
val plugins = pluginManager.getPluginsFromRepository(url)
extras.update { it.copy(openRepoPlugins = plugins, isLoadingRepoPlugins = false) }
}
override suspend fun dismissRepoDialog() {
extras.update { it.copy(openRepoUrl = null, openRepoName = null, openRepoPlugins = emptyList(), repoDialogError = null) }
}
override suspend fun removeRepo(url: String) {
pluginManager.removeRepository(url)
}
override suspend fun toggleRepoPlugin(repoUrl: String, internalName: String) {
val key = pluginManager.pluginInstallKey(repoUrl, internalName)
val plugin = extras.value.openRepoPlugins.find { it.internalName == internalName } ?: return
val isInstalled = pluginManager.installedPlugins.value.any {
pluginManager.pluginInstallKey(it.repositoryUrl, it.internalName) == key
}
extras.update { it.copy(installingPluginKeys = it.installingPluginKeys + key, repoDialogError = null) }
if (isInstalled) {
pluginManager.uninstallPlugin(repoUrl, internalName)
} else {
val result = pluginManager.installPlugin(plugin, repoUrl)
if (result.isFailure) {
extras.update {
it.copy(repoDialogError = result.exceptionOrNull()?.message ?: AppStrings.t(language(), "auto.install_failed"))
}
}
}
extras.update { it.copy(installingPluginKeys = it.installingPluginKeys - key) }
}
}

View file

@ -1,72 +0,0 @@
package com.fluxa.app.ui.settings
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fluxa.app.plugins.PluginRepositoryManager
import com.fluxa.app.plugins.PluginScraperUiModel
import com.fluxa.app.plugins.PluginSettingsFieldUiModel
import com.fluxa.app.plugins.PluginsUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
data class PluginScraperSettingsUiState(
val scraper: PluginScraperUiModel,
val loading: Boolean,
val fields: List<PluginSettingsFieldUiModel>
)
@HiltViewModel
class PluginsSettingsViewModel @Inject constructor(
private val manager: PluginRepositoryManager
) : ViewModel() {
val state: StateFlow<PluginsUiState> = manager.state
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PluginsUiState())
var settingsSheet by mutableStateOf<PluginScraperSettingsUiState?>(null)
private set
fun addRepository(url: String) {
val trimmed = url.trim()
if (trimmed.isEmpty()) return
viewModelScope.launch { manager.addRepository(trimmed) }
}
fun removeRepository(manifestUrl: String) {
viewModelScope.launch { manager.removeRepository(manifestUrl) }
}
fun refreshRepository(manifestUrl: String) {
viewModelScope.launch { manager.refreshRepository(manifestUrl) }
}
fun toggleScraper(scraperId: String, enabled: Boolean) {
viewModelScope.launch { manager.toggleScraper(scraperId, enabled) }
}
fun openScraperSettings(scraper: PluginScraperUiModel) {
settingsSheet = PluginScraperSettingsUiState(scraper = scraper, loading = true, fields = emptyList())
viewModelScope.launch {
val fields = manager.getSettingsLayout(scraper)
if (settingsSheet?.scraper?.id == scraper.id) {
settingsSheet = settingsSheet?.copy(loading = false, fields = fields)
}
}
}
fun dismissScraperSettings() {
settingsSheet = null
}
fun saveScraperSettings(scraperId: String, values: Map<String, Any?>) {
viewModelScope.launch { manager.updateScraperSettings(scraperId, values) }
settingsSheet = null
}
}

View file

@ -107,6 +107,9 @@ import com.fluxa.app.shared.feature.library.LibraryAction
import com.fluxa.app.shared.feature.library.LibraryFolderDetailScreen
import com.fluxa.app.shared.feature.library.LibraryScreen
import com.fluxa.app.shared.feature.library.LibraryUiState
import com.fluxa.app.shared.feature.plugins.PluginsAction
import com.fluxa.app.shared.feature.plugins.PluginsScreen
import com.fluxa.app.shared.feature.plugins.PluginsUiState
import com.fluxa.app.shared.feature.profile.ProfileAction
import com.fluxa.app.shared.feature.profile.ProfileEditScreen
import com.fluxa.app.shared.feature.profile.ProfileEditTarget
@ -150,6 +153,7 @@ enum class FluxaDestination(val titleKey: String) {
Library("nav.library"),
Settings("nav.settings"),
AddonStore("auto.addons"),
Plugins("settings.plugins.title"),
Auth("auth.log_in"),
ProfileList("auto.profile")
}
@ -205,6 +209,9 @@ fun FluxaApp(
onAddonStoreAction: (AddonStoreAction) -> Unit = {},
onOpenUrlRequested: (String) -> Unit = {},
onAddonStoreBackRequested: () -> Unit = {},
pluginsState: PluginsUiState? = null,
onPluginsAction: (PluginsAction) -> Unit = {},
onPluginsBackRequested: () -> Unit = {},
authState: AuthUiState? = null,
onAuthAction: (AuthAction) -> Unit = {},
nuvioIcon: @Composable () -> Unit = {},
@ -426,6 +433,13 @@ fun FluxaApp(
onBackRequested = onAddonStoreBackRequested,
modifier = Modifier.fillMaxSize()
)
state.destination == FluxaDestination.Plugins && pluginsState != null -> PluginsScreen(
state = pluginsState,
language = state.language,
onAction = onPluginsAction,
onBackRequested = onPluginsBackRequested,
modifier = Modifier.fillMaxSize()
)
state.destination == FluxaDestination.Auth && authState != null && deviceType == com.fluxa.app.ui.catalog.DeviceType.TV -> com.fluxa.app.shared.feature.auth.TvAuthScreen(
state = authState,
language = state.language,

View file

@ -12,6 +12,9 @@ import androidx.compose.ui.Modifier
import com.fluxa.app.shared.feature.addonstore.AddonStoreAction
import com.fluxa.app.shared.feature.addonstore.AddonStoreDataSource
import com.fluxa.app.shared.feature.addonstore.AddonStoreStore
import com.fluxa.app.shared.feature.plugins.PluginsAction
import com.fluxa.app.shared.feature.plugins.PluginsDataSource
import com.fluxa.app.shared.feature.plugins.PluginsStore
import com.fluxa.app.shared.feature.auth.AuthAction
import com.fluxa.app.shared.feature.auth.AuthDataSource
import com.fluxa.app.shared.feature.auth.AuthStore
@ -45,6 +48,7 @@ import com.fluxa.app.shared.feature.settings.SettingsAction
import com.fluxa.app.shared.feature.settings.SettingsDataSource
import com.fluxa.app.shared.feature.settings.SettingsStore
import com.fluxa.app.shared.platform.FluxaAddonStoreServices
import com.fluxa.app.shared.platform.FluxaPluginsServices
import com.fluxa.app.shared.platform.FluxaAuthServices
import com.fluxa.app.shared.platform.FluxaDetailServices
import com.fluxa.app.shared.platform.FluxaCalendarServices
@ -69,6 +73,7 @@ fun FluxaAppHost(
showNavigationBar: Boolean = true,
onOpenUrlRequested: (String) -> Unit = {},
onAddonStoreBackRequested: () -> Unit = {},
onPluginsBackRequested: () -> Unit = {},
onAuthBackRequested: () -> Unit = {},
onAuthCompleted: () -> Unit = {},
authStartOnNuvio: Boolean = false,
@ -104,6 +109,7 @@ fun FluxaAppHost(
searchDataSource = (platformServices as? FluxaSearchServices)?.searchDataSource,
profileDataSource = (platformServices as? FluxaProfileServices)?.profileDataSource,
addonStoreDataSource = (platformServices as? FluxaAddonStoreServices)?.addonStoreDataSource,
pluginsDataSource = (platformServices as? FluxaPluginsServices)?.pluginsDataSource,
authDataSource = (platformServices as? FluxaAuthServices)?.authDataSource,
settingsDataSource = (platformServices as? FluxaSettingsServices)?.settingsDataSource,
deviceType = deviceType,
@ -116,6 +122,7 @@ fun FluxaAppHost(
showNavigationBar = showNavigationBar,
onOpenUrlRequested = onOpenUrlRequested,
onAddonStoreBackRequested = onAddonStoreBackRequested,
onPluginsBackRequested = onPluginsBackRequested,
onAuthBackRequested = onAuthBackRequested,
onAuthCompleted = onAuthCompleted,
authStartOnNuvio = authStartOnNuvio,
@ -154,6 +161,7 @@ fun FluxaAppHost(
searchDataSource: SearchDataSource? = null,
profileDataSource: ProfileDataSource? = null,
addonStoreDataSource: AddonStoreDataSource? = null,
pluginsDataSource: PluginsDataSource? = null,
authDataSource: AuthDataSource? = null,
settingsDataSource: SettingsDataSource? = null,
deviceType: com.fluxa.app.ui.catalog.DeviceType = com.fluxa.app.ui.catalog.DeviceType.Mobile,
@ -166,6 +174,7 @@ fun FluxaAppHost(
showNavigationBar: Boolean = true,
onOpenUrlRequested: (String) -> Unit = {},
onAddonStoreBackRequested: () -> Unit = {},
onPluginsBackRequested: () -> Unit = {},
onAuthBackRequested: () -> Unit = {},
onAuthCompleted: () -> Unit = {},
authStartOnNuvio: Boolean = false,
@ -225,6 +234,10 @@ fun FluxaAppHost(
remember(source) { AddonStoreStore(source, scope) }
}
val addonStoreState = addonStoreStore?.state?.collectAsState()?.value
val pluginsStore = pluginsDataSource?.let { source ->
remember(source) { PluginsStore(source, scope) }
}
val pluginsState = pluginsStore?.state?.collectAsState()?.value
val authStore = authDataSource?.let { source ->
remember(source) { AuthStore(source, scope) }
}
@ -319,6 +332,11 @@ fun FluxaAppHost(
addonStoreStore?.dispatch(AddonStoreAction.Refresh)
}
}
LaunchedEffect(appState.uiState.destination, pluginsStore) {
if (appState.uiState.destination == FluxaDestination.Plugins) {
pluginsStore?.dispatch(PluginsAction.Refresh)
}
}
LaunchedEffect(authStartOnNuvio, authStore) {
if (authStartOnNuvio) {
authStore?.dispatch(AuthAction.ContinueWithNuvio)
@ -472,6 +490,13 @@ fun FluxaAppHost(
},
onOpenUrlRequested = onOpenUrlRequested,
onAddonStoreBackRequested = onAddonStoreBackRequested,
pluginsState = pluginsState,
onPluginsAction = { action ->
scope.launch {
pluginsStore?.dispatch(action)
}
},
onPluginsBackRequested = onPluginsBackRequested,
authState = authState,
onAuthAction = { action ->
when (action) {

View file

@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.Flow
enum class AddonStoreInputType {
UNKNOWN,
STREMIO_MANIFEST,
CLOUDSTREAM_REPO,
SEARCH_QUERY
}
@ -24,36 +23,14 @@ data class InstalledAddonUiModel(
val isRefreshing: Boolean = false
)
data class CloudstreamRepoUiModel(
val name: String,
val url: String,
val iconUrl: String? = null
)
data class CloudstreamPluginUiModel(
val internalName: String,
val name: String,
val description: String,
val iconUrl: String? = null,
val typesLabel: String = "",
val isInstalled: Boolean = false
)
data class AddonStoreUiState(
val installedAddons: List<InstalledAddonUiModel> = emptyList(),
val cloudstreamRepos: List<CloudstreamRepoUiModel> = emptyList(),
val accentColorArgb: Long = 0xFF4CAF50,
val isLoading: Boolean = false,
val inputText: String = "",
val inputDetectedType: AddonStoreInputType = AddonStoreInputType.UNKNOWN,
val isSubmittingInput: Boolean = false,
val inputError: String? = null,
val openRepoUrl: String? = null,
val openRepoName: String? = null,
val openRepoPlugins: List<CloudstreamPluginUiModel> = emptyList(),
val isLoadingRepoPlugins: Boolean = false,
val installingPluginKeys: Set<String> = emptySet(),
val repoDialogError: String? = null,
val addedAddonName: String? = null
)
@ -66,10 +43,6 @@ sealed interface AddonStoreAction {
data class AddonMoved(val url: String, val direction: Int) : AddonStoreAction
data class AddonRefreshed(val url: String) : AddonStoreAction
data class ConfigureRequested(val url: String) : AddonStoreAction
data class RepoOpened(val url: String) : AddonStoreAction
data object RepoDialogDismissed : AddonStoreAction
data class RepoRemoved(val url: String) : AddonStoreAction
data class RepoPluginToggled(val repoUrl: String, val internalName: String) : AddonStoreAction
data object AddedAddonDialogDismissed : AddonStoreAction
}
@ -83,9 +56,5 @@ interface AddonStoreDataSource {
suspend fun removeAddon(url: String)
suspend fun moveAddon(url: String, direction: Int)
suspend fun refreshAddon(url: String)
suspend fun openRepo(url: String)
suspend fun dismissRepoDialog()
suspend fun removeRepo(url: String)
suspend fun toggleRepoPlugin(repoUrl: String, internalName: String)
suspend fun dismissAddedAddonDialog()
}

View file

@ -12,12 +12,10 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@ -25,21 +23,18 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -112,25 +107,6 @@ fun AddonStoreScreen(
)
}
if (state.cloudstreamRepos.isNotEmpty()) {
item {
Text(
text = AppStrings.t(language, "auto.cloudstream3_repositories"),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
}
items(state.cloudstreamRepos, key = { "repo:${it.url}" }) { repo ->
CloudstreamRepoItem(
repo = repo,
language = language,
onRemove = { onAction(AddonStoreAction.RepoRemoved(repo.url)) },
onClick = { onAction(AddonStoreAction.RepoOpened(repo.url)) }
)
}
}
if (state.installedAddons.isNotEmpty()) {
item {
Spacer(Modifier.height(4.dp))
@ -160,17 +136,6 @@ fun AddonStoreScreen(
}
}
if (state.openRepoUrl != null) {
AddonRepoPluginsDialog(
state = state,
language = language,
onDismiss = { onAction(AddonStoreAction.RepoDialogDismissed) },
onTogglePlugin = { internalName ->
onAction(AddonStoreAction.RepoPluginToggled(state.openRepoUrl, internalName))
}
)
}
val addedAddonName = state.addedAddonName
if (addedAddonName != null) {
AlertDialog(
@ -258,49 +223,6 @@ private fun AddonSmartInput(
}
}
@Composable
private fun CloudstreamRepoItem(
repo: CloudstreamRepoUiModel,
language: String?,
onRemove: () -> Unit,
onClick: () -> Unit
) {
var focused by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.onFocusChanged { focused = it.isFocused }
.background(if (focused) Color.White.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.04f))
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White else Color.White.copy(alpha = 0.08f), RoundedCornerShape(14.dp))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 12.dp)
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
AddonLogo(url = repo.iconUrl, size = 36.dp, contentDescription = repo.name)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = repo.name.ifBlank { AppStrings.t(language, "addons.cloudstream_repo") },
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = repo.url,
color = Color.White.copy(alpha = 0.5f),
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
AddonIconButton(icon = Icons.Filled.Close, tint = Color.White.copy(alpha = 0.5f), onClick = onRemove)
}
}
}
@Composable
private fun InstalledAddonItem(
addon: InstalledAddonUiModel,
@ -388,126 +310,6 @@ private fun InstalledAddonItem(
}
}
@Composable
private fun AddonRepoPluginsDialog(
state: AddonStoreUiState,
language: String?,
onDismiss: () -> Unit,
onTogglePlugin: (String) -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = state.openRepoName ?: AppStrings.t(language, "auto.repository_plugins"),
color = Color.White,
fontWeight = FontWeight.Bold
)
},
text = {
LazyColumn(modifier = Modifier.height(400.dp)) {
if (state.isLoadingRepoPlugins) {
item {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
CircularProgressIndicator(color = Color.White, modifier = Modifier.size(32.dp))
}
}
} else if (state.openRepoPlugins.isEmpty()) {
item {
Text(
text = AppStrings.t(language, "auto.no_plugins_found_in_this_repository"),
color = Color.White.copy(alpha = 0.6f)
)
}
} else {
val error = state.repoDialogError
if (error != null) {
item {
Text(
text = error,
color = FluxaColors.errorRed,
fontSize = 13.sp,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)
)
}
}
itemsIndexed(state.openRepoPlugins, key = { _, p -> p.internalName }) { index, plugin ->
val installing = plugin.internalName in state.installingPluginKeys
CloudstreamPluginItem(
plugin = plugin,
isInstalling = installing,
onClick = { if (!installing) onTogglePlugin(plugin.internalName) }
)
if (index < state.openRepoPlugins.lastIndex) {
HorizontalDivider(color = Color.White.copy(alpha = 0.07f), thickness = 0.5.dp)
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(text = AppStrings.t(language, "auto.close"), color = Color.White.copy(alpha = 0.7f))
}
},
containerColor = Color(0xFF1A1D26)
)
}
@Composable
private fun CloudstreamPluginItem(
plugin: CloudstreamPluginUiModel,
isInstalling: Boolean,
onClick: () -> Unit
) {
val accentColor = if (plugin.isInstalled) FluxaColors.successGreen else Color.White.copy(alpha = 0.6f)
var focused by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.onFocusChanged { focused = it.isFocused }
.background(if (focused) Color.White.copy(alpha = 0.14f) else Color.Transparent)
.clickable(enabled = !isInstalling, onClick = onClick)
.padding(horizontal = 4.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
AddonLogo(url = plugin.iconUrl, size = 42.dp, contentDescription = plugin.name)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = plugin.name,
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val subtitle = buildString {
if (plugin.description.isNotBlank()) append(plugin.description.take(60))
if (plugin.typesLabel.isNotBlank()) {
if (isNotEmpty()) append(" · ")
append(plugin.typesLabel)
}
}
if (subtitle.isNotBlank()) {
Text(text = subtitle, color = Color.White.copy(alpha = 0.45f), fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Spacer(Modifier.width(10.dp))
if (isInstalling) {
CircularProgressIndicator(color = accentColor, strokeWidth = 2.dp, modifier = Modifier.size(20.dp))
} else {
Icon(
imageVector = if (plugin.isInstalled) Icons.Filled.Close else Icons.Filled.Download,
contentDescription = null,
tint = if (plugin.isInstalled) FluxaColors.errorRed else Color.White.copy(alpha = 0.5f),
modifier = Modifier.size(20.dp)
)
}
}
}
@Composable
private fun AddonIconButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,

View file

@ -22,10 +22,6 @@ class AddonStoreStore(
is AddonStoreAction.AddonMoved -> dataSource.moveAddon(action.url, action.direction)
is AddonStoreAction.AddonRefreshed -> dataSource.refreshAddon(action.url)
is AddonStoreAction.ConfigureRequested -> Unit
is AddonStoreAction.RepoOpened -> dataSource.openRepo(action.url)
AddonStoreAction.RepoDialogDismissed -> dataSource.dismissRepoDialog()
is AddonStoreAction.RepoRemoved -> dataSource.removeRepo(action.url)
is AddonStoreAction.RepoPluginToggled -> dataSource.toggleRepoPlugin(action.repoUrl, action.internalName)
AddonStoreAction.AddedAddonDialogDismissed -> dataSource.dismissAddedAddonDialog()
}
}

View file

@ -1,4 +1,4 @@
package com.fluxa.app.ui.settings
package com.fluxa.app.shared.feature.plugins
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.foundation.layout.Arrangement
@ -50,7 +50,6 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.fluxa.app.common.AppStrings
import com.fluxa.app.plugins.PluginSettingsFieldUiModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable

View file

@ -0,0 +1,108 @@
package com.fluxa.app.shared.feature.plugins
import kotlinx.coroutines.flow.Flow
data class PluginRepositoryUiModel(
val manifestUrl: String,
val name: String,
val description: String?,
val scraperCount: Int
)
data class PluginScraperUiModel(
val id: String,
val name: String,
val repositoryUrl: String,
val enabled: Boolean,
val supportedTypes: List<String>,
val hasSettings: Boolean = false,
val settings: Map<String, Any?> = emptyMap()
)
data class PluginSettingsOptionUiModel(
val label: String,
val value: String
)
data class PluginSettingsFieldUiModel(
val key: String,
val type: String,
val label: String,
val description: String? = null,
val placeholder: String? = null,
val isPassword: Boolean = false,
val defaultValue: String? = null,
val defaultBoolean: Boolean = false,
val options: List<PluginSettingsOptionUiModel> = emptyList()
)
data class PluginScraperSettingsUiState(
val scraper: PluginScraperUiModel,
val loading: Boolean,
val fields: List<PluginSettingsFieldUiModel>
)
data class CloudstreamRepoUiModel(
val name: String,
val url: String,
val iconUrl: String? = null
)
data class CloudstreamPluginUiModel(
val internalName: String,
val name: String,
val description: String,
val iconUrl: String? = null,
val typesLabel: String = "",
val isInstalled: Boolean = false
)
data class PluginsUiState(
val repositories: List<PluginRepositoryUiModel> = emptyList(),
val scrapers: List<PluginScraperUiModel> = emptyList(),
val addingRepositoryUrl: String? = null,
val repositoryError: String? = null,
val scraperSettingsSheet: PluginScraperSettingsUiState? = null,
val cloudstreamRepos: List<CloudstreamRepoUiModel> = emptyList(),
val isAddingCloudstreamRepo: Boolean = false,
val cloudstreamRepoError: String? = null,
val openRepoUrl: String? = null,
val openRepoName: String? = null,
val openRepoPlugins: List<CloudstreamPluginUiModel> = emptyList(),
val isLoadingRepoPlugins: Boolean = false,
val installingPluginKeys: Set<String> = emptySet(),
val repoDialogError: String? = null
)
sealed interface PluginsAction {
data object Refresh : PluginsAction
data class RepositoryAdded(val manifestUrl: String) : PluginsAction
data class RepositoryRemoved(val manifestUrl: String) : PluginsAction
data class RepositoryRefreshed(val manifestUrl: String) : PluginsAction
data class ScraperToggled(val scraperId: String, val enabled: Boolean) : PluginsAction
data class ScraperSettingsRequested(val scraperId: String) : PluginsAction
data object ScraperSettingsDismissed : PluginsAction
data class ScraperSettingsSaved(val scraperId: String, val values: Map<String, Any?>) : PluginsAction
data class CloudstreamRepoAdded(val url: String) : PluginsAction
data class RepoOpened(val url: String) : PluginsAction
data object RepoDialogDismissed : PluginsAction
data class RepoRemoved(val url: String) : PluginsAction
data class RepoPluginToggled(val repoUrl: String, val internalName: String) : PluginsAction
}
interface PluginsDataSource {
fun observePlugins(): Flow<PluginsUiState>
suspend fun refresh()
suspend fun addRepository(manifestUrl: String)
suspend fun removeRepository(manifestUrl: String)
suspend fun refreshRepository(manifestUrl: String)
suspend fun toggleScraper(scraperId: String, enabled: Boolean)
suspend fun requestScraperSettings(scraperId: String)
suspend fun dismissScraperSettings()
suspend fun saveScraperSettings(scraperId: String, values: Map<String, Any?>)
suspend fun addCloudstreamRepository(url: String)
suspend fun openRepo(url: String)
suspend fun dismissRepoDialog()
suspend fun removeRepo(url: String)
suspend fun toggleRepoPlugin(repoUrl: String, internalName: String)
}

View file

@ -1,4 +1,4 @@
package com.fluxa.app.ui.settings
package com.fluxa.app.shared.feature.plugins
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@ -17,14 +17,17 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@ -33,8 +36,8 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -47,57 +50,19 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.fluxa.app.common.AppStrings
import com.fluxa.app.plugins.PluginRepositoryUiModel
import com.fluxa.app.plugins.PluginScraperUiModel
import com.fluxa.app.plugins.PluginsUiState
import com.fluxa.app.shared.image.FluxaRemoteImage
import com.fluxa.app.ui.catalog.FluxaColors
@Composable
fun PluginsSettingsRoute(
language: String?,
onBackRequested: () -> Unit,
viewModel: PluginsSettingsViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsState()
PluginsSettingsScreen(
state = state,
language = language,
onAddRepository = viewModel::addRepository,
onRemoveRepository = viewModel::removeRepository,
onRefreshRepository = viewModel::refreshRepository,
onToggleScraper = viewModel::toggleScraper,
onOpenScraperSettings = viewModel::openScraperSettings,
onBackRequested = onBackRequested
)
val settingsSheet = viewModel.settingsSheet
if (settingsSheet != null) {
PluginScraperSettingsSheet(
scraperName = settingsSheet.scraper.name,
language = language,
loading = settingsSheet.loading,
fields = settingsSheet.fields,
initialValues = settingsSheet.scraper.settings,
onDismiss = viewModel::dismissScraperSettings,
onSave = { values -> viewModel.saveScraperSettings(settingsSheet.scraper.id, values) }
)
}
}
@Composable
fun PluginsSettingsScreen(
fun PluginsScreen(
state: PluginsUiState,
language: String?,
onAddRepository: (String) -> Unit,
onRemoveRepository: (String) -> Unit,
onRefreshRepository: (String) -> Unit,
onToggleScraper: (String, Boolean) -> Unit,
onOpenScraperSettings: (PluginScraperUiModel) -> Unit,
onBackRequested: () -> Unit,
onAction: (PluginsAction) -> Unit,
onBackRequested: () -> Unit = {},
modifier: Modifier = Modifier
) {
val scrapersByRepository = state.scrapers.groupBy { it.repositoryUrl }
@ -137,13 +102,26 @@ fun PluginsSettingsScreen(
}
item {
PluginRepositoryInput(language = language, onSubmit = onAddRepository)
Text(
text = AppStrings.t(language, "settings.plugins.installed_repositories"),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
}
if (state.error != null) {
item {
PluginRepositoryInput(
language = language,
placeholderKey = "settings.plugins.repository_url_placeholder",
onSubmit = { url -> onAction(PluginsAction.RepositoryAdded(url)) }
)
}
if (state.repositoryError != null) {
item {
Text(
text = AppStrings.format(language, "settings.plugins.error", state.error),
text = AppStrings.format(language, "settings.plugins.error", state.repositoryError),
color = FluxaColors.errorRed,
fontSize = 13.sp
)
@ -165,15 +143,6 @@ fun PluginsSettingsScreen(
}
}
item {
Text(
text = AppStrings.t(language, "settings.plugins.installed_repositories"),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
}
if (state.repositories.isEmpty()) {
item {
Text(
@ -184,28 +153,105 @@ fun PluginsSettingsScreen(
)
}
} else {
items(state.repositories, key = { "repo:${it.manifestUrl}" }) { repository ->
items(state.repositories, key = { "plugin-repo:${it.manifestUrl}" }) { repository ->
PluginRepositoryGroup(
repository = repository,
scrapers = scrapersByRepository[repository.manifestUrl].orEmpty(),
language = language,
isRefreshing = state.addingRepositoryUrl == repository.manifestUrl,
onRemove = { onRemoveRepository(repository.manifestUrl) },
onRefresh = { onRefreshRepository(repository.manifestUrl) },
onToggleScraper = onToggleScraper,
onOpenScraperSettings = onOpenScraperSettings
onRemove = { onAction(PluginsAction.RepositoryRemoved(repository.manifestUrl)) },
onRefresh = { onAction(PluginsAction.RepositoryRefreshed(repository.manifestUrl)) },
onToggleScraper = { scraperId, enabled -> onAction(PluginsAction.ScraperToggled(scraperId, enabled)) },
onOpenScraperSettings = { scraperId -> onAction(PluginsAction.ScraperSettingsRequested(scraperId)) }
)
}
}
item {
Spacer(Modifier.height(4.dp))
Text(
text = AppStrings.t(language, "auto.cloudstream3_repositories"),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
}
item {
PluginRepositoryInput(
language = language,
placeholderKey = "addons.cloudstream_repo",
onSubmit = { url -> onAction(PluginsAction.CloudstreamRepoAdded(url)) }
)
}
if (state.cloudstreamRepoError != null) {
item {
Text(
text = AppStrings.format(language, "settings.plugins.error", state.cloudstreamRepoError),
color = FluxaColors.errorRed,
fontSize = 13.sp
)
}
}
if (state.isAddingCloudstreamRepo) {
item {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
CircularProgressIndicator(color = Color.White.copy(alpha = 0.68f), strokeWidth = 2.dp, modifier = Modifier.size(16.dp))
Text(
text = AppStrings.t(language, "settings.plugins.adding"),
color = Color.White.copy(alpha = 0.6f),
fontSize = 13.sp
)
}
}
}
if (state.cloudstreamRepos.isNotEmpty()) {
items(state.cloudstreamRepos, key = { "cs-repo:${it.url}" }) { repo ->
CloudstreamRepoItem(
repo = repo,
language = language,
onRemove = { onAction(PluginsAction.RepoRemoved(repo.url)) },
onClick = { onAction(PluginsAction.RepoOpened(repo.url)) }
)
}
}
item { Spacer(Modifier.height(80.dp)) }
}
if (state.openRepoUrl != null) {
CloudstreamRepoPluginsDialog(
state = state,
language = language,
onDismiss = { onAction(PluginsAction.RepoDialogDismissed) },
onTogglePlugin = { internalName ->
onAction(PluginsAction.RepoPluginToggled(state.openRepoUrl, internalName))
}
)
}
}
val settingsSheet = state.scraperSettingsSheet
if (settingsSheet != null) {
PluginScraperSettingsSheet(
scraperName = settingsSheet.scraper.name,
language = language,
loading = settingsSheet.loading,
fields = settingsSheet.fields,
initialValues = settingsSheet.scraper.settings,
onDismiss = { onAction(PluginsAction.ScraperSettingsDismissed) },
onSave = { values -> onAction(PluginsAction.ScraperSettingsSaved(settingsSheet.scraper.id, values)) }
)
}
}
@Composable
private fun PluginRepositoryInput(
language: String?,
placeholderKey: String,
onSubmit: (String) -> Unit
) {
var url by remember { mutableStateOf("") }
@ -224,7 +270,7 @@ private fun PluginRepositoryInput(
singleLine = true,
placeholder = {
Text(
AppStrings.t(language, "settings.plugins.repository_url_placeholder"),
AppStrings.t(language, placeholderKey),
color = Color.White.copy(alpha = 0.3f),
fontSize = 13.sp
)
@ -268,7 +314,7 @@ private fun PluginRepositoryGroup(
onRemove: () -> Unit,
onRefresh: () -> Unit,
onToggleScraper: (String, Boolean) -> Unit,
onOpenScraperSettings: (PluginScraperUiModel) -> Unit
onOpenScraperSettings: (String) -> Unit
) {
Column(
modifier = Modifier
@ -302,9 +348,9 @@ private fun PluginRepositoryGroup(
CircularProgressIndicator(color = Color.White.copy(alpha = 0.68f), strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
}
} else {
PluginIconButton(icon = Icons.Filled.Refresh, onClick = onRefresh)
PluginsIconButton(icon = Icons.Filled.Refresh, onClick = onRefresh)
}
PluginIconButton(icon = Icons.Filled.Close, tint = Color.White.copy(alpha = 0.5f), onClick = onRemove)
PluginsIconButton(icon = Icons.Filled.Close, tint = Color.White.copy(alpha = 0.5f), onClick = onRemove)
}
if (scrapers.isNotEmpty()) {
@ -314,7 +360,7 @@ private fun PluginRepositoryGroup(
scraper = scraper,
language = language,
onToggle = { enabled -> onToggleScraper(scraper.id, enabled) },
onOpenSettings = { onOpenScraperSettings(scraper) }
onOpenSettings = { onOpenScraperSettings(scraper.id) }
)
if (index < scrapers.lastIndex) {
HorizontalDivider(color = Color.White.copy(alpha = 0.06f), thickness = 0.5.dp, modifier = Modifier.padding(horizontal = 16.dp))
@ -351,7 +397,7 @@ private fun PluginScraperRow(
)
}
if (scraper.hasSettings) {
PluginIconButton(
PluginsIconButton(
icon = Icons.Filled.Settings,
contentDescription = AppStrings.t(language, "settings.plugins.settings"),
onClick = onOpenSettings
@ -372,9 +418,173 @@ private fun PluginScraperRow(
}
@Composable
private fun PluginIconButton(
private fun CloudstreamRepoItem(
repo: CloudstreamRepoUiModel,
language: String?,
onRemove: () -> Unit,
onClick: () -> Unit
) {
var focused by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.onFocusChanged { focused = it.isFocused }
.background(if (focused) Color.White.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.04f))
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White else Color.White.copy(alpha = 0.08f), RoundedCornerShape(14.dp))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 12.dp)
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
PluginsLogo(url = repo.iconUrl, size = 36.dp, contentDescription = repo.name)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = repo.name.ifBlank { AppStrings.t(language, "addons.cloudstream_repo") },
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = repo.url,
color = Color.White.copy(alpha = 0.5f),
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
PluginsIconButton(icon = Icons.Filled.Close, tint = Color.White.copy(alpha = 0.5f), onClick = onRemove)
}
}
}
@Composable
private fun CloudstreamRepoPluginsDialog(
state: PluginsUiState,
language: String?,
onDismiss: () -> Unit,
onTogglePlugin: (String) -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = state.openRepoName ?: AppStrings.t(language, "auto.repository_plugins"),
color = Color.White,
fontWeight = FontWeight.Bold
)
},
text = {
LazyColumn(modifier = Modifier.height(400.dp)) {
if (state.isLoadingRepoPlugins) {
item {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
CircularProgressIndicator(color = Color.White, modifier = Modifier.size(32.dp))
}
}
} else if (state.openRepoPlugins.isEmpty()) {
item {
Text(
text = AppStrings.t(language, "auto.no_plugins_found_in_this_repository"),
color = Color.White.copy(alpha = 0.6f)
)
}
} else {
val error = state.repoDialogError
if (error != null) {
item {
Text(
text = error,
color = FluxaColors.errorRed,
fontSize = 13.sp,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)
)
}
}
itemsIndexed(state.openRepoPlugins, key = { _, p -> p.internalName }) { index, plugin ->
val installing = plugin.internalName in state.installingPluginKeys
CloudstreamPluginItem(
plugin = plugin,
isInstalling = installing,
onClick = { if (!installing) onTogglePlugin(plugin.internalName) }
)
if (index < state.openRepoPlugins.lastIndex) {
HorizontalDivider(color = Color.White.copy(alpha = 0.07f), thickness = 0.5.dp)
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(text = AppStrings.t(language, "auto.close"), color = Color.White.copy(alpha = 0.7f))
}
},
containerColor = Color(0xFF1A1D26)
)
}
@Composable
private fun CloudstreamPluginItem(
plugin: CloudstreamPluginUiModel,
isInstalling: Boolean,
onClick: () -> Unit
) {
val accentColor = if (plugin.isInstalled) FluxaColors.successGreen else Color.White.copy(alpha = 0.6f)
var focused by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.onFocusChanged { focused = it.isFocused }
.background(if (focused) Color.White.copy(alpha = 0.14f) else Color.Transparent)
.clickable(enabled = !isInstalling, onClick = onClick)
.padding(horizontal = 4.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
PluginsLogo(url = plugin.iconUrl, size = 42.dp, contentDescription = plugin.name)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = plugin.name,
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val subtitle = buildString {
if (plugin.description.isNotBlank()) append(plugin.description.take(60))
if (plugin.typesLabel.isNotBlank()) {
if (isNotEmpty()) append(" · ")
append(plugin.typesLabel)
}
}
if (subtitle.isNotBlank()) {
Text(text = subtitle, color = Color.White.copy(alpha = 0.45f), fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Spacer(Modifier.width(10.dp))
if (isInstalling) {
CircularProgressIndicator(color = accentColor, strokeWidth = 2.dp, modifier = Modifier.size(20.dp))
} else {
Icon(
imageVector = if (plugin.isInstalled) Icons.Filled.Close else Icons.Filled.Download,
contentDescription = null,
tint = if (plugin.isInstalled) FluxaColors.errorRed else Color.White.copy(alpha = 0.5f),
modifier = Modifier.size(20.dp)
)
}
}
}
@Composable
private fun PluginsIconButton(
icon: ImageVector,
contentDescription: String? = null,
enabled: Boolean = true,
tint: Color = Color.White.copy(alpha = 0.68f),
onClick: () -> Unit
) {
@ -385,14 +595,27 @@ private fun PluginIconButton(
.clip(CircleShape)
.onFocusChanged { focused = it.isFocused }
.background(if (focused) Color.White else Color.Transparent)
.clickable(onClick = onClick),
.clickable(enabled = enabled, onClick = onClick),
contentAlignment = Alignment.Center
) {
Icon(
icon,
contentDescription = contentDescription,
tint = if (focused) Color.Black else tint,
modifier = Modifier.size(18.dp)
)
Icon(icon, contentDescription = contentDescription, tint = if (focused) Color.Black else tint, modifier = Modifier.size(18.dp))
}
}
@Composable
private fun PluginsLogo(url: String?, size: Dp, contentDescription: String?) {
Box(
modifier = Modifier.size(size).clip(RoundedCornerShape(12.dp)).background(Color.White.copy(alpha = 0.08f)),
contentAlignment = Alignment.Center
) {
if (!url.isNullOrBlank()) {
FluxaRemoteImage(
imageUrl = url,
cacheKey = "plugin-logo:$url",
contentDescription = contentDescription,
modifier = Modifier.size(size * 0.82f).clip(RoundedCornerShape(8.dp)),
contentScale = androidx.compose.ui.layout.ContentScale.Fit
)
}
}
}

View file

@ -0,0 +1,32 @@
package com.fluxa.app.shared.feature.plugins
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
class PluginsStore(
private val dataSource: PluginsDataSource,
scope: CoroutineScope
) {
val state: StateFlow<PluginsUiState> = dataSource.observePlugins()
.stateIn(scope, SharingStarted.WhileSubscribed(5_000), PluginsUiState())
suspend fun dispatch(action: PluginsAction) {
when (action) {
PluginsAction.Refresh -> dataSource.refresh()
is PluginsAction.RepositoryAdded -> dataSource.addRepository(action.manifestUrl)
is PluginsAction.RepositoryRemoved -> dataSource.removeRepository(action.manifestUrl)
is PluginsAction.RepositoryRefreshed -> dataSource.refreshRepository(action.manifestUrl)
is PluginsAction.ScraperToggled -> dataSource.toggleScraper(action.scraperId, action.enabled)
is PluginsAction.ScraperSettingsRequested -> dataSource.requestScraperSettings(action.scraperId)
PluginsAction.ScraperSettingsDismissed -> dataSource.dismissScraperSettings()
is PluginsAction.ScraperSettingsSaved -> dataSource.saveScraperSettings(action.scraperId, action.values)
is PluginsAction.CloudstreamRepoAdded -> dataSource.addCloudstreamRepository(action.url)
is PluginsAction.RepoOpened -> dataSource.openRepo(action.url)
PluginsAction.RepoDialogDismissed -> dataSource.dismissRepoDialog()
is PluginsAction.RepoRemoved -> dataSource.removeRepo(action.url)
is PluginsAction.RepoPluginToggled -> dataSource.toggleRepoPlugin(action.repoUrl, action.internalName)
}
}
}

View file

@ -7,6 +7,7 @@ import com.fluxa.app.shared.feature.calendar.CalendarDataSource
import com.fluxa.app.shared.feature.detail.DetailDataSource
import com.fluxa.app.shared.feature.discover.DiscoverDataSource
import com.fluxa.app.shared.feature.library.LibraryDataSource
import com.fluxa.app.shared.feature.plugins.PluginsDataSource
import com.fluxa.app.shared.feature.profile.ProfileDataSource
import com.fluxa.app.shared.feature.search.SearchDataSource
import com.fluxa.app.shared.feature.settings.SettingsDataSource
@ -43,6 +44,10 @@ interface FluxaAddonStoreServices {
val addonStoreDataSource: AddonStoreDataSource
}
interface FluxaPluginsServices {
val pluginsDataSource: PluginsDataSource
}
interface FluxaAuthServices {
val authDataSource: AuthDataSource
}
@ -60,5 +65,6 @@ interface FluxaMobilePlatformServices :
FluxaDetailServices,
FluxaProfileServices,
FluxaAddonStoreServices,
FluxaPluginsServices,
FluxaAuthServices,
FluxaSettingsServices

View file

@ -16,6 +16,7 @@ import com.fluxa.app.shared.platform.AppleDetailSnapshot
import com.fluxa.app.shared.platform.AppleDetailStreamSnapshot
import com.fluxa.app.shared.platform.ApplePlaybackRequestSnapshot
import com.fluxa.app.shared.platform.AppleAddonStoreDataSource
import com.fluxa.app.shared.platform.ApplePluginsDataSource
import com.fluxa.app.shared.platform.AppleAuthDataSource
import com.fluxa.app.shared.platform.AppleCatalogHomeDataSource
import com.fluxa.app.shared.platform.AppleCalendarDataSource
@ -45,6 +46,7 @@ object FluxaApple {
internal val profileDataSource = AppleProfileDataSource(watchlistStore::setActiveProfile)
internal val settingsDataSource = AppleSettingsDataSource()
internal val addonStoreDataSource = AppleAddonStoreDataSource()
internal val pluginsDataSource = ApplePluginsDataSource()
internal val authDataSource = AppleAuthDataSource()
internal val platformServices = AppleFluxaPlatformServices(
catalogHomeDataSource,
@ -56,6 +58,7 @@ object FluxaApple {
profileDataSource,
settingsDataSource,
addonStoreDataSource,
pluginsDataSource,
authDataSource
)
private var onPlaybackRequested: (ApplePlaybackRequestSnapshot) -> Unit = {}

View file

@ -3,9 +3,6 @@ package com.fluxa.app.shared.platform
import com.fluxa.app.shared.feature.addonstore.AddonStoreDataSource
import com.fluxa.app.shared.feature.addonstore.AddonStoreInputType
import com.fluxa.app.shared.feature.addonstore.AddonStoreUiState
import com.fluxa.app.shared.feature.addonstore.CloudstreamPluginUiModel
import com.fluxa.app.shared.feature.addonstore.CloudstreamRepoUiModel
import com.fluxa.app.shared.feature.addonstore.InstalledAddonUiModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -15,9 +12,7 @@ data class AppleAddonStoreActionSnapshot(
val url: String? = null,
val text: String? = null,
val enabled: Boolean? = null,
val direction: Int? = null,
val repoUrl: String? = null,
val internalName: String? = null
val direction: Int? = null
)
class AppleAddonStoreDataSource : AddonStoreDataSource {
@ -31,7 +26,6 @@ class AppleAddonStoreDataSource : AddonStoreDataSource {
return when {
trimmed.isEmpty() -> AddonStoreInputType.UNKNOWN
trimmed.endsWith("manifest.json") || trimmed.startsWith("stremio://") -> AddonStoreInputType.STREMIO_MANIFEST
trimmed.endsWith(".json") && trimmed.startsWith("http") -> AddonStoreInputType.CLOUDSTREAM_REPO
trimmed.startsWith("http://") || trimmed.startsWith("https://") -> AddonStoreInputType.STREMIO_MANIFEST
else -> AddonStoreInputType.SEARCH_QUERY
}
@ -67,23 +61,6 @@ class AppleAddonStoreDataSource : AddonStoreDataSource {
postAction(AppleAddonStoreActionSnapshot(type = "refreshAddon", url = url))
}
override suspend fun openRepo(url: String) {
state.value = state.value.copy(openRepoUrl = url, isLoadingRepoPlugins = true)
postAction(AppleAddonStoreActionSnapshot(type = "openRepo", url = url))
}
override suspend fun dismissRepoDialog() {
state.value = state.value.copy(openRepoUrl = null, openRepoName = null, openRepoPlugins = emptyList(), repoDialogError = null)
}
override suspend fun removeRepo(url: String) {
postAction(AppleAddonStoreActionSnapshot(type = "removeRepo", url = url))
}
override suspend fun toggleRepoPlugin(repoUrl: String, internalName: String) {
postAction(AppleAddonStoreActionSnapshot(type = "toggleRepoPlugin", repoUrl = repoUrl, internalName = internalName))
}
override suspend fun dismissAddedAddonDialog() {
state.value = state.value.copy(addedAddonName = null)
}

View file

@ -10,6 +10,8 @@ class AppleFluxaPlatformServices(
override val profileDataSource: AppleProfileDataSource,
override val settingsDataSource: AppleSettingsDataSource,
override val addonStoreDataSource: AppleAddonStoreDataSource,
override val pluginsDataSource: ApplePluginsDataSource,
override val authDataSource: AppleAuthDataSource
) : FluxaPlatformServices, FluxaDetailServices, FluxaSearchServices, FluxaDiscoverServices, FluxaCalendarServices,
FluxaLibraryServices, FluxaProfileServices, FluxaSettingsServices, FluxaAddonStoreServices, FluxaAuthServices
FluxaLibraryServices, FluxaProfileServices, FluxaSettingsServices, FluxaAddonStoreServices, FluxaPluginsServices,
FluxaAuthServices

View file

@ -0,0 +1,86 @@
package com.fluxa.app.shared.platform
import com.fluxa.app.shared.feature.plugins.PluginsDataSource
import com.fluxa.app.shared.feature.plugins.PluginsUiState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
data class ApplePluginsActionSnapshot(
val type: String,
val manifestUrl: String? = null,
val scraperId: String? = null,
val enabled: Boolean? = null,
val settingsJson: String? = null,
val url: String? = null,
val repoUrl: String? = null,
val internalName: String? = null
)
class ApplePluginsDataSource : PluginsDataSource {
private val state = MutableStateFlow(PluginsUiState())
private var onActionRequested: (ApplePluginsActionSnapshot) -> Unit = {}
override fun observePlugins(): Flow<PluginsUiState> = state.asStateFlow()
override suspend fun refresh() {
postAction(ApplePluginsActionSnapshot(type = "refresh"))
}
override suspend fun addRepository(manifestUrl: String) {
postAction(ApplePluginsActionSnapshot(type = "addRepository", manifestUrl = manifestUrl))
}
override suspend fun removeRepository(manifestUrl: String) {
postAction(ApplePluginsActionSnapshot(type = "removeRepository", manifestUrl = manifestUrl))
}
override suspend fun refreshRepository(manifestUrl: String) {
postAction(ApplePluginsActionSnapshot(type = "refreshRepository", manifestUrl = manifestUrl))
}
override suspend fun toggleScraper(scraperId: String, enabled: Boolean) {
postAction(ApplePluginsActionSnapshot(type = "toggleScraper", scraperId = scraperId, enabled = enabled))
}
override suspend fun requestScraperSettings(scraperId: String) {
postAction(ApplePluginsActionSnapshot(type = "requestScraperSettings", scraperId = scraperId))
}
override suspend fun dismissScraperSettings() {
state.value = state.value.copy(scraperSettingsSheet = null)
}
override suspend fun saveScraperSettings(scraperId: String, values: Map<String, Any?>) {
postAction(ApplePluginsActionSnapshot(type = "saveScraperSettings", scraperId = scraperId))
}
override suspend fun addCloudstreamRepository(url: String) {
postAction(ApplePluginsActionSnapshot(type = "addCloudstreamRepository", url = url))
}
override suspend fun openRepo(url: String) {
state.value = state.value.copy(openRepoUrl = url, isLoadingRepoPlugins = true)
postAction(ApplePluginsActionSnapshot(type = "openRepo", url = url))
}
override suspend fun dismissRepoDialog() {
state.value = state.value.copy(openRepoUrl = null, openRepoName = null, openRepoPlugins = emptyList(), repoDialogError = null)
}
override suspend fun removeRepo(url: String) {
postAction(ApplePluginsActionSnapshot(type = "removeRepo", url = url))
}
override suspend fun toggleRepoPlugin(repoUrl: String, internalName: String) {
postAction(ApplePluginsActionSnapshot(type = "toggleRepoPlugin", repoUrl = repoUrl, internalName = internalName))
}
fun setOnActionRequested(handler: (ApplePluginsActionSnapshot) -> Unit) {
onActionRequested = handler
}
private fun postAction(action: ApplePluginsActionSnapshot) {
onActionRequested(action)
}
}