diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt index f352058e..5a00ab79 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt @@ -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) 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 4744659a..fdb2eed5 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 @@ -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(value).normalized() + json.decodeFromString(value).normalized() } catch (_: SerializationException) { null } catch (_: IllegalArgumentException) { @@ -447,7 +447,7 @@ object DebridSettingsRepository { if (decodedRules?.hasImport == true) return decodedRules val legacyRules = try { - json.decodeFromString(value) + json.decodeFromString(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 = emptyList(), - val groups: List = emptyList(), + val filters: List = emptyList(), + val groups: List = emptyList(), ) { - fun toBadgeRules(): DebridStreamBadgeRules = - DebridStreamBadgeRules( + fun toBadgeRules(): StreamBadgeRules = + StreamBadgeRules( imports = listOf( - DebridStreamBadgeImport( + StreamBadgeImport( sourceUrl = sourceUrl, filters = filters, groups = groups, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt index 9b00dc33..9d99cee6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt @@ -13,11 +13,11 @@ class DebridStreamFormatter( fun format( stream: StreamItem, settings: DebridSettings, - compiledBadgeFilters: List = - DebridStreamBadgeMatcher.compile(settings.streamBadgeRules), + compiledBadgeFilters: List = + 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 } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt index 43deeb5d..500d2f7a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt @@ -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 -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/ImportedBadgeChip.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/ImportedBadgeChip.kt new file mode 100644 index 00000000..6a38a758 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/ImportedBadgeChip.kt @@ -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) } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamBadgeRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/StreamBadgeRules.kt similarity index 78% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamBadgeRules.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/StreamBadgeRules.kt index 29c76cfc..eb47d17c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamBadgeRules.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/StreamBadgeRules.kt @@ -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 = emptyList(), +data class StreamBadgeRules( + val imports: List = 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() + fun normalized(): StreamBadgeRules { + val normalizedImports = mutableListOf() 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 = emptyList(), - val groups: List = emptyList(), + val filters: List = emptyList(), + val groups: List = 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(payload) + json.decodeFromString(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 { +internal object StreamBadgeMatcher { + fun compile(rules: StreamBadgeRules): List { 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 = + fun matchedNames(stream: StreamItem, rules: StreamBadgeRules): List = matchedNames(stream, compile(rules)) - fun matchedNames(stream: StreamItem, filters: List): List { + fun matchedNames(stream: StreamItem, filters: List): List { return matchedBadges(stream, filters).map { it.name } } - fun matchedBadges(stream: StreamItem, filters: List): List { + fun matchedBadges(stream: StreamItem, filters: List): List { 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 = emptyList(), - val groups: List = emptyList(), +private data class StreamBadgePayload( + val filters: List = emptyList(), + val groups: List = 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, 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 45753ced..afe490d1 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,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(null) } var isImporting by rememberSaveable { mutableStateOf(false) } - var previewImport by remember { mutableStateOf(null) } + var previewImport by remember { mutableStateOf(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, + val filters: List, ) -private fun badgePreviewSections(import: DebridStreamBadgeImport): List { +private fun badgePreviewSections(import: StreamBadgeImport): 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() + val sections = mutableListOf() 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 "FF$hex" - 8 -> hex - else -> return null - } - return argb.toLongOrNull(16)?.let { Color(it) } -} - @Composable private fun DebridPreferenceRow( isTablet: Boolean, 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 4d98400b..e4e79475 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 @@ -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 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 38fdb1fb..1f769ad9 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 @@ -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",