ref(cloud): refactor badge handling

This commit is contained in:
tapframe 2026-05-29 02:00:20 +05:30
parent 34b8c757ac
commit 32f99fc252
9 changed files with 230 additions and 190 deletions

View file

@ -17,7 +17,7 @@ data class DebridSettings(
val streamPreferences: DebridStreamPreferences = DebridStreamPreferences(),
val streamNameTemplate: String = DebridStreamFormatterDefaults.NAME_TEMPLATE,
val streamDescriptionTemplate: String = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE,
val streamBadgeRules: DebridStreamBadgeRules = DebridStreamBadgeRules(),
val streamBadgeRules: StreamBadgeRules = StreamBadgeRules(),
) {
val torboxApiKey: String
get() = apiKeyFor(DebridProviders.TORBOX_ID)

View file

@ -37,7 +37,7 @@ object DebridSettingsRepository {
private var streamPreferences = DebridStreamPreferences()
private var streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE
private var streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE
private var streamBadgeRules = DebridStreamBadgeRules()
private var streamBadgeRules = StreamBadgeRules()
fun ensureLoaded() {
if (hasLoaded) return
@ -230,16 +230,16 @@ object DebridSettingsRepository {
)
}
suspend fun importStreamBadgeRulesFromUrl(url: String): DebridStreamBadgeImportResult {
suspend fun importStreamBadgeRulesFromUrl(url: String): StreamBadgeImportResult {
ensureLoaded()
val normalizedUrl = url.trim()
if (normalizedUrl.isBlank()) {
return DebridStreamBadgeImportResult.Error("Enter a badge JSON URL.")
return StreamBadgeImportResult.Error("Enter a badge JSON URL.")
}
if (!normalizedUrl.startsWith("https://", ignoreCase = true) &&
!normalizedUrl.startsWith("http://", ignoreCase = true)
) {
return DebridStreamBadgeImportResult.Error("Badge URL must start with http:// or https://.")
return StreamBadgeImportResult.Error("Badge URL must start with http:// or https://.")
}
return try {
@ -247,21 +247,21 @@ object DebridSettingsRepository {
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.")
if (!isExistingImport && currentRules.imports.size >= STREAM_BADGE_IMPORT_LIMIT) {
return StreamBadgeImportResult.Error("You can import up to $STREAM_BADGE_IMPORT_LIMIT badge URLs.")
}
val payload = httpGetText(normalizedUrl)
val parsedImport = DebridStreamBadgeRulesParser.parse(
val parsedImport = StreamBadgeRulesParser.parse(
sourceUrl = normalizedUrl,
payload = payload,
)
streamBadgeRules = currentRules.upsert(parsedImport, activate = true)
publish()
saveStreamBadgeRules()
DebridStreamBadgeImportResult.Success(streamBadgeRules)
StreamBadgeImportResult.Success(streamBadgeRules)
} catch (error: Exception) {
if (error is CancellationException) throw error
DebridStreamBadgeImportResult.Error(error.message ?: "Badge import failed.")
StreamBadgeImportResult.Error(error.message ?: "Badge import failed.")
}
}
@ -383,7 +383,7 @@ object DebridSettingsRepository {
DebridSettingsStorage.loadStreamDescriptionTemplate().orEmpty(),
DebridTemplateKind.DESCRIPTION,
)
streamBadgeRules = parseStreamBadgeRules(DebridSettingsStorage.loadStreamBadgeRules()) ?: DebridStreamBadgeRules()
streamBadgeRules = parseStreamBadgeRules(DebridSettingsStorage.loadStreamBadgeRules()) ?: StreamBadgeRules()
publish()
}
@ -435,10 +435,10 @@ object DebridSettingsRepository {
}
}
private fun parseStreamBadgeRules(value: String?): DebridStreamBadgeRules? {
private fun parseStreamBadgeRules(value: String?): StreamBadgeRules? {
if (value.isNullOrBlank()) return null
val decodedRules = try {
json.decodeFromString<DebridStreamBadgeRules>(value).normalized()
json.decodeFromString<StreamBadgeRules>(value).normalized()
} catch (_: SerializationException) {
null
} catch (_: IllegalArgumentException) {
@ -447,7 +447,7 @@ object DebridSettingsRepository {
if (decodedRules?.hasImport == true) return decodedRules
val legacyRules = try {
json.decodeFromString<LegacyDebridStreamBadgeRules>(value)
json.decodeFromString<LegacyStreamBadgeRules>(value)
.toBadgeRules()
.normalized()
} catch (_: SerializationException) {
@ -475,15 +475,15 @@ object DebridSettingsRepository {
}
@Serializable
private data class LegacyDebridStreamBadgeRules(
private data class LegacyStreamBadgeRules(
val sourceUrl: String = "",
val filters: List<DebridStreamBadgeFilter> = emptyList(),
val groups: List<DebridStreamBadgeGroup> = emptyList(),
val filters: List<StreamBadgeFilter> = emptyList(),
val groups: List<StreamBadgeGroup> = emptyList(),
) {
fun toBadgeRules(): DebridStreamBadgeRules =
DebridStreamBadgeRules(
fun toBadgeRules(): StreamBadgeRules =
StreamBadgeRules(
imports = listOf(
DebridStreamBadgeImport(
StreamBadgeImport(
sourceUrl = sourceUrl,
filters = filters,
groups = groups,

View file

@ -13,11 +13,11 @@ class DebridStreamFormatter(
fun format(
stream: StreamItem,
settings: DebridSettings,
compiledBadgeFilters: List<DebridCompiledStreamBadgeFilter> =
DebridStreamBadgeMatcher.compile(settings.streamBadgeRules),
compiledBadgeFilters: List<CompiledStreamBadgeFilter> =
StreamBadgeMatcher.compile(settings.streamBadgeRules),
): StreamItem {
if (!stream.isManagedDebridStream) return stream
val matchedBadges = DebridStreamBadgeMatcher.matchedBadges(stream, compiledBadgeFilters)
val matchedBadges = StreamBadgeMatcher.matchedBadges(stream, compiledBadgeFilters)
val values = buildValues(stream, settings, matchedBadges)
val nameTemplate = settings.streamNameTemplate.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE }
val descriptionTemplate = settings.streamDescriptionTemplate.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE }

View file

@ -16,7 +16,7 @@ object DebridStreamPresentation {
val debridStreams = visibleStreams.filter { stream -> stream.isManagedDebridStream }
if (debridStreams.isEmpty()) return@map group.copy(streams = visibleStreams)
val compiledBadgeFilters = DebridStreamBadgeMatcher.compile(settings.streamBadgeRules)
val compiledBadgeFilters = StreamBadgeMatcher.compile(settings.streamBadgeRules)
val shouldFormatStreams = settings.hasCustomStreamFormatting || compiledBadgeFilters.isNotEmpty()
val presentedDebridStreams = applyPreferences(debridStreams, settings)
.map { stream ->

View file

@ -0,0 +1,102 @@
package com.nuvio.app.features.debrid
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
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.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
internal object BadgeChipDefaults {
val shape = RoundedCornerShape(6.dp)
val fileSizeHorizontalPadding = 6.dp
val fileSizeFontSize: TextUnit = 10.sp
val fileSizeLineHeight: TextUnit = 12.sp
val fileSizeLetterSpacing: TextUnit = 0.sp
}
internal enum class ImportedBadgeChipSize(
val containerHeight: Dp,
val imageHeight: Dp,
val minImageWidth: Dp,
val maxImageWidth: Dp,
val horizontalPadding: Dp,
val verticalPadding: Dp,
) {
STREAM(
containerHeight = 20.dp,
imageHeight = 16.dp,
minImageWidth = 34.dp,
maxImageWidth = 92.dp,
horizontalPadding = 3.dp,
verticalPadding = 2.dp,
),
PREVIEW(
containerHeight = 24.dp,
imageHeight = 18.dp,
minImageWidth = 38.dp,
maxImageWidth = 112.dp,
horizontalPadding = 4.dp,
verticalPadding = 3.dp,
),
}
@Composable
internal fun ImportedBadgeChip(
imageURL: String,
name: String,
tagColor: String,
tagStyle: String,
borderColor: String,
size: ImportedBadgeChipSize,
modifier: Modifier = Modifier,
) {
val backgroundColor = if (tagStyle.equals("filled", ignoreCase = true)) {
tagColor.toBadgeColorOrNull()
} else {
null
}
val outlineColor = borderColor.toBadgeColorOrNull()
val shape = BadgeChipDefaults.shape
Box(
modifier = modifier
.height(size.containerHeight)
.then(if (backgroundColor != null) Modifier.background(backgroundColor, shape) else Modifier)
.then(if (outlineColor != null) Modifier.border(1.dp, outlineColor, shape) else Modifier)
.padding(horizontal = size.horizontalPadding, vertical = size.verticalPadding),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = imageURL,
contentDescription = name,
modifier = Modifier
.height(size.imageHeight)
.widthIn(min = size.minImageWidth, max = size.maxImageWidth)
.clip(shape),
contentScale = ContentScale.Fit,
)
}
}
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) }
}

View file

@ -8,23 +8,23 @@ import kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
const val DEBRID_STREAM_BADGE_IMPORT_LIMIT = 3
const val STREAM_BADGE_IMPORT_LIMIT = 3
@Serializable
data class DebridStreamBadgeRules(
val imports: List<DebridStreamBadgeImport> = emptyList(),
data class StreamBadgeRules(
val imports: List<StreamBadgeImport> = emptyList(),
) {
val hasImport: Boolean
get() = imports.isNotEmpty()
val activeImport: DebridStreamBadgeImport?
val activeImport: StreamBadgeImport?
get() = imports.firstOrNull { it.isActive } ?: imports.firstOrNull()
val enabledFilterCount: Int
get() = activeImport?.enabledFilterCount ?: 0
fun normalized(): DebridStreamBadgeRules {
val normalizedImports = mutableListOf<DebridStreamBadgeImport>()
fun normalized(): StreamBadgeRules {
val normalizedImports = mutableListOf<StreamBadgeImport>()
imports.forEach { import ->
val normalizedUrl = import.sourceUrl.trim()
if (normalizedUrl.isBlank() || import.filters.isEmpty()) return@forEach
@ -32,7 +32,7 @@ data class DebridStreamBadgeRules(
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) {
} else if (normalizedImports.size < STREAM_BADGE_IMPORT_LIMIT) {
normalizedImports += normalizedImport
}
}
@ -45,7 +45,7 @@ data class DebridStreamBadgeRules(
)
}
fun upsert(import: DebridStreamBadgeImport, activate: Boolean = true): DebridStreamBadgeRules {
fun upsert(import: StreamBadgeImport, activate: Boolean = true): StreamBadgeRules {
val normalizedUrl = import.sourceUrl.trim()
if (normalizedUrl.isBlank()) return normalized()
val normalizedImport = import.copy(sourceUrl = normalizedUrl, isActive = activate)
@ -67,7 +67,7 @@ data class DebridStreamBadgeRules(
return copy(imports = activeImports).normalized()
}
fun setActiveSource(sourceUrl: String): DebridStreamBadgeRules {
fun setActiveSource(sourceUrl: String): StreamBadgeRules {
val normalizedUrl = sourceUrl.trim()
if (normalizedUrl.isBlank() || imports.none { it.sourceUrl.equals(normalizedUrl, ignoreCase = true) }) {
return normalized()
@ -79,29 +79,29 @@ data class DebridStreamBadgeRules(
).normalized()
}
fun removeSource(sourceUrl: String): DebridStreamBadgeRules =
fun removeSource(sourceUrl: String): StreamBadgeRules =
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 }})"
"StreamBadgeRules(imports=${imports.size}, activeFilters=$enabledFilterCount, groups=${imports.sumOf { it.groups.size }})"
}
@Serializable
data class DebridStreamBadgeImport(
data class StreamBadgeImport(
val sourceUrl: String = "",
val filters: List<DebridStreamBadgeFilter> = emptyList(),
val groups: List<DebridStreamBadgeGroup> = emptyList(),
val filters: List<StreamBadgeFilter> = emptyList(),
val groups: List<StreamBadgeGroup> = emptyList(),
val isActive: Boolean = true,
) {
val enabledFilterCount: Int
get() = filters.count { it.isEnabled }
override fun toString(): String =
"DebridStreamBadgeImport(sourceUrl=$sourceUrl, filters=${filters.size}, groups=${groups.size}, isActive=$isActive)"
"StreamBadgeImport(sourceUrl=$sourceUrl, filters=${filters.size}, groups=${groups.size}, isActive=$isActive)"
}
@Serializable
data class DebridStreamBadgeFilter(
data class StreamBadgeFilter(
val id: String = "",
val groupId: String = "",
val name: String = "",
@ -115,28 +115,28 @@ data class DebridStreamBadgeFilter(
)
@Serializable
data class DebridStreamBadgeGroup(
data class StreamBadgeGroup(
val id: String = "",
val name: String = "",
val color: String = "",
val isExpanded: Boolean = true,
)
sealed interface DebridStreamBadgeImportResult {
data class Success(val rules: DebridStreamBadgeRules) : DebridStreamBadgeImportResult
data class Error(val message: String) : DebridStreamBadgeImportResult
sealed interface StreamBadgeImportResult {
data class Success(val rules: StreamBadgeRules) : StreamBadgeImportResult
data class Error(val message: String) : StreamBadgeImportResult
}
internal object DebridStreamBadgeRulesParser {
internal object StreamBadgeRulesParser {
@OptIn(ExperimentalSerializationApi::class)
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
fun parse(sourceUrl: String, payload: String): DebridStreamBadgeImport {
fun parse(sourceUrl: String, payload: String): StreamBadgeImport {
val decoded = try {
json.decodeFromString<DebridStreamBadgePayload>(payload)
json.decodeFromString<StreamBadgePayload>(payload)
} catch (error: SerializationException) {
throw IllegalArgumentException("Invalid badge JSON: ${error.message.orEmpty()}")
} catch (error: IllegalArgumentException) {
@ -150,7 +150,7 @@ internal object DebridStreamBadgeRulesParser {
return@mapNotNull null
}
DebridStreamBadgeFilter(
StreamBadgeFilter(
id = filter.id.orEmpty(),
groupId = filter.groupId.orEmpty(),
name = name,
@ -169,7 +169,7 @@ internal object DebridStreamBadgeRulesParser {
}
val groups = decoded.groups.map { group ->
DebridStreamBadgeGroup(
StreamBadgeGroup(
id = group.id.orEmpty(),
name = group.name.orEmpty(),
color = group.color.orEmpty(),
@ -177,7 +177,7 @@ internal object DebridStreamBadgeRulesParser {
)
}
return DebridStreamBadgeImport(
return StreamBadgeImport(
sourceUrl = sourceUrl.trim(),
filters = filters,
groups = groups,
@ -185,8 +185,8 @@ internal object DebridStreamBadgeRulesParser {
}
}
internal object DebridStreamBadgeMatcher {
fun compile(rules: DebridStreamBadgeRules): List<DebridCompiledStreamBadgeFilter> {
internal object StreamBadgeMatcher {
fun compile(rules: StreamBadgeRules): List<CompiledStreamBadgeFilter> {
if (!rules.hasImport) return emptyList()
return rules.normalized().imports.filter { it.isActive }.flatMap { import ->
import.filters.mapNotNull { filter ->
@ -194,7 +194,7 @@ internal object DebridStreamBadgeMatcher {
return@mapNotNull null
}
val regex = runCatching { Regex(filter.pattern) }.getOrNull() ?: return@mapNotNull null
DebridCompiledStreamBadgeFilter(
CompiledStreamBadgeFilter(
name = filter.name,
badge = StreamBadge(
name = filter.name,
@ -210,14 +210,14 @@ internal object DebridStreamBadgeMatcher {
}
}
fun matchedNames(stream: StreamItem, rules: DebridStreamBadgeRules): List<String> =
fun matchedNames(stream: StreamItem, rules: StreamBadgeRules): List<String> =
matchedNames(stream, compile(rules))
fun matchedNames(stream: StreamItem, filters: List<DebridCompiledStreamBadgeFilter>): List<String> {
fun matchedNames(stream: StreamItem, filters: List<CompiledStreamBadgeFilter>): List<String> {
return matchedBadges(stream, filters).map { it.name }
}
fun matchedBadges(stream: StreamItem, filters: List<DebridCompiledStreamBadgeFilter>): List<StreamBadge> {
fun matchedBadges(stream: StreamItem, filters: List<CompiledStreamBadgeFilter>): List<StreamBadge> {
if (filters.isEmpty()) return emptyList()
val candidates = badgeMatchCandidates(stream)
if (candidates.isEmpty()) return emptyList()
@ -271,7 +271,7 @@ internal object DebridStreamBadgeMatcher {
}
}
data class DebridCompiledStreamBadgeFilter(
data class CompiledStreamBadgeFilter(
val name: String,
val badge: StreamBadge,
val regex: Regex,
@ -281,13 +281,13 @@ private fun StreamBadge.dedupeKey(): String =
imageURL.takeIf { it.isNotBlank() } ?: name
@Serializable
private data class DebridStreamBadgePayload(
val filters: List<DebridStreamBadgeFilterPayload> = emptyList(),
val groups: List<DebridStreamBadgeGroupPayload> = emptyList(),
private data class StreamBadgePayload(
val filters: List<StreamBadgeFilterPayload> = emptyList(),
val groups: List<StreamBadgeGroupPayload> = emptyList(),
)
@Serializable
private data class DebridStreamBadgeFilterPayload(
private data class StreamBadgeFilterPayload(
val id: String? = null,
val groupId: String? = null,
val name: String? = null,
@ -302,7 +302,7 @@ private data class DebridStreamBadgeFilterPayload(
)
@Serializable
private data class DebridStreamBadgeGroupPayload(
private data class StreamBadgeGroupPayload(
val id: String? = null,
val name: String? = null,
val color: String? = null,

View file

@ -1,7 +1,5 @@
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
@ -17,7 +15,6 @@ 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
@ -50,18 +47,16 @@ 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.STREAM_BADGE_IMPORT_LIMIT
import com.nuvio.app.features.debrid.ImportedBadgeChip
import com.nuvio.app.features.debrid.ImportedBadgeChipSize
import com.nuvio.app.features.debrid.DebridCredentialValidator
import com.nuvio.app.features.debrid.DebridDeviceAuthorization
import com.nuvio.app.features.debrid.DebridDeviceAuthorizationTokenResult
@ -71,10 +66,10 @@ import com.nuvio.app.features.debrid.DebridProviderAuthMethod
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.StreamBadgeImportResult
import com.nuvio.app.features.debrid.StreamBadgeFilter
import com.nuvio.app.features.debrid.StreamBadgeImport
import com.nuvio.app.features.debrid.StreamBadgeRules
import com.nuvio.app.features.debrid.DebridStreamFormatterDefaults
import com.nuvio.app.features.debrid.DebridStreamAudioChannel
import com.nuvio.app.features.debrid.DebridStreamAudioTag
@ -486,7 +481,7 @@ internal fun LazyListScope.debridSettingsContent(
}
if (showBadgeImportDialog) {
DebridBadgeUrlManagerDialog(
BadgeUrlManagerDialog(
currentRules = settings.streamBadgeRules,
onDismiss = { showBadgeImportDialog = false },
)
@ -538,10 +533,10 @@ private fun templatePreview(value: String, defaultValue: String): String {
return if (firstLine.length <= 28) firstLine else "${firstLine.take(28)}..."
}
private fun badgeRulesPreview(rules: DebridStreamBadgeRules): String {
private fun badgeRulesPreview(rules: StreamBadgeRules): String {
val normalizedRules = rules.normalized()
return if (normalizedRules.hasImport) {
"${normalizedRules.imports.size}/$DEBRID_STREAM_BADGE_IMPORT_LIMIT URLs, ${normalizedRules.enabledFilterCount} active badges"
"${normalizedRules.imports.size}/$STREAM_BADGE_IMPORT_LIMIT URLs, ${normalizedRules.enabledFilterCount} active badges"
} else {
"Not imported"
}
@ -712,8 +707,8 @@ private fun DebridTemplateDialog(
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun DebridBadgeUrlManagerDialog(
currentRules: DebridStreamBadgeRules,
private fun BadgeUrlManagerDialog(
currentRules: StreamBadgeRules,
onDismiss: () -> Unit,
) {
val scope = rememberCoroutineScope()
@ -721,12 +716,12 @@ private fun DebridBadgeUrlManagerDialog(
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) }
var previewImport by remember { mutableStateOf<StreamBadgeImport?>(null) }
BasicAlertDialog(onDismissRequest = onDismiss) {
DebridDialogSurface(title = "Badge URLs") {
Text(
text = "Import up to $DEBRID_STREAM_BADGE_IMPORT_LIMIT label badge JSON URLs. Each URL can be updated or deleted separately.",
text = "Import up to $STREAM_BADGE_IMPORT_LIMIT label badge JSON URLs. Each URL can be updated or deleted separately.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -756,7 +751,7 @@ private fun DebridBadgeUrlManagerDialog(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${imports.size}/$DEBRID_STREAM_BADGE_IMPORT_LIMIT URLs imported",
text = "${imports.size}/$STREAM_BADGE_IMPORT_LIMIT URLs imported",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
@ -768,11 +763,11 @@ private fun DebridBadgeUrlManagerDialog(
isImporting = true
errorMessage = null
when (val result = DebridSettingsRepository.importStreamBadgeRulesFromUrl(draftUrl)) {
is DebridStreamBadgeImportResult.Success -> {
is StreamBadgeImportResult.Success -> {
draftUrl = ""
isImporting = false
}
is DebridStreamBadgeImportResult.Error -> {
is StreamBadgeImportResult.Error -> {
errorMessage = result.message
isImporting = false
}
@ -816,7 +811,7 @@ private fun DebridBadgeUrlManagerDialog(
items = imports,
key = { import -> import.sourceUrl },
) { import ->
DebridBadgeUrlRow(
BadgeUrlRow(
import = import,
showActiveChoice = imports.size > 1,
enabled = !isImporting,
@ -850,7 +845,7 @@ private fun DebridBadgeUrlManagerDialog(
}
previewImport?.let { import ->
DebridBadgePreviewDialog(
BadgePreviewDialog(
import = import,
onDismiss = { previewImport = null },
)
@ -858,8 +853,8 @@ private fun DebridBadgeUrlManagerDialog(
}
@Composable
private fun DebridBadgeUrlRow(
import: DebridStreamBadgeImport,
private fun BadgeUrlRow(
import: StreamBadgeImport,
showActiveChoice: Boolean,
enabled: Boolean,
onActivate: () -> Unit,
@ -943,8 +938,8 @@ private fun DebridBadgeUrlRow(
@Composable
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
private fun DebridBadgePreviewDialog(
import: DebridStreamBadgeImport,
private fun BadgePreviewDialog(
import: StreamBadgeImport,
onDismiss: () -> Unit,
) {
val sections = remember(import) { badgePreviewSections(import) }
@ -997,7 +992,14 @@ private fun DebridBadgePreviewDialog(
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
section.filters.forEach { filter ->
DebridBadgePreviewChip(filter)
ImportedBadgeChip(
imageURL = filter.imageURL,
name = filter.name,
tagColor = filter.tagColor,
tagStyle = filter.tagStyle,
borderColor = filter.borderColor,
size = ImportedBadgeChipSize.PREVIEW,
)
}
}
}
@ -1016,54 +1018,24 @@ private fun DebridBadgePreviewDialog(
}
}
@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(
private data class BadgePreviewSection(
val id: String,
val title: String,
val filters: List<DebridStreamBadgeFilter>,
val filters: List<StreamBadgeFilter>,
)
private fun badgePreviewSections(import: DebridStreamBadgeImport): List<DebridBadgePreviewSection> {
private fun badgePreviewSections(import: StreamBadgeImport): List<BadgePreviewSection> {
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>()
val sections = mutableListOf<BadgePreviewSection>()
import.groups.forEachIndexed { index, group ->
val groupFilters = filtersByGroupId[group.id].orEmpty()
if (groupFilters.isNotEmpty()) {
usedGroupIds += group.id
sections += DebridBadgePreviewSection(
sections += BadgePreviewSection(
id = group.id.ifBlank { "group-$index" },
title = group.name.ifBlank { "Group ${index + 1}" },
filters = groupFilters,
@ -1073,7 +1045,7 @@ private fun badgePreviewSections(import: DebridStreamBadgeImport): List<DebridBa
val ungroupedFilters = filters.filter { it.groupId !in usedGroupIds }
if (ungroupedFilters.isNotEmpty()) {
sections += DebridBadgePreviewSection(
sections += BadgePreviewSection(
id = "other",
title = "Other badges",
filters = ungroupedFilters,
@ -1082,16 +1054,6 @@ private fun badgePreviewSections(import: DebridStreamBadgeImport): List<DebridBa
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
private fun DebridPreferenceRow(
isTablet: Boolean,

View file

@ -90,6 +90,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.features.debrid.BadgeChipDefaults
import com.nuvio.app.features.debrid.ImportedBadgeChip
import com.nuvio.app.features.debrid.ImportedBadgeChipSize
import com.nuvio.app.features.debrid.DebridProviders
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.player.PlayerSettingsRepository
@ -1214,32 +1217,14 @@ private fun StreamItem.instantServiceLabel(): String? {
@Composable
private fun StreamImportedBadge(badge: StreamBadge) {
val shape = RoundedCornerShape(6.dp)
val backgroundColor = if (badge.tagStyle.equals("filled", ignoreCase = true)) {
badge.tagColor.toBadgeColorOrNull()
} else {
null
}
val borderColor = badge.borderColor.toBadgeColorOrNull()
Box(
modifier = Modifier
.height(20.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 = 3.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = badge.imageURL,
contentDescription = badge.name,
modifier = Modifier
.height(16.dp)
.widthIn(min = 34.dp, max = 92.dp)
.clip(shape),
contentScale = ContentScale.Fit,
)
}
ImportedBadgeChip(
imageURL = badge.imageURL,
name = badge.name,
tagColor = badge.tagColor,
tagStyle = badge.tagStyle,
borderColor = badge.borderColor,
size = ImportedBadgeChipSize.STREAM,
)
}
@Composable
@ -1254,38 +1239,29 @@ private fun StreamFileSizeBadge(stream: StreamItem) {
"${round(mib).toInt()} ${localizedByteUnit("MB")}"
}
val badgeShape = BadgeChipDefaults.shape
Box(
modifier = Modifier
.height(20.dp)
.clip(RoundedCornerShape(6.dp))
.height(ImportedBadgeChipSize.STREAM.containerHeight)
.clip(badgeShape)
.background(Color(0xFF0A0C0C))
.border(1.dp, Color(0xFF0A0C0C), RoundedCornerShape(6.dp))
.padding(horizontal = 6.dp),
.border(1.dp, Color(0xFF0A0C0C), badgeShape)
.padding(horizontal = BadgeChipDefaults.fileSizeHorizontalPadding),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(Res.string.streams_size, sizeLabel),
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 10.sp,
lineHeight = 12.sp,
fontSize = BadgeChipDefaults.fileSizeFontSize,
lineHeight = BadgeChipDefaults.fileSizeLineHeight,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.sp,
letterSpacing = BadgeChipDefaults.fileSizeLetterSpacing,
),
color = Color.White,
)
}
}
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) }
}
private fun Long.toPlaybackClock(): String {
val totalSeconds = (this / 1000L).coerceAtLeast(0L)
val hours = totalSeconds / 3600L

View file

@ -53,13 +53,13 @@ class DebridStreamPresentationTest {
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"),
streamNameTemplate = "{stream.rseMatched::join(' | ')}",
streamDescriptionTemplate = "{stream.regexMatched::~REMUX[\"has-remux\"||\"missing-remux\"]}",
streamBadgeRules = DebridStreamBadgeRules(
streamBadgeRules = StreamBadgeRules(
imports = listOf(
DebridStreamBadgeImport(
StreamBadgeImport(
sourceUrl = "https://example.test/media-badges.json",
isActive = false,
filters = listOf(
DebridStreamBadgeFilter(
StreamBadgeFilter(
name = "REMUX",
pattern = "(?i)\\bremux\\b",
imageURL = "https://example.test/remux.png",
@ -68,18 +68,18 @@ class DebridStreamPresentationTest {
textColor = "#FFFFFF",
borderColor = "#27C04F",
),
DebridStreamBadgeFilter(
StreamBadgeFilter(
name = "Disabled",
pattern = "(?i)\\bbluray\\b",
isEnabled = false,
),
),
),
DebridStreamBadgeImport(
StreamBadgeImport(
sourceUrl = "https://example.test/audio-badges.json",
isActive = true,
filters = listOf(
DebridStreamBadgeFilter(
StreamBadgeFilter(
name = "TRUEHD",
pattern = "(?i)\\btruehd\\b",
imageURL = "https://example.test/truehd.png",
@ -103,7 +103,7 @@ class DebridStreamPresentationTest {
@Test
fun `parses fusion badge url payload shape`() {
val importedRules = DebridStreamBadgeRulesParser.parse(
val importedRules = StreamBadgeRulesParser.parse(
sourceUrl = "https://example.test/fusion-tags-ume.json",
payload = """
{
@ -160,12 +160,12 @@ class DebridStreamPresentationTest {
settings = DebridSettings(
enabled = true,
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"),
streamBadgeRules = DebridStreamBadgeRules(
streamBadgeRules = StreamBadgeRules(
imports = listOf(
DebridStreamBadgeImport(
StreamBadgeImport(
sourceUrl = "https://example.test/badges.json",
filters = listOf(
DebridStreamBadgeFilter(
StreamBadgeFilter(
name = "REMUX 1",
pattern = "(?i)\\bremux\\b",
imageURL = "https://example.test/remux-t1.png",