diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt index 6e77db32..337075fa 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt @@ -2,11 +2,15 @@ package com.nuvio.app.features.plugins import android.content.Context import android.content.SharedPreferences +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json internal object PluginStorage { private const val preferencesName = "nuvio_plugins" private const val pluginsStateKey = "plugins_state" + private const val pluginConfigKey = "plugin_config" + private val json = Json { ignoreUnknownKeys = true } private var preferences: SharedPreferences? = null fun initialize(context: Context) { @@ -22,8 +26,25 @@ internal object PluginStorage { ?.putString("${pluginsStateKey}_$profileId", payload) ?.apply() } + + fun loadConfig(manifestUrl: String): Map { + val key = "${pluginConfigKey}_${manifestUrl.hashCode()}" + val raw = preferences?.getString(key, null)?.trim().orEmpty() + if (raw.isBlank()) return emptyMap() + return runCatching { + json.decodeFromString>(raw) + }.getOrDefault(emptyMap()) + } + + fun saveConfig(manifestUrl: String, values: Map) { + val key = "${pluginConfigKey}_${manifestUrl.hashCode()}" + preferences + ?.edit() + ?.putString(key, json.encodeToString(values)) + ?.apply() + } } internal fun currentPluginPlatform(): String = "android" -internal fun currentEpochMillis(): Long = System.currentTimeMillis() +internal fun currentEpochMillis(): Long = System.currentTimeMillis() \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt index afca7988..7e6ab081 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt @@ -3,12 +3,23 @@ package com.nuvio.app.features.plugins import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +@Serializable +data class PluginConfigField( + val key: String, + val label: String, + val description: String? = null, + val type: String = "text", + val required: Boolean = false, + val default: String? = null, +) + @Serializable data class PluginManifest( val name: String, val version: String, val description: String? = null, val author: String? = null, + val settings: List = emptyList(), val scrapers: List = emptyList(), ) @@ -40,6 +51,7 @@ data class PluginRepositoryItem( val lastUpdated: Long = 0L, val isRefreshing: Boolean = false, val errorMessage: String? = null, + val settings: List = emptyList(), ) data class PluginScraper( @@ -106,6 +118,7 @@ internal data class StoredPluginRepository( val version: String? = null, val scraperCount: Int = 0, val lastUpdated: Long = 0L, + val settings: List = emptyList(), ) @Serializable @@ -129,4 +142,4 @@ internal fun normalizePluginType(value: String): String = when (value.lowercase()) { "series", "show", "other" -> "tv" else -> value.lowercase() - } + } \ No newline at end of file diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt index 32e0562f..c4bea631 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt @@ -319,6 +319,7 @@ actual object PluginRepository { tmdbId = tmdbId, mediaType = mediaType, ) + val userSettings = PluginStorage.loadConfig(scraper.repositoryUrl) return runCatching { PluginRuntime.executePlugin( @@ -328,7 +329,7 @@ actual object PluginRepository { season = season, episode = episode, scraperId = scraper.id, - scraperSettings = emptyMap(), + scraperSettings = userSettings, ) } } @@ -399,6 +400,7 @@ actual object PluginRepository { lastUpdated = currentEpochMillis(), isRefreshing = false, errorMessage = null, + settings = manifest.settings, ) repo to scrapers } @@ -462,6 +464,7 @@ actual object PluginRepository { version = repo.version, scraperCount = repo.scraperCount, lastUpdated = repo.lastUpdated, + settings = repo.settings, ) }, scrapers = state.scrapers.map { scraper -> @@ -527,6 +530,7 @@ actual object PluginRepository { lastUpdated = it.lastUpdated, isRefreshing = false, errorMessage = null, + settings = it.settings, ) } ?: emptyList(), @@ -582,4 +586,4 @@ actual object PluginRepository { val active = ProfileRepository.state.value.activeProfile return if (active != null && !active.id.isBlank() && active.usesPrimaryPlugins) 1 else profileId } -} +} \ No newline at end of file diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt index 71b7e4e3..12682eb4 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt @@ -12,11 +12,17 @@ import androidx.compose.material.icons.rounded.Bolt import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Extension import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material3.AlertDialog import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -29,6 +35,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -63,6 +71,8 @@ fun PluginsSettingsPageContent( var testingScraperId by remember { mutableStateOf(null) } val testResults = remember { mutableStateMapOf>() } + var configuringRepo by remember { mutableStateOf(null) } + val sortedRepos = remember(uiState.repositories) { uiState.repositories.sortedBy { it.name.lowercase() } } @@ -79,6 +89,17 @@ fun PluginsSettingsPageContent( ) } + configuringRepo?.let { repo -> + PluginConfigDialog( + repo = repo, + onDismiss = { configuringRepo = null }, + onSave = { values -> + PluginStorage.saveConfig(repo.manifestUrl, values) + configuringRepo = null + }, + ) + } + Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp), @@ -257,6 +278,14 @@ fun PluginsSettingsPageContent( ) } Row(verticalAlignment = Alignment.CenterVertically) { + if (repo.settings.isNotEmpty()) { + NuvioIconActionButton( + icon = Icons.Rounded.Settings, + contentDescription = "Configure plugin repository", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + onClick = { configuringRepo = repo }, + ) + } NuvioIconActionButton( icon = Icons.Rounded.Refresh, contentDescription = "Refresh plugin repository", @@ -277,6 +306,9 @@ fun PluginsSettingsPageContent( horizontalArrangement = Arrangement.spacedBy(10.dp), ) { NuvioInfoBadge(text = "${repo.scraperCount} providers") + if (repo.settings.isNotEmpty()) { + NuvioInfoBadge(text = "Configurable") + } if (repo.isRefreshing) { NuvioInfoBadge(text = "Refreshing") } @@ -441,6 +473,101 @@ fun PluginsSettingsPageContent( } } +@Composable +private fun PluginConfigDialog( + repo: PluginRepositoryItem, + onDismiss: () -> Unit, + onSave: (Map) -> Unit, +) { + val savedValues = remember(repo.manifestUrl) { + PluginStorage.loadConfig(repo.manifestUrl) + } + val fieldValues = remember(repo.manifestUrl) { + mutableStateMapOf().apply { + repo.settings.forEach { field -> + put(field.key, savedValues[field.key] ?: field.default.orEmpty()) + } + } + } + val passwordVisibility = remember(repo.manifestUrl) { + mutableStateMapOf().apply { + repo.settings.forEach { field -> put(field.key, false) } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = "${repo.name} — Settings", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + repo.settings.forEach { field -> + val isPassword = field.type == "password" + val isVisible = passwordVisibility[field.key] == true + Column { + Text( + text = field.label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + field.description?.let { desc -> + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = desc, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(6.dp)) + NuvioInputField( + value = fieldValues[field.key].orEmpty(), + onValueChange = { fieldValues[field.key] = it }, + placeholder = field.label, + trailingContent = if (isPassword) { + { + IconButton(onClick = { + passwordVisibility[field.key] = !isVisible + }) { + Icon( + imageVector = if (isVisible) Icons.Rounded.VisibilityOff else Icons.Rounded.Visibility, + contentDescription = if (isVisible) "Hide" else "Show", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else null, + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { onSave(fieldValues.toMap()) }) { + Text( + text = "Save", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = "Cancel", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + containerColor = MaterialTheme.colorScheme.surface, + ) +} + private fun String.fallbackRepositoryLabel(): String { val withoutQuery = substringBefore("?") val withoutManifest = withoutQuery.removeSuffix("/manifest.json") @@ -448,4 +575,4 @@ private fun String.fallbackRepositoryLabel(): String { return host.ifBlank { withoutManifest.substringAfterLast('/').ifBlank { "Plugin repository" } } -} +} \ No newline at end of file