This commit is contained in:
albyalex96 2026-05-15 19:28:23 -05:00 committed by GitHub
commit c2d07195e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 205 additions and 5 deletions

View file

@ -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<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 currentEpochMillis(): Long = System.currentTimeMillis()
internal fun currentEpochMillis(): Long = System.currentTimeMillis()

View file

@ -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<PluginConfigField> = emptyList(),
val scrapers: List<PluginManifestScraper> = emptyList(),
)
@ -40,6 +51,7 @@ data class PluginRepositoryItem(
val lastUpdated: Long = 0L,
val isRefreshing: Boolean = false,
val errorMessage: String? = null,
val settings: List<PluginConfigField> = 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<PluginConfigField> = emptyList(),
)
@Serializable
@ -129,4 +142,4 @@ internal fun normalizePluginType(value: String): String =
when (value.lowercase()) {
"series", "show", "other" -> "tv"
else -> value.lowercase()
}
}

View file

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

View file

@ -12,11 +12,22 @@ 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.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
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 +40,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 +76,8 @@ fun PluginsSettingsPageContent(
var testingScraperId by remember { mutableStateOf<String?>(null) }
val testResults = remember { mutableStateMapOf<String, List<PluginRuntimeResult>>() }
var configuringRepo by remember { mutableStateOf<PluginRepositoryItem?>(null) }
val sortedRepos = remember(uiState.repositories) {
uiState.repositories.sortedBy { it.name.lowercase() }
}
@ -79,6 +94,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 +283,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 +311,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 +478,131 @@ 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 = {
val scrollState = rememberScrollState()
Column(
modifier = Modifier.verticalScroll(scrollState),
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))
if (isPassword) {
OutlinedTextField(
value = fieldValues[field.key].orEmpty(),
onValueChange = { fieldValues[field.key] = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(14.dp),
placeholder = {
Text(
text = field.label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge,
)
},
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
),
visualTransformation = if (isVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
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,
)
}
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.outline,
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
} else {
NuvioInputField(
value = fieldValues[field.key].orEmpty(),
onValueChange = { fieldValues[field.key] = it },
placeholder = field.label,
)
}
}
}
}
},
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 +610,4 @@ private fun String.fallbackRepositoryLabel(): String {
return host.ifBlank {
withoutManifest.substringAfterLast('/').ifBlank { "Plugin repository" }
}
}
}