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.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,8 +26,25 @@ 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"
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.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
@ -129,4 +142,4 @@ internal fun normalizePluginType(value: String): String =
when (value.lowercase()) { when (value.lowercase()) {
"series", "show", "other" -> "tv" "series", "show", "other" -> "tv"
else -> value.lowercase() else -> value.lowercase()
} }

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(),
@ -582,4 +586,4 @@ actual object PluginRepository {
val active = ProfileRepository.state.value.activeProfile val active = ProfileRepository.state.value.activeProfile
return if (active != null && !active.id.isBlank() && active.usesPrimaryPlugins) 1 else profileId 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.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.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.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.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
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 +40,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 +76,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 +94,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 +283,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 +311,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 +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 { private fun String.fallbackRepositoryLabel(): String {
val withoutQuery = substringBefore("?") val withoutQuery = substringBefore("?")
val withoutManifest = withoutQuery.removeSuffix("/manifest.json") val withoutManifest = withoutQuery.removeSuffix("/manifest.json")
@ -448,4 +610,4 @@ private fun String.fallbackRepositoryLabel(): String {
return host.ifBlank { return host.ifBlank {
withoutManifest.substringAfterLast('/').ifBlank { "Plugin repository" } withoutManifest.substringAfterLast('/').ifBlank { "Plugin repository" }
} }
} }