mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +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.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()
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue