Added Plugin configuration support

This commit is contained in:
cyberalby2 2026-05-14 11:46:23 +02:00
parent 37203d1fc1
commit e753d9efd2
4 changed files with 170 additions and 5 deletions

View file

@ -2,11 +2,15 @@ package com.nuvio.app.features.plugins
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
internal object PluginStorage { internal object PluginStorage {
private const val preferencesName = "nuvio_plugins" private const val preferencesName = "nuvio_plugins"
private const val pluginsStateKey = "plugins_state" private const val pluginsStateKey = "plugins_state"
private const val pluginConfigKey = "plugin_config"
private val json = Json { ignoreUnknownKeys = true }
private var preferences: SharedPreferences? = null private var preferences: SharedPreferences? = null
fun initialize(context: Context) { fun initialize(context: Context) {
@ -22,6 +26,23 @@ internal object PluginStorage {
?.putString("${pluginsStateKey}_$profileId", payload) ?.putString("${pluginsStateKey}_$profileId", payload)
?.apply() ?.apply()
} }
fun loadConfig(manifestUrl: String): Map<String, String> {
val key = "${pluginConfigKey}_${manifestUrl.hashCode()}"
val raw = preferences?.getString(key, null)?.trim().orEmpty()
if (raw.isBlank()) return emptyMap()
return runCatching {
json.decodeFromString<Map<String, String>>(raw)
}.getOrDefault(emptyMap())
}
fun saveConfig(manifestUrl: String, values: Map<String, String>) {
val key = "${pluginConfigKey}_${manifestUrl.hashCode()}"
preferences
?.edit()
?.putString(key, json.encodeToString(values))
?.apply()
}
} }
internal fun currentPluginPlatform(): String = "android" internal fun currentPluginPlatform(): String = "android"

View file

@ -3,12 +3,23 @@ package com.nuvio.app.features.plugins
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable 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 @Serializable
data class PluginManifest( data class PluginManifest(
val name: String, val name: String,
val version: String, val version: String,
val description: String? = null, val description: String? = null,
val author: String? = null, val author: String? = null,
val settings: List<PluginConfigField> = emptyList(),
val scrapers: List<PluginManifestScraper> = emptyList(), val scrapers: List<PluginManifestScraper> = emptyList(),
) )
@ -40,6 +51,7 @@ data class PluginRepositoryItem(
val lastUpdated: Long = 0L, val lastUpdated: Long = 0L,
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val settings: List<PluginConfigField> = emptyList(),
) )
data class PluginScraper( data class PluginScraper(
@ -106,6 +118,7 @@ internal data class StoredPluginRepository(
val version: String? = null, val version: String? = null,
val scraperCount: Int = 0, val scraperCount: Int = 0,
val lastUpdated: Long = 0L, val lastUpdated: Long = 0L,
val settings: List<PluginConfigField> = emptyList(),
) )
@Serializable @Serializable

View file

@ -319,6 +319,7 @@ actual object PluginRepository {
tmdbId = tmdbId, tmdbId = tmdbId,
mediaType = mediaType, mediaType = mediaType,
) )
val userSettings = PluginStorage.loadConfig(scraper.repositoryUrl)
return runCatching { return runCatching {
PluginRuntime.executePlugin( PluginRuntime.executePlugin(
@ -328,7 +329,7 @@ actual object PluginRepository {
season = season, season = season,
episode = episode, episode = episode,
scraperId = scraper.id, scraperId = scraper.id,
scraperSettings = emptyMap(), scraperSettings = userSettings,
) )
} }
} }
@ -399,6 +400,7 @@ actual object PluginRepository {
lastUpdated = currentEpochMillis(), lastUpdated = currentEpochMillis(),
isRefreshing = false, isRefreshing = false,
errorMessage = null, errorMessage = null,
settings = manifest.settings,
) )
repo to scrapers repo to scrapers
} }
@ -462,6 +464,7 @@ actual object PluginRepository {
version = repo.version, version = repo.version,
scraperCount = repo.scraperCount, scraperCount = repo.scraperCount,
lastUpdated = repo.lastUpdated, lastUpdated = repo.lastUpdated,
settings = repo.settings,
) )
}, },
scrapers = state.scrapers.map { scraper -> scrapers = state.scrapers.map { scraper ->
@ -527,6 +530,7 @@ actual object PluginRepository {
lastUpdated = it.lastUpdated, lastUpdated = it.lastUpdated,
isRefreshing = false, isRefreshing = false,
errorMessage = null, errorMessage = null,
settings = it.settings,
) )
} }
?: emptyList(), ?: emptyList(),

View file

@ -12,11 +12,17 @@ import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Extension import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Refresh 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.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -29,6 +35,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -63,6 +71,8 @@ fun PluginsSettingsPageContent(
var testingScraperId by remember { mutableStateOf<String?>(null) } var testingScraperId by remember { mutableStateOf<String?>(null) }
val testResults = remember { mutableStateMapOf<String, List<PluginRuntimeResult>>() } val testResults = remember { mutableStateMapOf<String, List<PluginRuntimeResult>>() }
var configuringRepo by remember { mutableStateOf<PluginRepositoryItem?>(null) }
val sortedRepos = remember(uiState.repositories) { val sortedRepos = remember(uiState.repositories) {
uiState.repositories.sortedBy { it.name.lowercase() } 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( Column(
modifier = modifier, modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
@ -257,6 +278,14 @@ fun PluginsSettingsPageContent(
) )
} }
Row(verticalAlignment = Alignment.CenterVertically) { 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( NuvioIconActionButton(
icon = Icons.Rounded.Refresh, icon = Icons.Rounded.Refresh,
contentDescription = "Refresh plugin repository", contentDescription = "Refresh plugin repository",
@ -277,6 +306,9 @@ fun PluginsSettingsPageContent(
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
NuvioInfoBadge(text = "${repo.scraperCount} providers") NuvioInfoBadge(text = "${repo.scraperCount} providers")
if (repo.settings.isNotEmpty()) {
NuvioInfoBadge(text = "Configurable")
}
if (repo.isRefreshing) { if (repo.isRefreshing) {
NuvioInfoBadge(text = "Refreshing") NuvioInfoBadge(text = "Refreshing")
} }
@ -441,6 +473,101 @@ fun PluginsSettingsPageContent(
} }
} }
@Composable
private fun PluginConfigDialog(
repo: PluginRepositoryItem,
onDismiss: () -> Unit,
onSave: (Map<String, String>) -> Unit,
) {
val savedValues = remember(repo.manifestUrl) {
PluginStorage.loadConfig(repo.manifestUrl)
}
val fieldValues = remember(repo.manifestUrl) {
mutableStateMapOf<String, String>().apply {
repo.settings.forEach { field ->
put(field.key, savedValues[field.key] ?: field.default.orEmpty())
}
}
}
val passwordVisibility = remember(repo.manifestUrl) {
mutableStateMapOf<String, Boolean>().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 { private fun String.fallbackRepositoryLabel(): String {
val withoutQuery = substringBefore("?") val withoutQuery = substringBefore("?")
val withoutManifest = withoutQuery.removeSuffix("/manifest.json") val withoutManifest = withoutQuery.removeSuffix("/manifest.json")