mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 15:01:59 +00:00
Merge bb8de0fe4e into 70d3eee9d2
This commit is contained in:
commit
c2d07195e9
4 changed files with 205 additions and 5 deletions
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue