diff --git a/app/src/main/java/com/fluxa/app/ui/AndroidFluxaPlatformServices.kt b/app/src/main/java/com/fluxa/app/ui/AndroidFluxaPlatformServices.kt index 21b7f46..3da8ab5 100644 --- a/app/src/main/java/com/fluxa/app/ui/AndroidFluxaPlatformServices.kt +++ b/app/src/main/java/com/fluxa/app/ui/AndroidFluxaPlatformServices.kt @@ -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, diff --git a/app/src/main/java/com/fluxa/app/ui/MainActivity.kt b/app/src/main/java/com/fluxa/app/ui/MainActivity.kt index db4ba16..eadd3c1 100644 --- a/app/src/main/java/com/fluxa/app/ui/MainActivity.kt +++ b/app/src/main/java/com/fluxa/app/ui/MainActivity.kt @@ -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 { diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/AndroidAddonStoreDataSource.kt b/app/src/main/java/com/fluxa/app/ui/catalog/AndroidAddonStoreDataSource.kt index 2942d7c..76d8777 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/AndroidAddonStoreDataSource.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/AndroidAddonStoreDataSource.kt @@ -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 = emptyList(), - val isLoadingRepoPlugins: Boolean = false, - val installingPluginKeys: Set = 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 = 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, - repos: List, userAddons: List, 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) } } diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/AppContainer.kt b/app/src/main/java/com/fluxa/app/ui/catalog/AppContainer.kt index 0938164..ba81628 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/AppContainer.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/AppContainer.kt @@ -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 diff --git a/app/src/main/java/com/fluxa/app/ui/routes/AppRoutesHost.kt b/app/src/main/java/com/fluxa/app/ui/routes/AppRoutesHost.kt index 464270e..3fd2883 100644 --- a/app/src/main/java/com/fluxa/app/ui/routes/AppRoutesHost.kt +++ b/app/src/main/java/com/fluxa/app/ui/routes/AppRoutesHost.kt @@ -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()) { diff --git a/app/src/main/java/com/fluxa/app/ui/settings/AndroidPluginsDataSource.kt b/app/src/main/java/com/fluxa/app/ui/settings/AndroidPluginsDataSource.kt new file mode 100644 index 0000000..5d26047 --- /dev/null +++ b/app/src/main/java/com/fluxa/app/ui/settings/AndroidPluginsDataSource.kt @@ -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 = emptyList(), + val isLoadingRepoPlugins: Boolean = false, + val installingPluginKeys: Set = emptySet(), + val repoDialogError: String? = null + ) + + private val extras = MutableStateFlow(Extras()) + private var latestNuvioScrapers: List = emptyList() + + override fun observePlugins(): Flow = 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): List = + 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) { + 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) } + } +} diff --git a/app/src/main/java/com/fluxa/app/ui/settings/PluginsSettingsViewModel.kt b/app/src/main/java/com/fluxa/app/ui/settings/PluginsSettingsViewModel.kt deleted file mode 100644 index 5b72f81..0000000 --- a/app/src/main/java/com/fluxa/app/ui/settings/PluginsSettingsViewModel.kt +++ /dev/null @@ -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 -) - -@HiltViewModel -class PluginsSettingsViewModel @Inject constructor( - private val manager: PluginRepositoryManager -) : ViewModel() { - - val state: StateFlow = manager.state - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PluginsUiState()) - - var settingsSheet by mutableStateOf(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) { - viewModelScope.launch { manager.updateScraperSettings(scraperId, values) } - settingsSheet = null - } -} diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaApp.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaApp.kt index edd9afd..05b0e39 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaApp.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaApp.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppHost.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppHost.kt index 0f3d6af..0da4826 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppHost.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppHost.kt @@ -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) { diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/addonstore/AddonStoreContracts.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/addonstore/AddonStoreContracts.kt index c756372..9b513b2 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/addonstore/AddonStoreContracts.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/addonstore/AddonStoreContracts.kt @@ -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 = emptyList(), - val cloudstreamRepos: List = 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 = emptyList(), - val isLoadingRepoPlugins: Boolean = false, - val installingPluginKeys: Set = 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() } diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/addonstore/AddonStoreScreen.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/addonstore/AddonStoreScreen.kt index 4f8f877..ffeab12 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/addonstore/AddonStoreScreen.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/addonstore/AddonStoreScreen.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/addonstore/AddonStoreStore.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/addonstore/AddonStoreStore.kt index e50b9ce..6fffedc 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/addonstore/AddonStoreStore.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/addonstore/AddonStoreStore.kt @@ -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() } } diff --git a/app/src/main/java/com/fluxa/app/ui/settings/PluginScraperSettingsSheet.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/plugins/PluginScraperSettingsSheet.kt similarity index 99% rename from app/src/main/java/com/fluxa/app/ui/settings/PluginScraperSettingsSheet.kt rename to shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/plugins/PluginScraperSettingsSheet.kt index 6ab888a..7b587ca 100644 --- a/app/src/main/java/com/fluxa/app/ui/settings/PluginScraperSettingsSheet.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/plugins/PluginScraperSettingsSheet.kt @@ -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 diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/plugins/PluginsContracts.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/plugins/PluginsContracts.kt new file mode 100644 index 0000000..9a83599 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/plugins/PluginsContracts.kt @@ -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, + val hasSettings: Boolean = false, + val settings: Map = 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 = emptyList() +) + +data class PluginScraperSettingsUiState( + val scraper: PluginScraperUiModel, + val loading: Boolean, + val fields: List +) + +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 = emptyList(), + val scrapers: List = emptyList(), + val addingRepositoryUrl: String? = null, + val repositoryError: String? = null, + val scraperSettingsSheet: PluginScraperSettingsUiState? = null, + val cloudstreamRepos: List = emptyList(), + val isAddingCloudstreamRepo: Boolean = false, + val cloudstreamRepoError: String? = null, + val openRepoUrl: String? = null, + val openRepoName: String? = null, + val openRepoPlugins: List = emptyList(), + val isLoadingRepoPlugins: Boolean = false, + val installingPluginKeys: Set = 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) : 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 + 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) + 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) +} diff --git a/app/src/main/java/com/fluxa/app/ui/settings/PluginsSettingsScreen.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/plugins/PluginsScreen.kt similarity index 52% rename from app/src/main/java/com/fluxa/app/ui/settings/PluginsSettingsScreen.kt rename to shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/plugins/PluginsScreen.kt index 91c2109..65c1f1f 100644 --- a/app/src/main/java/com/fluxa/app/ui/settings/PluginsSettingsScreen.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/plugins/PluginsScreen.kt @@ -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 + ) + } } } diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/plugins/PluginsStore.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/plugins/PluginsStore.kt new file mode 100644 index 0000000..10b3f49 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/plugins/PluginsStore.kt @@ -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 = 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) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/platform/FluxaPlatformServices.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/platform/FluxaPlatformServices.kt index ac3c5de..1ea81e3 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/platform/FluxaPlatformServices.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/platform/FluxaPlatformServices.kt @@ -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 diff --git a/shared/src/iosMain/kotlin/com/fluxa/app/shared/FluxaApple.kt b/shared/src/iosMain/kotlin/com/fluxa/app/shared/FluxaApple.kt index 3d304db..d57c129 100644 --- a/shared/src/iosMain/kotlin/com/fluxa/app/shared/FluxaApple.kt +++ b/shared/src/iosMain/kotlin/com/fluxa/app/shared/FluxaApple.kt @@ -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 = {} diff --git a/shared/src/iosMain/kotlin/com/fluxa/app/shared/platform/AppleAddonStoreDataSource.kt b/shared/src/iosMain/kotlin/com/fluxa/app/shared/platform/AppleAddonStoreDataSource.kt index 1baa43f..d3bd8d5 100644 --- a/shared/src/iosMain/kotlin/com/fluxa/app/shared/platform/AppleAddonStoreDataSource.kt +++ b/shared/src/iosMain/kotlin/com/fluxa/app/shared/platform/AppleAddonStoreDataSource.kt @@ -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) } diff --git a/shared/src/iosMain/kotlin/com/fluxa/app/shared/platform/AppleFluxaPlatformServices.kt b/shared/src/iosMain/kotlin/com/fluxa/app/shared/platform/AppleFluxaPlatformServices.kt index df5d9a5..e0b1f24 100644 --- a/shared/src/iosMain/kotlin/com/fluxa/app/shared/platform/AppleFluxaPlatformServices.kt +++ b/shared/src/iosMain/kotlin/com/fluxa/app/shared/platform/AppleFluxaPlatformServices.kt @@ -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 diff --git a/shared/src/iosMain/kotlin/com/fluxa/app/shared/platform/ApplePluginsDataSource.kt b/shared/src/iosMain/kotlin/com/fluxa/app/shared/platform/ApplePluginsDataSource.kt new file mode 100644 index 0000000..dd8fe61 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/fluxa/app/shared/platform/ApplePluginsDataSource.kt @@ -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 = 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) { + 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) + } +}