mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
feat: add upto 3 badges
This commit is contained in:
parent
226e8370f1
commit
1d68504a05
5 changed files with 569 additions and 110 deletions
|
|
@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
|
@ -242,27 +243,46 @@ object DebridSettingsRepository {
|
|||
}
|
||||
|
||||
return try {
|
||||
val currentRules = streamBadgeRules.normalized()
|
||||
val isExistingImport = currentRules.imports.any { import ->
|
||||
import.sourceUrl.equals(normalizedUrl, ignoreCase = true)
|
||||
}
|
||||
if (!isExistingImport && currentRules.imports.size >= DEBRID_STREAM_BADGE_IMPORT_LIMIT) {
|
||||
return DebridStreamBadgeImportResult.Error("You can import up to $DEBRID_STREAM_BADGE_IMPORT_LIMIT badge URLs.")
|
||||
}
|
||||
val payload = httpGetText(normalizedUrl)
|
||||
val parsed = DebridStreamBadgeRulesParser.parse(
|
||||
val parsedImport = DebridStreamBadgeRulesParser.parse(
|
||||
sourceUrl = normalizedUrl,
|
||||
payload = payload,
|
||||
)
|
||||
streamBadgeRules = parsed
|
||||
streamBadgeRules = currentRules.upsert(parsedImport, activate = true)
|
||||
publish()
|
||||
saveStreamBadgeRules()
|
||||
DebridStreamBadgeImportResult.Success(parsed)
|
||||
DebridStreamBadgeImportResult.Success(streamBadgeRules)
|
||||
} catch (error: Exception) {
|
||||
if (error is CancellationException) throw error
|
||||
DebridStreamBadgeImportResult.Error(error.message ?: "Badge import failed.")
|
||||
}
|
||||
}
|
||||
|
||||
fun clearStreamBadgeRules() {
|
||||
fun setActiveStreamBadgeRulesSource(sourceUrl: String) {
|
||||
ensureLoaded()
|
||||
if (streamBadgeRules == DebridStreamBadgeRules()) return
|
||||
streamBadgeRules = DebridStreamBadgeRules()
|
||||
val currentRules = streamBadgeRules.normalized()
|
||||
val nextRules = currentRules.setActiveSource(sourceUrl)
|
||||
if (nextRules == currentRules) return
|
||||
streamBadgeRules = nextRules
|
||||
publish()
|
||||
DebridSettingsStorage.saveStreamBadgeRules("")
|
||||
saveStreamBadgeRules()
|
||||
}
|
||||
|
||||
fun deleteStreamBadgeRulesSource(sourceUrl: String) {
|
||||
ensureLoaded()
|
||||
val currentRules = streamBadgeRules.normalized()
|
||||
val nextRules = currentRules.removeSource(sourceUrl)
|
||||
if (nextRules == currentRules) return
|
||||
streamBadgeRules = nextRules
|
||||
publish()
|
||||
saveStreamBadgeRules()
|
||||
}
|
||||
|
||||
private fun disableIfNoResolver() {
|
||||
|
|
@ -392,7 +412,13 @@ object DebridSettingsRepository {
|
|||
}
|
||||
|
||||
private fun saveStreamBadgeRules() {
|
||||
DebridSettingsStorage.saveStreamBadgeRules(json.encodeToString(streamBadgeRules))
|
||||
val normalizedRules = streamBadgeRules.normalized()
|
||||
val payload = if (normalizedRules.hasImport) {
|
||||
json.encodeToString(normalizedRules)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
DebridSettingsStorage.saveStreamBadgeRules(payload)
|
||||
}
|
||||
|
||||
private inline fun <reified T : Enum<T>> enumValueOrDefault(value: String?, default: T): T =
|
||||
|
|
@ -411,13 +437,25 @@ object DebridSettingsRepository {
|
|||
|
||||
private fun parseStreamBadgeRules(value: String?): DebridStreamBadgeRules? {
|
||||
if (value.isNullOrBlank()) return null
|
||||
return try {
|
||||
json.decodeFromString<DebridStreamBadgeRules>(value)
|
||||
val decodedRules = try {
|
||||
json.decodeFromString<DebridStreamBadgeRules>(value).normalized()
|
||||
} catch (_: SerializationException) {
|
||||
null
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
if (decodedRules?.hasImport == true) return decodedRules
|
||||
|
||||
val legacyRules = try {
|
||||
json.decodeFromString<LegacyDebridStreamBadgeRules>(value)
|
||||
.toBadgeRules()
|
||||
.normalized()
|
||||
} catch (_: SerializationException) {
|
||||
null
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
return legacyRules?.takeIf { it.hasImport } ?: decodedRules
|
||||
}
|
||||
|
||||
private enum class DebridTemplateKind {
|
||||
|
|
@ -436,6 +474,24 @@ object DebridSettingsRepository {
|
|||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class LegacyDebridStreamBadgeRules(
|
||||
val sourceUrl: String = "",
|
||||
val filters: List<DebridStreamBadgeFilter> = emptyList(),
|
||||
val groups: List<DebridStreamBadgeGroup> = emptyList(),
|
||||
) {
|
||||
fun toBadgeRules(): DebridStreamBadgeRules =
|
||||
DebridStreamBadgeRules(
|
||||
imports = listOf(
|
||||
DebridStreamBadgeImport(
|
||||
sourceUrl = sourceUrl,
|
||||
filters = filters,
|
||||
groups = groups,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences =
|
||||
copy(
|
||||
maxResults = normalizeDebridStreamMaxResults(maxResults),
|
||||
|
|
|
|||
|
|
@ -8,17 +8,96 @@ import kotlinx.serialization.SerializationException
|
|||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
const val DEBRID_STREAM_BADGE_IMPORT_LIMIT = 3
|
||||
|
||||
@Serializable
|
||||
data class DebridStreamBadgeRules(
|
||||
val imports: List<DebridStreamBadgeImport> = emptyList(),
|
||||
) {
|
||||
val hasImport: Boolean
|
||||
get() = imports.isNotEmpty()
|
||||
|
||||
val activeImport: DebridStreamBadgeImport?
|
||||
get() = imports.firstOrNull { it.isActive } ?: imports.firstOrNull()
|
||||
|
||||
val enabledFilterCount: Int
|
||||
get() = activeImport?.enabledFilterCount ?: 0
|
||||
|
||||
fun normalized(): DebridStreamBadgeRules {
|
||||
val normalizedImports = mutableListOf<DebridStreamBadgeImport>()
|
||||
imports.forEach { import ->
|
||||
val normalizedUrl = import.sourceUrl.trim()
|
||||
if (normalizedUrl.isBlank() || import.filters.isEmpty()) return@forEach
|
||||
val normalizedImport = import.copy(sourceUrl = normalizedUrl)
|
||||
val existingIndex = normalizedImports.indexOfFirst { it.sourceUrl.equals(normalizedUrl, ignoreCase = true) }
|
||||
if (existingIndex >= 0) {
|
||||
normalizedImports[existingIndex] = normalizedImport
|
||||
} else if (normalizedImports.size < DEBRID_STREAM_BADGE_IMPORT_LIMIT) {
|
||||
normalizedImports += normalizedImport
|
||||
}
|
||||
}
|
||||
if (normalizedImports.isEmpty()) return copy(imports = emptyList())
|
||||
val activeIndex = normalizedImports.indexOfFirst { it.isActive }.takeIf { it >= 0 } ?: 0
|
||||
return copy(
|
||||
imports = normalizedImports.mapIndexed { index, import ->
|
||||
import.copy(isActive = index == activeIndex)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun upsert(import: DebridStreamBadgeImport, activate: Boolean = true): DebridStreamBadgeRules {
|
||||
val normalizedUrl = import.sourceUrl.trim()
|
||||
if (normalizedUrl.isBlank()) return normalized()
|
||||
val normalizedImport = import.copy(sourceUrl = normalizedUrl, isActive = activate)
|
||||
val replaced = imports.map { existing ->
|
||||
if (existing.sourceUrl.equals(normalizedUrl, ignoreCase = true)) normalizedImport else existing
|
||||
}
|
||||
val nextImports = if (imports.any { it.sourceUrl.equals(normalizedUrl, ignoreCase = true) }) {
|
||||
replaced
|
||||
} else {
|
||||
imports + normalizedImport
|
||||
}
|
||||
val activeImports = if (activate) {
|
||||
nextImports.map { existing ->
|
||||
existing.copy(isActive = existing.sourceUrl.equals(normalizedUrl, ignoreCase = true))
|
||||
}
|
||||
} else {
|
||||
nextImports
|
||||
}
|
||||
return copy(imports = activeImports).normalized()
|
||||
}
|
||||
|
||||
fun setActiveSource(sourceUrl: String): DebridStreamBadgeRules {
|
||||
val normalizedUrl = sourceUrl.trim()
|
||||
if (normalizedUrl.isBlank() || imports.none { it.sourceUrl.equals(normalizedUrl, ignoreCase = true) }) {
|
||||
return normalized()
|
||||
}
|
||||
return copy(
|
||||
imports = imports.map { import ->
|
||||
import.copy(isActive = import.sourceUrl.equals(normalizedUrl, ignoreCase = true))
|
||||
},
|
||||
).normalized()
|
||||
}
|
||||
|
||||
fun removeSource(sourceUrl: String): DebridStreamBadgeRules =
|
||||
copy(imports = imports.filterNot { it.sourceUrl.equals(sourceUrl.trim(), ignoreCase = true) }).normalized()
|
||||
|
||||
override fun toString(): String =
|
||||
"DebridStreamBadgeRules(imports=${imports.size}, activeFilters=$enabledFilterCount, groups=${imports.sumOf { it.groups.size }})"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class DebridStreamBadgeImport(
|
||||
val sourceUrl: String = "",
|
||||
val filters: List<DebridStreamBadgeFilter> = emptyList(),
|
||||
val groups: List<DebridStreamBadgeGroup> = emptyList(),
|
||||
val isActive: Boolean = true,
|
||||
) {
|
||||
val hasImport: Boolean
|
||||
get() = filters.isNotEmpty()
|
||||
val enabledFilterCount: Int
|
||||
get() = filters.count { it.isEnabled }
|
||||
|
||||
override fun toString(): String =
|
||||
"DebridStreamBadgeRules(sourceUrl=$sourceUrl, filters=${filters.size}, groups=${groups.size})"
|
||||
"DebridStreamBadgeImport(sourceUrl=$sourceUrl, filters=${filters.size}, groups=${groups.size}, isActive=$isActive)"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
|
@ -55,7 +134,7 @@ internal object DebridStreamBadgeRulesParser {
|
|||
explicitNulls = false
|
||||
}
|
||||
|
||||
fun parse(sourceUrl: String, payload: String): DebridStreamBadgeRules {
|
||||
fun parse(sourceUrl: String, payload: String): DebridStreamBadgeImport {
|
||||
val decoded = try {
|
||||
json.decodeFromString<DebridStreamBadgePayload>(payload)
|
||||
} catch (error: SerializationException) {
|
||||
|
|
@ -98,7 +177,7 @@ internal object DebridStreamBadgeRulesParser {
|
|||
)
|
||||
}
|
||||
|
||||
return DebridStreamBadgeRules(
|
||||
return DebridStreamBadgeImport(
|
||||
sourceUrl = sourceUrl.trim(),
|
||||
filters = filters,
|
||||
groups = groups,
|
||||
|
|
@ -109,23 +188,25 @@ internal object DebridStreamBadgeRulesParser {
|
|||
internal object DebridStreamBadgeMatcher {
|
||||
fun compile(rules: DebridStreamBadgeRules): List<DebridCompiledStreamBadgeFilter> {
|
||||
if (!rules.hasImport) return emptyList()
|
||||
return rules.filters.mapNotNull { filter ->
|
||||
if (!filter.isEnabled || filter.name.isBlank() || filter.pattern.isBlank()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val regex = runCatching { Regex(filter.pattern) }.getOrNull() ?: return@mapNotNull null
|
||||
DebridCompiledStreamBadgeFilter(
|
||||
name = filter.name,
|
||||
badge = StreamBadge(
|
||||
return rules.normalized().imports.filter { it.isActive }.flatMap { import ->
|
||||
import.filters.mapNotNull { filter ->
|
||||
if (!filter.isEnabled || filter.name.isBlank() || filter.pattern.isBlank()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val regex = runCatching { Regex(filter.pattern) }.getOrNull() ?: return@mapNotNull null
|
||||
DebridCompiledStreamBadgeFilter(
|
||||
name = filter.name,
|
||||
imageURL = filter.imageURL,
|
||||
tagColor = filter.tagColor,
|
||||
tagStyle = filter.tagStyle,
|
||||
textColor = filter.textColor,
|
||||
borderColor = filter.borderColor,
|
||||
),
|
||||
regex = regex,
|
||||
)
|
||||
badge = StreamBadge(
|
||||
name = filter.name,
|
||||
imageURL = filter.imageURL,
|
||||
tagColor = filter.tagColor,
|
||||
tagStyle = filter.tagStyle,
|
||||
textColor = filter.textColor,
|
||||
borderColor = filter.borderColor,
|
||||
),
|
||||
regex = regex,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
package com.nuvio.app.features.settings
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
|
@ -12,21 +16,27 @@ import androidx.compose.foundation.layout.height
|
|||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Visibility
|
||||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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.RadioButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
|
|
@ -40,12 +50,18 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.features.debrid.DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT
|
||||
import com.nuvio.app.features.debrid.DEBRID_STREAM_BADGE_IMPORT_LIMIT
|
||||
import com.nuvio.app.features.debrid.DebridCredentialValidator
|
||||
import com.nuvio.app.features.debrid.DebridDeviceAuthorization
|
||||
import com.nuvio.app.features.debrid.DebridDeviceAuthorizationTokenResult
|
||||
|
|
@ -56,6 +72,8 @@ import com.nuvio.app.features.debrid.DebridProviders
|
|||
import com.nuvio.app.features.debrid.DebridSettings
|
||||
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||
import com.nuvio.app.features.debrid.DebridStreamBadgeImportResult
|
||||
import com.nuvio.app.features.debrid.DebridStreamBadgeFilter
|
||||
import com.nuvio.app.features.debrid.DebridStreamBadgeImport
|
||||
import com.nuvio.app.features.debrid.DebridStreamBadgeRules
|
||||
import com.nuvio.app.features.debrid.DebridStreamFormatterDefaults
|
||||
import com.nuvio.app.features.debrid.DebridStreamAudioChannel
|
||||
|
|
@ -75,6 +93,7 @@ import kotlinx.coroutines.delay
|
|||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.action_cancel
|
||||
import nuvio.composeapp.generated.resources.action_clear
|
||||
import nuvio.composeapp.generated.resources.action_delete
|
||||
import nuvio.composeapp.generated.resources.action_retry
|
||||
import nuvio.composeapp.generated.resources.action_reset
|
||||
import nuvio.composeapp.generated.resources.action_save
|
||||
|
|
@ -428,8 +447,8 @@ internal fun LazyListScope.debridSettingsContent(
|
|||
SettingsGroupDivider(isTablet = isTablet)
|
||||
DebridPreferenceRow(
|
||||
isTablet = isTablet,
|
||||
title = "Badge URL",
|
||||
description = "Import Fusion badge filters from a JSON URL.",
|
||||
title = "Badge URLs",
|
||||
description = "Manage imported label badge JSON URLs.",
|
||||
value = badgeRulesPreview(settings.streamBadgeRules),
|
||||
enabled = settings.canResolvePlayableLinks,
|
||||
onClick = { showBadgeImportDialog = true },
|
||||
|
|
@ -467,7 +486,7 @@ internal fun LazyListScope.debridSettingsContent(
|
|||
}
|
||||
|
||||
if (showBadgeImportDialog) {
|
||||
DebridBadgeImportDialog(
|
||||
DebridBadgeUrlManagerDialog(
|
||||
currentRules = settings.streamBadgeRules,
|
||||
onDismiss = { showBadgeImportDialog = false },
|
||||
)
|
||||
|
|
@ -519,12 +538,14 @@ private fun templatePreview(value: String, defaultValue: String): String {
|
|||
return if (firstLine.length <= 28) firstLine else "${firstLine.take(28)}..."
|
||||
}
|
||||
|
||||
private fun badgeRulesPreview(rules: DebridStreamBadgeRules): String =
|
||||
if (rules.hasImport) {
|
||||
"${rules.filters.count { it.isEnabled }} badges"
|
||||
private fun badgeRulesPreview(rules: DebridStreamBadgeRules): String {
|
||||
val normalizedRules = rules.normalized()
|
||||
return if (normalizedRules.hasImport) {
|
||||
"${normalizedRules.imports.size}/$DEBRID_STREAM_BADGE_IMPORT_LIMIT URLs, ${normalizedRules.enabledFilterCount} active badges"
|
||||
} else {
|
||||
"Not imported"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun prepareCountLabel(limit: Int): String =
|
||||
|
|
@ -691,19 +712,21 @@ private fun DebridTemplateDialog(
|
|||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun DebridBadgeImportDialog(
|
||||
private fun DebridBadgeUrlManagerDialog(
|
||||
currentRules: DebridStreamBadgeRules,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var draftUrl by rememberSaveable(currentRules.sourceUrl) { mutableStateOf(currentRules.sourceUrl) }
|
||||
val imports = currentRules.normalized().imports
|
||||
var draftUrl by rememberSaveable { mutableStateOf("") }
|
||||
var errorMessage by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var isImporting by rememberSaveable { mutableStateOf(false) }
|
||||
var previewImport by remember { mutableStateOf<DebridStreamBadgeImport?>(null) }
|
||||
|
||||
BasicAlertDialog(onDismissRequest = onDismiss) {
|
||||
DebridDialogSurface(title = "Import badge URL") {
|
||||
DebridDialogSurface(title = "Badge URLs") {
|
||||
Text(
|
||||
text = "Paste a Fusion badge filter JSON URL. Nuvio stores the imported rules and only uses user-imported badges.",
|
||||
text = "Import up to $DEBRID_STREAM_BADGE_IMPORT_LIMIT label badge JSON URLs. Each URL can be updated or deleted separately.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -714,6 +737,7 @@ private fun DebridBadgeImportDialog(
|
|||
errorMessage = null
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Badge JSON URL") },
|
||||
singleLine = false,
|
||||
minLines = 2,
|
||||
maxLines = 4,
|
||||
|
|
@ -726,50 +750,28 @@ private fun DebridBadgeImportDialog(
|
|||
disabledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
errorMessage?.let { message ->
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
if (currentRules.hasImport) {
|
||||
Text(
|
||||
text = "${currentRules.filters.count { it.isEnabled }} enabled badges imported from ${currentRules.groups.size} groups.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (currentRules.hasImport) {
|
||||
TextButton(
|
||||
enabled = !isImporting,
|
||||
onClick = {
|
||||
DebridSettingsRepository.clearStreamBadgeRules()
|
||||
onDismiss()
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(Res.string.action_clear), maxLines = 1)
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
enabled = !isImporting,
|
||||
onClick = onDismiss,
|
||||
) {
|
||||
Text(text = stringResource(Res.string.action_cancel), maxLines = 1)
|
||||
}
|
||||
Text(
|
||||
text = "${imports.size}/$DEBRID_STREAM_BADGE_IMPORT_LIMIT URLs imported",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Button(
|
||||
enabled = !isImporting,
|
||||
enabled = !isImporting && draftUrl.isNotBlank(),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
isImporting = true
|
||||
errorMessage = null
|
||||
when (val result = DebridSettingsRepository.importStreamBadgeRulesFromUrl(draftUrl)) {
|
||||
is DebridStreamBadgeImportResult.Success -> onDismiss()
|
||||
is DebridStreamBadgeImportResult.Success -> {
|
||||
draftUrl = ""
|
||||
isImporting = false
|
||||
}
|
||||
is DebridStreamBadgeImportResult.Error -> {
|
||||
errorMessage = result.message
|
||||
isImporting = false
|
||||
|
|
@ -789,8 +791,305 @@ private fun DebridBadgeImportDialog(
|
|||
}
|
||||
}
|
||||
}
|
||||
errorMessage?.let { message ->
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
if (imports.isEmpty()) {
|
||||
Text(
|
||||
text = "No badge URLs imported.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 300.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(
|
||||
items = imports,
|
||||
key = { import -> import.sourceUrl },
|
||||
) { import ->
|
||||
DebridBadgeUrlRow(
|
||||
import = import,
|
||||
showActiveChoice = imports.size > 1,
|
||||
enabled = !isImporting,
|
||||
onActivate = {
|
||||
DebridSettingsRepository.setActiveStreamBadgeRulesSource(import.sourceUrl)
|
||||
},
|
||||
onPreview = { previewImport = import },
|
||||
onDelete = {
|
||||
DebridSettingsRepository.deleteStreamBadgeRulesSource(import.sourceUrl)
|
||||
if (previewImport?.sourceUrl.equals(import.sourceUrl, ignoreCase = true)) {
|
||||
previewImport = null
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(
|
||||
enabled = !isImporting,
|
||||
onClick = onDismiss,
|
||||
) {
|
||||
Text(text = stringResource(Res.string.action_cancel), maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previewImport?.let { import ->
|
||||
DebridBadgePreviewDialog(
|
||||
import = import,
|
||||
onDismiss = { previewImport = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DebridBadgeUrlRow(
|
||||
import: DebridStreamBadgeImport,
|
||||
showActiveChoice: Boolean,
|
||||
enabled: Boolean,
|
||||
onActivate: () -> Unit,
|
||||
onPreview: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val containerColor = if (import.isActive) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = containerColor,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (showActiveChoice) {
|
||||
RadioButton(
|
||||
selected = import.isActive,
|
||||
onClick = onActivate,
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = import.sourceUrl,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
) {
|
||||
val status = if (import.isActive) "Active" else "Inactive"
|
||||
Text(
|
||||
text = "$status, ${import.enabledFilterCount} enabled badges, ${import.groups.size} groups",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(
|
||||
enabled = enabled,
|
||||
onClick = onPreview,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Visibility,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(text = "Preview", maxLines = 1)
|
||||
}
|
||||
IconButton(
|
||||
enabled = enabled,
|
||||
onClick = onDelete,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Delete,
|
||||
contentDescription = stringResource(Res.string.action_delete),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
private fun DebridBadgePreviewDialog(
|
||||
import: DebridStreamBadgeImport,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sections = remember(import) { badgePreviewSections(import) }
|
||||
val badgeCount = sections.sumOf { it.filters.size }
|
||||
|
||||
BasicAlertDialog(onDismissRequest = onDismiss) {
|
||||
DebridDialogSurface(title = "Badge preview") {
|
||||
Text(
|
||||
text = import.sourceUrl,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "$badgeCount badges from this URL",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (sections.isEmpty()) {
|
||||
Text(
|
||||
text = "No badge images in this URL.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 460.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
items(
|
||||
items = sections,
|
||||
key = { section -> section.id },
|
||||
) { section ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = section.title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
section.filters.forEach { filter ->
|
||||
DebridBadgePreviewChip(filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(text = "Close", maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DebridBadgePreviewChip(filter: DebridStreamBadgeFilter) {
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
val backgroundColor = if (filter.tagStyle.equals("filled", ignoreCase = true)) {
|
||||
filter.tagColor.toBadgeColorOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val borderColor = filter.borderColor.toBadgeColorOrNull()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(24.dp)
|
||||
.then(if (backgroundColor != null) Modifier.background(backgroundColor, shape) else Modifier)
|
||||
.then(if (borderColor != null) Modifier.border(1.dp, borderColor, shape) else Modifier)
|
||||
.padding(horizontal = 4.dp, vertical = 3.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = filter.imageURL,
|
||||
contentDescription = filter.name,
|
||||
modifier = Modifier
|
||||
.height(18.dp)
|
||||
.widthIn(min = 38.dp, max = 112.dp)
|
||||
.clip(shape),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class DebridBadgePreviewSection(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val filters: List<DebridStreamBadgeFilter>,
|
||||
)
|
||||
|
||||
private fun badgePreviewSections(import: DebridStreamBadgeImport): List<DebridBadgePreviewSection> {
|
||||
val filters = import.filters.filter { it.imageURL.isNotBlank() }
|
||||
if (filters.isEmpty()) return emptyList()
|
||||
|
||||
val filtersByGroupId = filters.groupBy { it.groupId }
|
||||
val usedGroupIds = mutableSetOf<String>()
|
||||
val sections = mutableListOf<DebridBadgePreviewSection>()
|
||||
import.groups.forEachIndexed { index, group ->
|
||||
val groupFilters = filtersByGroupId[group.id].orEmpty()
|
||||
if (groupFilters.isNotEmpty()) {
|
||||
usedGroupIds += group.id
|
||||
sections += DebridBadgePreviewSection(
|
||||
id = group.id.ifBlank { "group-$index" },
|
||||
title = group.name.ifBlank { "Group ${index + 1}" },
|
||||
filters = groupFilters,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val ungroupedFilters = filters.filter { it.groupId !in usedGroupIds }
|
||||
if (ungroupedFilters.isNotEmpty()) {
|
||||
sections += DebridBadgePreviewSection(
|
||||
id = "other",
|
||||
title = "Other badges",
|
||||
filters = ungroupedFilters,
|
||||
)
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
private fun String.toBadgeColorOrNull(): Color? {
|
||||
val hex = trim().removePrefix("#")
|
||||
val argb = when (hex.length) {
|
||||
6 -> "FF$hex"
|
||||
8 -> hex
|
||||
else -> return null
|
||||
}
|
||||
return argb.toLongOrNull(16)?.let { Color(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -1214,7 +1214,7 @@ private fun StreamItem.instantServiceLabel(): String? {
|
|||
|
||||
@Composable
|
||||
private fun StreamImportedBadge(badge: StreamBadge) {
|
||||
val shape = RoundedCornerShape(4.dp)
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
val backgroundColor = if (badge.tagStyle.equals("filled", ignoreCase = true)) {
|
||||
badge.tagColor.toBadgeColorOrNull()
|
||||
} else {
|
||||
|
|
@ -1257,9 +1257,9 @@ private fun StreamFileSizeBadge(stream: StreamItem) {
|
|||
Box(
|
||||
modifier = Modifier
|
||||
.height(20.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color(0xFF0A0C0C))
|
||||
.border(1.dp, Color(0xFF0A0C0C), RoundedCornerShape(4.dp))
|
||||
.border(1.dp, Color(0xFF0A0C0C), RoundedCornerShape(6.dp))
|
||||
.padding(horizontal = 6.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -52,39 +52,58 @@ class DebridStreamPresentationTest {
|
|||
enabled = true,
|
||||
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"),
|
||||
streamNameTemplate = "{stream.rseMatched::join(' | ')}",
|
||||
streamDescriptionTemplate = "{stream.regexMatched::~REMUX[\"has-remux\"||\"missing\"]}",
|
||||
streamDescriptionTemplate = "{stream.regexMatched::~REMUX[\"has-remux\"||\"missing-remux\"]}",
|
||||
streamBadgeRules = DebridStreamBadgeRules(
|
||||
sourceUrl = "https://example.test/badges.json",
|
||||
filters = listOf(
|
||||
DebridStreamBadgeFilter(
|
||||
name = "REMUX",
|
||||
pattern = "(?i)\\bremux\\b",
|
||||
imageURL = "https://example.test/remux.png",
|
||||
tagColor = "#27C04F",
|
||||
tagStyle = "filled",
|
||||
textColor = "#FFFFFF",
|
||||
borderColor = "#27C04F",
|
||||
imports = listOf(
|
||||
DebridStreamBadgeImport(
|
||||
sourceUrl = "https://example.test/media-badges.json",
|
||||
isActive = false,
|
||||
filters = listOf(
|
||||
DebridStreamBadgeFilter(
|
||||
name = "REMUX",
|
||||
pattern = "(?i)\\bremux\\b",
|
||||
imageURL = "https://example.test/remux.png",
|
||||
tagColor = "#27C04F",
|
||||
tagStyle = "filled",
|
||||
textColor = "#FFFFFF",
|
||||
borderColor = "#27C04F",
|
||||
),
|
||||
DebridStreamBadgeFilter(
|
||||
name = "Disabled",
|
||||
pattern = "(?i)\\bbluray\\b",
|
||||
isEnabled = false,
|
||||
),
|
||||
),
|
||||
),
|
||||
DebridStreamBadgeFilter(
|
||||
name = "Disabled",
|
||||
pattern = "(?i)\\bbluray\\b",
|
||||
isEnabled = false,
|
||||
DebridStreamBadgeImport(
|
||||
sourceUrl = "https://example.test/audio-badges.json",
|
||||
isActive = true,
|
||||
filters = listOf(
|
||||
DebridStreamBadgeFilter(
|
||||
name = "TRUEHD",
|
||||
pattern = "(?i)\\btruehd\\b",
|
||||
imageURL = "https://example.test/truehd.png",
|
||||
tagColor = "#B968FF",
|
||||
tagStyle = "filled",
|
||||
textColor = "#FFFFFF",
|
||||
borderColor = "#B968FF",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("REMUX", formatted.name)
|
||||
assertEquals("has-remux", formatted.description)
|
||||
assertEquals(1, formatted.badges.size)
|
||||
assertEquals("REMUX", formatted.badges.single().name)
|
||||
assertEquals("https://example.test/remux.png", formatted.badges.single().imageURL)
|
||||
assertEquals("TRUEHD", formatted.name)
|
||||
assertEquals("missing-remux", formatted.description)
|
||||
assertEquals(listOf("TRUEHD"), formatted.badges.map { it.name })
|
||||
assertEquals(listOf("https://example.test/truehd.png"), formatted.badges.map { it.imageURL })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses fusion badge url payload shape`() {
|
||||
val rules = DebridStreamBadgeRulesParser.parse(
|
||||
val importedRules = DebridStreamBadgeRulesParser.parse(
|
||||
sourceUrl = "https://example.test/fusion-tags-ume.json",
|
||||
payload = """
|
||||
{
|
||||
|
|
@ -115,12 +134,12 @@ class DebridStreamPresentationTest {
|
|||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals("https://example.test/fusion-tags-ume.json", rules.sourceUrl)
|
||||
assertEquals(1, rules.filters.size)
|
||||
assertEquals("REMUX", rules.filters.single().name)
|
||||
assertEquals("(?i)\\bremux\\b", rules.filters.single().pattern)
|
||||
assertEquals("https://example.test/remux.png", rules.filters.single().imageURL)
|
||||
assertEquals("Media Source", rules.groups.single().name)
|
||||
assertEquals("https://example.test/fusion-tags-ume.json", importedRules.sourceUrl)
|
||||
assertEquals(1, importedRules.filters.size)
|
||||
assertEquals("REMUX", importedRules.filters.single().name)
|
||||
assertEquals("(?i)\\bremux\\b", importedRules.filters.single().pattern)
|
||||
assertEquals("https://example.test/remux.png", importedRules.filters.single().imageURL)
|
||||
assertEquals("Media Source", importedRules.groups.single().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -142,12 +161,16 @@ class DebridStreamPresentationTest {
|
|||
enabled = true,
|
||||
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"),
|
||||
streamBadgeRules = DebridStreamBadgeRules(
|
||||
sourceUrl = "https://example.test/badges.json",
|
||||
filters = listOf(
|
||||
DebridStreamBadgeFilter(
|
||||
name = "REMUX 1",
|
||||
pattern = "(?i)\\bremux\\b",
|
||||
imageURL = "https://example.test/remux-t1.png",
|
||||
imports = listOf(
|
||||
DebridStreamBadgeImport(
|
||||
sourceUrl = "https://example.test/badges.json",
|
||||
filters = listOf(
|
||||
DebridStreamBadgeFilter(
|
||||
name = "REMUX 1",
|
||||
pattern = "(?i)\\bremux\\b",
|
||||
imageURL = "https://example.test/remux-t1.png",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue