From 1d68504a05e39dfc7ff1d3f0b3fb65edcd3bc9fa Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 29 May 2026 01:51:12 +0530 Subject: [PATCH] feat: add upto 3 badges --- .../debrid/DebridSettingsRepository.kt | 76 +++- .../features/debrid/DebridStreamBadgeRules.kt | 123 +++++- .../features/settings/DebridSettingsPage.kt | 385 ++++++++++++++++-- .../app/features/streams/StreamsScreen.kt | 6 +- .../debrid/DebridStreamPresentationTest.kt | 89 ++-- 5 files changed, 569 insertions(+), 110 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt index 15c4b49c6..4744659ae 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt @@ -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 > 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(value) + val decodedRules = try { + json.decodeFromString(value).normalized() } catch (_: SerializationException) { null } catch (_: IllegalArgumentException) { null } + if (decodedRules?.hasImport == true) return decodedRules + + val legacyRules = try { + json.decodeFromString(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 = emptyList(), + val groups: List = emptyList(), +) { + fun toBadgeRules(): DebridStreamBadgeRules = + DebridStreamBadgeRules( + imports = listOf( + DebridStreamBadgeImport( + sourceUrl = sourceUrl, + filters = filters, + groups = groups, + ), + ), + ) +} + internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences = copy( maxResults = normalizeDebridStreamMaxResults(maxResults), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamBadgeRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamBadgeRules.kt index f243126fd..29c76cfcd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamBadgeRules.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamBadgeRules.kt @@ -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 = 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() + 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 = emptyList(), val groups: List = 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(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 { 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, + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt index 549d967e9..45753cedb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -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(null) } var isImporting by rememberSaveable { mutableStateOf(false) } + var previewImport by remember { mutableStateOf(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, +) + +private fun badgePreviewSections(import: DebridStreamBadgeImport): List { + val filters = import.filters.filter { it.imageURL.isNotBlank() } + if (filters.isEmpty()) return emptyList() + + val filtersByGroupId = filters.groupBy { it.groupId } + val usedGroupIds = mutableSetOf() + val sections = mutableListOf() + 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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index 9004d9525..4d98400b6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -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, ) { diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt index c5d17bf19..38fdb1fba 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt @@ -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", + ), + ), ), ), ),