Add per-scraper settings UI backed by onSettings() JS export

Wire executePluginScraper's updated scraper_id/scraper_settings_json
params through FluxaCoreUniFfi and PluginRepositoryManager, add
getSettingsLayout() to fetch and parse a scraper's onSettings() field
layout into small UI models, and add a gear-icon-triggered bottom
sheet (PluginScraperSettingsSheet) rendering header/info/text/select/
toggle fields, matching Nuvio's real settings schema.
This commit is contained in:
KhooLy 2026-07-20 02:54:53 +03:00
parent b710320ce4
commit 1e776bbb8a
7 changed files with 438 additions and 4 deletions

View file

@ -54,6 +54,23 @@ data class PluginsUiState(
val error: String? = null
)
data class PluginSettingsOptionUiModel(
val label: String,
val value: String
)
data class PluginSettingsFieldUiModel(
val key: String,
val type: String,
val label: String,
val description: String? = null,
val placeholder: String? = null,
val isPassword: Boolean = false,
val defaultValue: String? = null,
val defaultBoolean: Boolean = false,
val options: List<PluginSettingsOptionUiModel> = emptyList()
)
private data class PersistedScraperOverride(
val enabled: Boolean = true,
val settings: Map<String, Any?> = emptyMap()
@ -156,7 +173,16 @@ class PluginRepositoryManager @Inject constructor(
): List<Stream> = withContext(Dispatchers.IO) {
val code = fetchScraperCode(scraper) ?: return@withContext emptyList()
try {
val rawJson = FluxaCoreUniFfi.executePluginScraper(pluginHttpClient, code, tmdbId, mediaType, season, episode)
val rawJson = FluxaCoreUniFfi.executePluginScraper(
pluginHttpClient,
code,
scraper.id,
gson.toJson(scraper.settings),
tmdbId,
mediaType,
season,
episode
)
val normalized = FluxaCoreUniFfi.coreInvokeValue("pluginStreamResultsToStreams", rawJson)
val streams: List<Stream> = gson.fromJson(normalized, streamListType) ?: emptyList()
streams.map { it.copy(addonName = scraper.name) }
@ -166,6 +192,54 @@ class PluginRepositoryManager @Inject constructor(
}
}
suspend fun getSettingsLayout(scraper: PluginScraperUiModel): List<PluginSettingsFieldUiModel> =
withContext(Dispatchers.IO) {
val code = fetchScraperCode(scraper) ?: return@withContext emptyList()
try {
val layoutJson = FluxaCoreUniFfi.getPluginScraperSettingsLayout(code, scraper.id)
parseSettingsLayout(layoutJson)
} catch (e: Exception) {
Log.w("PluginRepositoryManager", "settings layout for ${scraper.id} failed", e)
emptyList()
}
}
private fun parseSettingsLayout(layoutJson: String): List<PluginSettingsFieldUiModel> {
val elements = try {
com.google.gson.JsonParser.parseString(layoutJson).asJsonArray
} catch (e: Exception) {
return emptyList()
}
return elements.mapNotNull { element ->
val field = element.asJsonObject
val type = field.get("type")?.asString ?: return@mapNotNull null
val key = field.get("key")?.asString ?: ""
val label = field.get("label")?.asString ?: ""
val optionsArray = field.get("options")?.takeIf { it.isJsonArray }?.asJsonArray
val options = optionsArray?.map { option ->
val optionObject = option.asJsonObject
PluginSettingsOptionUiModel(
label = optionObject.get("label")?.asString ?: "",
value = optionObject.get("value")?.asString ?: ""
)
} ?: emptyList()
val defaultValueElement = field.get("defaultValue")
val defaultIsBoolean = defaultValueElement?.isJsonPrimitive == true &&
defaultValueElement.asJsonPrimitive.isBoolean
PluginSettingsFieldUiModel(
key = key,
type = type,
label = label,
description = field.get("description")?.asString,
placeholder = field.get("placeholder")?.asString,
isPassword = field.get("isPassword")?.asBoolean ?: false,
defaultValue = if (!defaultIsBoolean) defaultValueElement?.takeIf { !it.isJsonNull }?.asString else null,
defaultBoolean = if (defaultIsBoolean) defaultValueElement.asBoolean else false,
options = options
)
}
}
private suspend fun fetchScraperCode(scraper: PluginScraperUiModel): String? = codeCacheMutex.withLock {
codeCache[scraper.id]?.let { return@withLock it }
val url = resolveScraperUrl(scraper.repositoryUrl, scraper.filename)

View file

@ -0,0 +1,254 @@
package com.fluxa.app.ui.settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowDropDown
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
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.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.fluxa.app.common.AppStrings
import com.fluxa.app.plugins.PluginSettingsFieldUiModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PluginScraperSettingsSheet(
scraperName: String,
language: String?,
loading: Boolean,
fields: List<PluginSettingsFieldUiModel>,
initialValues: Map<String, Any?>,
onDismiss: () -> Unit,
onSave: (Map<String, Any?>) -> Unit
) {
val values = remember(fields, initialValues) {
val map = mutableStateMapOf<String, Any?>()
fields.forEach { field ->
if (field.key.isEmpty()) return@forEach
map[field.key] = initialValues[field.key] ?: when (field.type) {
"toggle" -> field.defaultBoolean
else -> field.defaultValue
}
}
map
}
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = Color(0xFF111318),
contentColor = Color.White
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 22.dp, vertical = 10.dp)
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom))
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
AppStrings.format(language, "settings.plugins.settings_title", scraperName),
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Black
)
when {
loading -> {
Box(modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(24.dp))
}
}
fields.isEmpty() -> {
Text(
AppStrings.t(language, "settings.plugins.settings_empty"),
color = Color.White.copy(alpha = 0.5f),
modifier = Modifier.padding(vertical = 12.dp)
)
}
else -> {
fields.forEach { field ->
PluginSettingsField(
field = field,
language = language,
value = values[field.key],
onValueChange = { values[field.key] = it }
)
}
}
}
if (!loading && fields.isNotEmpty()) {
Button(
onClick = { onSave(values.toMap()) },
modifier = Modifier.fillMaxWidth().height(52.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color.Black),
shape = RoundedCornerShape(12.dp)
) {
Text(AppStrings.t(language, "settings.plugins.settings_save"), fontWeight = FontWeight.Black)
}
}
}
}
}
@Composable
private fun PluginSettingsField(
field: PluginSettingsFieldUiModel,
language: String?,
value: Any?,
onValueChange: (Any?) -> Unit
) {
when (field.type) {
"header" -> {
Text(
field.label,
color = Color.White.copy(alpha = 0.6f),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 8.dp)
)
}
"info" -> {
Text(
field.label,
color = Color.White.copy(alpha = 0.5f),
fontSize = 13.sp
)
}
"text" -> {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(field.label, color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
TextField(
value = value as? String ?: "",
onValueChange = onValueChange,
placeholder = { field.placeholder?.let { Text(it, color = Color.White.copy(alpha = 0.35f)) } },
singleLine = true,
visualTransformation = if (field.isPassword) PasswordVisualTransformation() else VisualTransformation.None,
keyboardOptions = KeyboardOptions(keyboardType = if (field.isPassword) KeyboardType.Password else KeyboardType.Text),
modifier = Modifier.fillMaxWidth(),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White.copy(alpha = 0.05f),
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
focusedTextColor = Color.White,
unfocusedTextColor = Color.White
)
)
field.description?.let {
Text(it, color = Color.White.copy(alpha = 0.4f), fontSize = 11.sp)
}
}
}
"select" -> {
var expanded by remember { mutableStateOf(false) }
val currentValue = value as? String ?: field.defaultValue ?: ""
val selectedLabel = field.options.find { it.value == currentValue }?.label
?: currentValue.ifBlank { AppStrings.t(language, "settings.plugins.select_placeholder") }
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(field.label, color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
Box {
OutlinedButton(
onClick = { expanded = true },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
contentPadding = PaddingValues(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(selectedLabel, color = Color.White)
Icon(Icons.Rounded.ArrowDropDown, contentDescription = null, tint = Color.White.copy(alpha = 0.6f))
}
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.fillMaxWidth(0.85f)
) {
field.options.forEach { option ->
DropdownMenuItem(
text = { Text(option.label) },
onClick = {
onValueChange(option.value)
expanded = false
}
)
}
}
}
field.description?.let {
Text(it, color = Color.White.copy(alpha = 0.4f), fontSize = 11.sp)
}
}
}
"toggle" -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(field.label, color = Color.White, fontSize = 14.sp)
field.description?.let {
Text(it, color = Color.White.copy(alpha = 0.4f), fontSize = 11.sp)
}
}
Switch(
checked = value as? Boolean ?: field.defaultBoolean,
onCheckedChange = onValueChange,
colors = SwitchDefaults.colors(
checkedTrackColor = Color.White,
checkedThumbColor = Color.Black,
uncheckedTrackColor = Color.White.copy(alpha = 0.15f),
uncheckedThumbColor = Color.White.copy(alpha = 0.7f)
)
)
}
}
}
}

View file

@ -38,6 +38,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.Icon
import com.fluxa.app.common.AppStrings
import com.fluxa.app.plugins.PluginRepositoryUiModel
import com.fluxa.app.plugins.PluginScraperUiModel
@ -57,8 +60,22 @@ fun PluginsSettingsRoute(
onAddRepository = viewModel::addRepository,
onRemoveRepository = viewModel::removeRepository,
onToggleScraper = viewModel::toggleScraper,
onOpenScraperSettings = viewModel::openScraperSettings,
onBackRequested = onBackRequested
)
val settingsSheet = viewModel.settingsSheet
if (settingsSheet != null) {
PluginScraperSettingsSheet(
scraperName = settingsSheet.scraper.name,
language = language,
loading = settingsSheet.loading,
fields = settingsSheet.fields,
initialValues = settingsSheet.scraper.settings,
onDismiss = viewModel::dismissScraperSettings,
onSave = { values -> viewModel.saveScraperSettings(settingsSheet.scraper.id, values) }
)
}
}
@Composable
@ -68,6 +85,7 @@ fun PluginsSettingsScreen(
onAddRepository: (String) -> Unit,
onRemoveRepository: (String) -> Unit,
onToggleScraper: (String, Boolean) -> Unit,
onOpenScraperSettings: (PluginScraperUiModel) -> Unit,
onBackRequested: () -> Unit,
modifier: Modifier = Modifier
) {
@ -123,7 +141,12 @@ fun PluginsSettingsScreen(
)
} else {
state.scrapers.forEach { scraper ->
PluginScraperRow(scraper = scraper, onToggle = { enabled -> onToggleScraper(scraper.id, enabled) })
PluginScraperRow(
scraper = scraper,
language = language,
onToggle = { enabled -> onToggleScraper(scraper.id, enabled) },
onOpenSettings = { onOpenScraperSettings(scraper) }
)
}
}
Spacer(Modifier.height(120.dp))
@ -214,7 +237,12 @@ private fun PluginRepositoryRow(
}
@Composable
private fun PluginScraperRow(scraper: PluginScraperUiModel, onToggle: (Boolean) -> Unit) {
private fun PluginScraperRow(
scraper: PluginScraperUiModel,
language: String?,
onToggle: (Boolean) -> Unit,
onOpenSettings: () -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
@ -224,6 +252,24 @@ private fun PluginScraperRow(scraper: PluginScraperUiModel, onToggle: (Boolean)
Text(scraper.name, color = Color.White)
Text(scraper.supportedTypes.joinToString(", "), color = Color.White.copy(alpha = 0.4f), fontSize = 11.sp)
}
if (scraper.hasSettings) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.05f))
.clickable(onClick = onOpenSettings),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Rounded.Settings,
contentDescription = AppStrings.t(language, "settings.plugins.settings"),
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(18.dp)
)
}
Spacer(Modifier.width(8.dp))
}
Switch(
checked = scraper.enabled,
onCheckedChange = onToggle,

View file

@ -1,8 +1,13 @@
package com.fluxa.app.ui.settings
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fluxa.app.plugins.PluginRepositoryManager
import com.fluxa.app.plugins.PluginScraperUiModel
import com.fluxa.app.plugins.PluginSettingsFieldUiModel
import com.fluxa.app.plugins.PluginsUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
@ -11,6 +16,12 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
data class PluginScraperSettingsUiState(
val scraper: PluginScraperUiModel,
val loading: Boolean,
val fields: List<PluginSettingsFieldUiModel>
)
@HiltViewModel
class PluginsSettingsViewModel @Inject constructor(
private val manager: PluginRepositoryManager
@ -19,6 +30,9 @@ class PluginsSettingsViewModel @Inject constructor(
val state: StateFlow<PluginsUiState> = manager.state
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PluginsUiState())
var settingsSheet by mutableStateOf<PluginScraperSettingsUiState?>(null)
private set
fun addRepository(url: String) {
val trimmed = url.trim()
if (trimmed.isEmpty()) return
@ -32,4 +46,23 @@ class PluginsSettingsViewModel @Inject constructor(
fun toggleScraper(scraperId: String, enabled: Boolean) {
viewModelScope.launch { manager.toggleScraper(scraperId, enabled) }
}
fun openScraperSettings(scraper: PluginScraperUiModel) {
settingsSheet = PluginScraperSettingsUiState(scraper = scraper, loading = true, fields = emptyList())
viewModelScope.launch {
val fields = manager.getSettingsLayout(scraper)
if (settingsSheet?.scraper?.id == scraper.id) {
settingsSheet = settingsSheet?.copy(loading = false, fields = fields)
}
}
}
fun dismissScraperSettings() {
settingsSheet = null
}
fun saveScraperSettings(scraperId: String, values: Map<String, Any?>) {
viewModelScope.launch { manager.updateScraperSettings(scraperId, values) }
settingsSheet = null
}
}

View file

@ -298,6 +298,12 @@
"settings.plugins.scraper_count": "%s scrapers",
"settings.plugins.adding": "Adding repository…",
"settings.plugins.error": "Failed to add repository: %s",
"settings.plugins.settings": "Settings",
"settings.plugins.settings_title": "%s Settings",
"settings.plugins.settings_save": "Save",
"settings.plugins.settings_loading": "Loading settings…",
"settings.plugins.settings_empty": "This scraper has no configurable settings.",
"settings.plugins.select_placeholder": "Select an option",
"settings.torrent_wifi_only": "Wi-Fi Only",
"settings.torrent_wifi_only_desc": "Only stream torrents when connected to Wi-Fi.",
"auto.stremio_like_speed_presets": "Stremio-like speed presets.",

View file

@ -298,6 +298,12 @@
"settings.plugins.scraper_count": "%s kazıyıcı",
"settings.plugins.adding": "Depo ekleniyor…",
"settings.plugins.error": "Depo eklenemedi: %s",
"settings.plugins.settings": "Ayarlar",
"settings.plugins.settings_title": "%s Ayarları",
"settings.plugins.settings_save": "Kaydet",
"settings.plugins.settings_loading": "Ayarlar yükleniyor…",
"settings.plugins.settings_empty": "Bu kazıyıcının yapılandırılabilir ayarı yok.",
"settings.plugins.select_placeholder": "Bir seçenek seçin",
"settings.torrent_wifi_only": "Yalnızca Wi-Fi",
"settings.torrent_wifi_only_desc": "Yalnızca Wi-Fi'ye bağlıyken torrent akışı yapar.",
"auto.stremio_like_speed_presets": "Stremio benzeri hazır hız profilleri.",

View file

@ -10,6 +10,7 @@ import com.fluxa.core.uniffi.destroyAppCoreStateJson
import com.fluxa.core.uniffi.destroyHeadlessEngineJson
import com.fluxa.core.uniffi.executePluginScraper as executePluginScraperUniFfi
import com.fluxa.core.uniffi.fluxaCoreVersion
import com.fluxa.core.uniffi.getPluginScraperSettingsLayout as getPluginScraperSettingsLayoutUniFfi
import com.fluxa.core.uniffi.headlessEngineCompleteEffectJson
import com.fluxa.core.uniffi.headlessEngineDispatchJson
import com.fluxa.core.uniffi.headlessEngineSnapshotJson
@ -91,9 +92,23 @@ object FluxaCoreUniFfi {
fun executePluginScraper(
client: PluginHttpClient,
code: String,
scraperId: String,
scraperSettingsJson: String,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?
): String = executePluginScraperUniFfi(client, code, tmdbId, mediaType, season, episode)
): String = executePluginScraperUniFfi(
client,
code,
scraperId,
scraperSettingsJson,
tmdbId,
mediaType,
season,
episode
)
fun getPluginScraperSettingsLayout(code: String, scraperId: String): String =
getPluginScraperSettingsLayoutUniFfi(code, scraperId)
}