From df15c4d2f20eb624e7fc16ae16d4bfeaad5627df Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 31 May 2026 15:41:40 +0530 Subject: [PATCH] feat: support streambadges globally --- composeApp/proguard-rules.pro | 8 +- .../kotlin/com/nuvio/app/MainActivity.kt | 2 + ...PlatformLocalAccountDataCleaner.android.kt | 1 + .../debrid/DebridSettingsStorage.android.kt | 7 - .../StreamBadgeSettingsStorage.android.kt | 65 +++ .../composeResources/values/strings.xml | 6 + .../core/storage/LocalAccountDataCleaner.kt | 2 + .../app/core/sync/ProfileSettingsSync.kt | 12 +- .../app/features/debrid/DebridSettings.kt | 1 - .../debrid/DebridSettingsRepository.kt | 112 ---- .../features/debrid/DebridSettingsStorage.kt | 2 - .../features/debrid/DebridStreamFormatter.kt | 6 +- .../debrid/DebridStreamPresentation.kt | 6 +- .../player/PlayerStreamsRepository.kt | 26 +- .../features/profiles/ProfileRepository.kt | 2 + .../features/settings/DebridSettingsPage.kt | 385 -------------- .../app/features/settings/SettingsModels.kt | 6 + .../app/features/settings/SettingsRootPage.kt | 12 + .../app/features/settings/SettingsScreen.kt | 8 + .../app/features/settings/SettingsSearch.kt | 17 + .../features/settings/StreamsSettingsPage.kt | 481 ++++++++++++++++++ .../streams/AddonStreamWarmupRepository.kt | 13 +- .../StreamBadgeChip.kt} | 12 +- .../streams/StreamBadgePresentation.kt | 29 ++ .../{debrid => streams}/StreamBadgeRules.kt | 10 +- .../streams/StreamBadgeSettingsRepository.kt | 173 +++++++ .../streams/StreamBadgeSettingsStorage.kt | 12 + .../app/features/streams/StreamsRepository.kt | 24 +- .../app/features/streams/StreamsScreen.kt | 23 +- .../debrid/DebridStreamPresentationTest.kt | 133 +---- .../streams/StreamBadgePresentationTest.kt | 179 +++++++ .../PlatformLocalAccountDataCleaner.ios.kt | 2 + .../debrid/DebridSettingsStorage.ios.kt | 7 - .../streams/StreamBadgeSettingsStorage.ios.kt | 47 ++ 34 files changed, 1142 insertions(+), 689 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt rename composeApp/src/commonMain/kotlin/com/nuvio/app/features/{debrid/ImportedBadgeChip.kt => streams/StreamBadgeChip.kt} (92%) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgePresentation.kt rename composeApp/src/commonMain/kotlin/com/nuvio/app/features/{debrid => streams}/StreamBadgeRules.kt (97%) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamBadgePresentationTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt diff --git a/composeApp/proguard-rules.pro b/composeApp/proguard-rules.pro index 8d8bdeee..bbe05c0c 100644 --- a/composeApp/proguard-rules.pro +++ b/composeApp/proguard-rules.pro @@ -21,10 +21,10 @@ kotlinx.serialization.KSerializer serializer(...); } -# Avoid R8 merging/optimizing the imported badge chip used in lazy stream rows. --keep class com.nuvio.app.features.debrid.ImportedBadgeChipKt { *; } --keep class com.nuvio.app.features.debrid.ImportedBadgeChipSize { *; } --keep class com.nuvio.app.features.debrid.BadgeChipDefaults { *; } +# Avoid R8 merging/optimizing the stream badge chip used in lazy stream rows. +-keep class com.nuvio.app.features.streams.StreamBadgeChipKt { *; } +-keep class com.nuvio.app.features.streams.StreamBadgeChipSize { *; } +-keep class com.nuvio.app.features.streams.StreamBadgeChipDefaults { *; } -keep class com.nuvio.app.features.streams.StreamsScreenKt { *; } -keep class com.nuvio.app.features.streams.StreamsScreenKt$* { *; } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index d3506a49..4f013654 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -47,6 +47,7 @@ import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform import com.nuvio.app.core.ui.PosterCardStyleStorage import com.nuvio.app.features.watched.WatchedStorage import com.nuvio.app.features.streams.StreamLinkCacheStorage +import com.nuvio.app.features.streams.StreamBadgeSettingsStorage import com.nuvio.app.features.streams.BingeGroupCacheStorage import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentStorage import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage @@ -94,6 +95,7 @@ class MainActivity : AppCompatActivity() { EpisodeReleaseNotificationsStorage.initialize(applicationContext) WatchProgressStorage.initialize(applicationContext) StreamLinkCacheStorage.initialize(applicationContext) + StreamBadgeSettingsStorage.initialize(applicationContext) BingeGroupCacheStorage.initialize(applicationContext) PluginStorage.initialize(applicationContext) CollectionMobileSettingsStorage.initialize(applicationContext) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index aeeafd4b..27b3d1f5 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -20,6 +20,7 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_trakt_settings", "nuvio_watched", "nuvio_stream_link_cache", + "nuvio_stream_badge_settings", "nuvio_continue_watching_preferences", "nuvio_episode_release_notifications", "nuvio_episode_release_notifications_platform", diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt index 969bc8eb..de8e76b1 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt @@ -30,7 +30,6 @@ actual object DebridSettingsStorage { private const val streamPreferencesKey = "debrid_stream_preferences" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" - private const val streamBadgeRulesKey = "debrid_stream_badge_rules" private fun syncKeys(): List = listOf( enabledKey, @@ -151,12 +150,6 @@ actual object DebridSettingsStorage { saveString(streamDescriptionTemplateKey, template) } - actual fun loadStreamBadgeRules(): String? = loadString(streamBadgeRulesKey) - - actual fun saveStreamBadgeRules(rules: String) { - saveString(streamBadgeRulesKey, rules) - } - private fun loadBoolean(key: String): Boolean? = preferences?.let { sharedPreferences -> val scopedKey = ProfileScopedKey.of(key) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt new file mode 100644 index 00000000..244df821 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt @@ -0,0 +1,65 @@ +package com.nuvio.app.features.streams + +import android.content.Context +import android.content.SharedPreferences +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncString +import com.nuvio.app.core.sync.encodeSyncString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +actual object StreamBadgeSettingsStorage { + private const val preferencesName = "nuvio_stream_badge_settings" + private const val legacyDebridPreferencesName = "nuvio_debrid_settings" + private const val streamBadgeRulesKey = "stream_badge_rules" + private const val legacyDebridStreamBadgeRulesKey = "debrid_stream_badge_rules" + + private val syncKeys = listOf(streamBadgeRulesKey) + + private var preferences: SharedPreferences? = null + private var legacyDebridPreferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + legacyDebridPreferences = context.getSharedPreferences(legacyDebridPreferencesName, Context.MODE_PRIVATE) + } + + actual fun loadStreamBadgeRules(): String? = loadString(streamBadgeRulesKey) + + actual fun saveStreamBadgeRules(rules: String) { + saveString(streamBadgeRulesKey, rules) + } + + actual fun loadLegacyDebridStreamBadgeRules(): String? = + legacyDebridPreferences?.getString(ProfileScopedKey.of(legacyDebridStreamBadgeRulesKey), null) + + actual fun clearLegacyDebridStreamBadgeRules() { + legacyDebridPreferences + ?.edit() + ?.remove(ProfileScopedKey.of(legacyDebridStreamBadgeRulesKey)) + ?.apply() + } + + private fun loadString(key: String): String? = + preferences?.getString(ProfileScopedKey.of(key), null) + + private fun saveString(key: String, value: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(key), value) + ?.apply() + } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadStreamBadgeRules()?.let { put(streamBadgeRulesKey, encodeSyncString(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + preferences?.edit()?.apply { + syncKeys.forEach { remove(ProfileScopedKey.of(it)) } + }?.apply() + + payload.decodeSyncString(streamBadgeRulesKey)?.let(::saveStreamBadgeRules) + } +} diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index b426f2de..d290c02a 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -418,6 +418,7 @@ Plugins Poster Card Style Settings + Streams Supporters & Contributors TMDB Enrichment Trakt @@ -433,6 +434,7 @@ GENERAL Manage available integrations Manage episode release alerts and send a test notification. + Stream result display and badge URL rules. Change to a different profile. Switch Profile Open Trakt connection screen @@ -666,6 +668,10 @@ Controls the metadata shown under each result. Leave blank to use the original result details. Reset formatting Restore default result formatting. + Stream Badges + Badge URLs + Import up to %1$d stream badge JSON URLs. Each URL can be updated or deleted separately. + Manage imported stream badge JSON URLs. API key validated. Could not validate this API key. Add your MDBList API key below before turning ratings on. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt index fee939cb..a027d803 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt @@ -20,6 +20,7 @@ import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.search.SearchRepository import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.streams.StreamContextStore +import com.nuvio.app.features.streams.StreamBadgeSettingsRepository import com.nuvio.app.features.streams.StreamLaunchStore import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.trakt.TraktAuthRepository @@ -53,6 +54,7 @@ internal object LocalAccountDataCleaner { TraktAuthRepository.clearLocalState() TraktSettingsRepository.clearLocalState() PlayerSettingsRepository.clearLocalState() + StreamBadgeSettingsRepository.clearLocalState() P2pSettingsRepository.clearLocalState() CatalogRepository.clear() StreamsRepository.clear() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index aacb5336..0702be8d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt @@ -21,6 +21,8 @@ import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.core.ui.PosterCardStyleStorage import com.nuvio.app.features.settings.ThemeSettingsStorage import com.nuvio.app.features.settings.ThemeSettingsRepository +import com.nuvio.app.features.streams.StreamBadgeSettingsRepository +import com.nuvio.app.features.streams.StreamBadgeSettingsStorage import com.nuvio.app.features.tmdb.TmdbSettingsStorage import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.trakt.TraktCommentsStorage @@ -159,6 +161,7 @@ object ProfileSettingsSync { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" }, PosterCardStyleRepository.uiState.map { "poster_card_style" }, PlayerSettingsRepository.uiState.map { "player" }, + StreamBadgeSettingsRepository.uiState.map { "stream_badges" }, DebridSettingsRepository.uiState.map { "debrid" }, TmdbSettingsRepository.uiState.map { "tmdb" }, MdbListSettingsRepository.uiState.map { "mdblist" }, @@ -205,6 +208,7 @@ object ProfileSettingsSync { themeSettings = ThemeSettingsStorage.exportToSyncPayload(), posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(), playerSettings = PlayerSettingsStorage.exportToSyncPayload(), + streamBadgeSettings = StreamBadgeSettingsStorage.exportToSyncPayload(), debridSettings = DebridSettingsStorage.exportToSyncPayload(), tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(), mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(), @@ -230,6 +234,9 @@ object ProfileSettingsSync { PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings) PlayerSettingsRepository.onProfileChanged() + StreamBadgeSettingsStorage.replaceFromSyncPayload(blob.features.streamBadgeSettings) + StreamBadgeSettingsRepository.onProfileChanged() + DebridSettingsStorage.replaceFromSyncPayload(blob.features.debridSettings) DebridSettingsRepository.onProfileChanged() @@ -262,6 +269,7 @@ object ProfileSettingsSync { ThemeSettingsRepository.ensureLoaded() PosterCardStyleRepository.ensureLoaded() PlayerSettingsRepository.ensureLoaded() + StreamBadgeSettingsRepository.ensureLoaded() DebridSettingsRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded() MdbListSettingsRepository.ensureLoaded() @@ -285,6 +293,7 @@ object ProfileSettingsSync { "liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}", "poster_card_style=${PosterCardStyleRepository.uiState.value}", "player=${PlayerSettingsRepository.uiState.value}", + "stream_badges=${StreamBadgeSettingsRepository.uiState.value}", "debrid=${DebridSettingsRepository.uiState.value}", "tmdb=${TmdbSettingsRepository.uiState.value}", "mdblist=${MdbListSettingsRepository.uiState.value}", @@ -299,7 +308,7 @@ object ProfileSettingsSync { @Serializable private data class MobileProfileSettingsBlob( - val version: Int = 2, + val version: Int = 3, val features: MobileProfileSettingsFeatures = MobileProfileSettingsFeatures(), ) @@ -308,6 +317,7 @@ private data class MobileProfileSettingsFeatures( @SerialName("theme_settings") val themeSettings: JsonObject = JsonObject(emptyMap()), @SerialName("poster_card_style_settings_payload") val posterCardStyleSettingsPayload: String = "", @SerialName("player_settings") val playerSettings: JsonObject = JsonObject(emptyMap()), + @SerialName("stream_badge_settings") val streamBadgeSettings: JsonObject = JsonObject(emptyMap()), @SerialName("debrid_settings") val debridSettings: JsonObject = JsonObject(emptyMap()), @SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()), @SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()), 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 2baf0c1e..8e10daf7 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,6 @@ data class DebridSettings( val streamPreferences: DebridStreamPreferences = DebridStreamPreferences(), val streamNameTemplate: String = DebridStreamFormatterDefaults.NAME_TEMPLATE, val streamDescriptionTemplate: String = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, - 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 f42d6995..4020f69d 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 @@ -1,12 +1,9 @@ package com.nuvio.app.features.debrid -import com.nuvio.app.features.addons.httpGetText -import kotlinx.coroutines.CancellationException 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 @@ -37,7 +34,6 @@ object DebridSettingsRepository { private var streamPreferences = DebridStreamPreferences() private var streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE private var streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE - private var streamBadgeRules = StreamBadgeRules() fun ensureLoaded() { if (hasLoaded) return @@ -236,61 +232,6 @@ object DebridSettingsRepository { ) } - suspend fun importStreamBadgeRulesFromUrl(url: String): StreamBadgeImportResult { - ensureLoaded() - val normalizedUrl = url.trim() - if (normalizedUrl.isBlank()) { - return StreamBadgeImportResult.Error("Enter a badge JSON URL.") - } - if (!normalizedUrl.startsWith("https://", ignoreCase = true) && - !normalizedUrl.startsWith("http://", ignoreCase = true) - ) { - return StreamBadgeImportResult.Error("Badge URL must start with http:// or https://.") - } - - return try { - val currentRules = streamBadgeRules.normalized() - val isExistingImport = currentRules.imports.any { import -> - import.sourceUrl.equals(normalizedUrl, ignoreCase = true) - } - 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 = StreamBadgeRulesParser.parse( - sourceUrl = normalizedUrl, - payload = payload, - ) - streamBadgeRules = currentRules.upsert(parsedImport, activate = true) - publish() - saveStreamBadgeRules() - StreamBadgeImportResult.Success(streamBadgeRules) - } catch (error: Exception) { - if (error is CancellationException) throw error - StreamBadgeImportResult.Error(error.message ?: "Badge import failed.") - } - } - - fun setActiveStreamBadgeRulesSource(sourceUrl: String) { - ensureLoaded() - val currentRules = streamBadgeRules.normalized() - val nextRules = currentRules.setActiveSource(sourceUrl) - if (nextRules == currentRules) return - streamBadgeRules = nextRules - publish() - 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() { if (!hasResolverProvider()) { enabled = false @@ -399,7 +340,6 @@ object DebridSettingsRepository { ?: DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, DebridTemplateKind.DESCRIPTION, ) - streamBadgeRules = parseStreamBadgeRules(DebridSettingsStorage.loadStreamBadgeRules()) ?: StreamBadgeRules() publish() } @@ -419,7 +359,6 @@ object DebridSettingsRepository { streamPreferences = streamPreferences, streamNameTemplate = streamNameTemplate, streamDescriptionTemplate = streamDescriptionTemplate, - streamBadgeRules = streamBadgeRules, ) } @@ -427,16 +366,6 @@ object DebridSettingsRepository { DebridSettingsStorage.saveStreamPreferences(json.encodeToString(streamPreferences.normalized())) } - private fun saveStreamBadgeRules() { - 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 = runCatching { enumValueOf(value.orEmpty()) }.getOrDefault(default) @@ -451,29 +380,6 @@ object DebridSettingsRepository { } } - private fun parseStreamBadgeRules(value: String?): StreamBadgeRules? { - if (value.isNullOrBlank()) return null - 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 { NAME, DESCRIPTION, @@ -490,24 +396,6 @@ object DebridSettingsRepository { } } -@Serializable -private data class LegacyStreamBadgeRules( - val sourceUrl: String = "", - val filters: List = emptyList(), - val groups: List = emptyList(), -) { - fun toBadgeRules(): StreamBadgeRules = - StreamBadgeRules( - imports = listOf( - StreamBadgeImport( - 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/DebridSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt index a64cca0f..7d68db48 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt @@ -35,8 +35,6 @@ internal expect object DebridSettingsStorage { fun saveStreamNameTemplate(template: String) fun loadStreamDescriptionTemplate(): String? fun saveStreamDescriptionTemplate(template: String) - fun loadStreamBadgeRules(): String? - fun saveStreamBadgeRules(rules: String) fun exportToSyncPayload(): JsonObject fun replaceFromSyncPayload(payload: JsonObject) } 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 da1af160..8840c967 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,9 @@ class DebridStreamFormatter( fun format( stream: StreamItem, settings: DebridSettings, - compiledBadgeFilters: List = - StreamBadgeMatcher.compile(settings.streamBadgeRules), ): StreamItem { if (!stream.isManagedDebridStream) return stream - val matchedBadges = StreamBadgeMatcher.matchedBadges(stream, compiledBadgeFilters) + val matchedBadges = stream.badges val values = buildValues(stream, settings, matchedBadges) val formattedName = engine.render(settings.streamNameTemplate, values) .lineSequence() @@ -34,7 +32,7 @@ class DebridStreamFormatter( return stream.copy( name = formattedName.ifBlank { stream.name ?: DebridProviders.displayName(serviceId(stream)) }, description = formattedDescription.ifBlank { stream.description ?: stream.title }, - badges = matchedBadges, + badges = stream.badges, ) } 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 e84a75aa..f42082a5 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,12 +16,12 @@ object DebridStreamPresentation { val debridStreams = visibleStreams.filter { stream -> stream.isManagedDebridStream } if (debridStreams.isEmpty()) return@map group.copy(streams = visibleStreams) - val compiledBadgeFilters = StreamBadgeMatcher.compile(settings.streamBadgeRules) - val shouldFormatStreams = settings.hasCustomStreamFormatting || compiledBadgeFilters.isNotEmpty() + val shouldFormatStreams = settings.hasCustomStreamFormatting || + debridStreams.any { stream -> stream.badges.isNotEmpty() } val presentedDebridStreams = applyPreferences(debridStreams, settings) .map { stream -> if (shouldFormatStreams) { - formatter.format(stream, settings, compiledBadgeFilters) + formatter.format(stream, settings) } else { stream } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index ed69b6dd..6d907991 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -18,6 +18,8 @@ import com.nuvio.app.features.plugins.PluginScraper import com.nuvio.app.features.streams.AddonStreamWarmupRepository import com.nuvio.app.features.streams.AddonStreamGroup import com.nuvio.app.features.streams.StreamAutoPlaySelector +import com.nuvio.app.features.streams.StreamBadgePresentation +import com.nuvio.app.features.streams.StreamBadgeSettingsRepository import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamParser import com.nuvio.app.features.streams.StreamsUiState @@ -142,6 +144,7 @@ object PlayerStreamsRepository { jobHolder()?.cancel() stateFlow.value = StreamsUiState() + val streamBadgeRules = StreamBadgeSettingsRepository.snapshot() val embeddedStreams = MetaDetailsRepository.findEmbeddedStreams(videoId) if (embeddedStreams.isNotEmpty()) { log.d { "Using ${embeddedStreams.size} embedded streams for type=$type id=$videoId" } @@ -151,8 +154,12 @@ object PlayerStreamsRepository { streams = embeddedStreams, isLoading = false, ) - stateFlow.value = StreamsUiState( + val presentedGroup = StreamBadgePresentation.apply( groups = listOf(group), + rules = streamBadgeRules, + ).firstOrNull() ?: group + stateFlow.value = StreamsUiState( + groups = listOf(presentedGroup), activeAddonIds = setOf("embedded"), isAnyLoading = false, ) @@ -248,11 +255,16 @@ object PlayerStreamsRepository { null } - fun presentDebridGroup(group: AddonStreamGroup): AddonStreamGroup = - DebridStreamPresentation.apply( + fun presentStreamGroup(group: AddonStreamGroup): AddonStreamGroup { + val badgeGroup = StreamBadgePresentation.apply( groups = listOf(group), - settings = debridSettings, + rules = streamBadgeRules, ).firstOrNull() ?: group + return DebridStreamPresentation.apply( + groups = listOf(badgeGroup), + settings = debridSettings, + ).firstOrNull() ?: badgeGroup + } fun publishStreamGroup(group: AddonStreamGroup) { stateFlow.update { current -> @@ -273,7 +285,7 @@ object PlayerStreamsRepository { fun publishStreamGroupAfterCacheCheck(group: AddonStreamGroup) { if (group.addonId !in installedAddonIds || group.streams.isEmpty()) { - publishStreamGroup(presentDebridGroup(group)) + publishStreamGroup(presentStreamGroup(group)) return } @@ -283,7 +295,7 @@ object PlayerStreamsRepository { eligibleGroupIds = eligibleGroupIds, ) if (!shouldWaitForCacheCheck) { - publishStreamGroup(presentDebridGroup(group)) + publishStreamGroup(presentStreamGroup(group)) return } @@ -297,7 +309,7 @@ object PlayerStreamsRepository { groups = listOf(checkingGroup), eligibleGroupIds = eligibleGroupIds, ).firstOrNull() ?: checkingGroup - publishStreamGroup(presentDebridGroup(availabilityGroup)) + publishStreamGroup(presentStreamGroup(availabilityGroup)) } debridAvailabilityJobs += availabilityJob } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 824c7fd7..18057320 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -21,6 +21,7 @@ import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.search.SearchHistoryRepository import com.nuvio.app.features.settings.ThemeSettingsRepository +import com.nuvio.app.features.streams.StreamBadgeSettingsRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.tmdb.TmdbSettingsRepository @@ -148,6 +149,7 @@ object ProfileRepository { ThemeSettingsRepository.onProfileChanged() PosterCardStyleRepository.onProfileChanged() PlayerSettingsRepository.onProfileChanged() + StreamBadgeSettingsRepository.onProfileChanged() P2pSettingsRepository.onProfileChanged() HomeCatalogSettingsRepository.onProfileChanged() HomeRepository.clear() 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 d5858fd2..646a1597 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 @@ -5,8 +5,6 @@ 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 @@ -21,7 +19,6 @@ 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 @@ -33,7 +30,6 @@ 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 @@ -54,9 +50,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.nuvio.app.features.debrid.DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_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 @@ -66,10 +59,6 @@ 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.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 @@ -485,7 +474,6 @@ internal fun LazyListScope.debridSettingsContent( item { var activeTemplateField by rememberSaveable { mutableStateOf(null) } - var showBadgeImportDialog by rememberSaveable { mutableStateOf(false) } SettingsSection( title = stringResource(Res.string.settings_debrid_section_formatting), @@ -516,15 +504,6 @@ internal fun LazyListScope.debridSettingsContent( onClick = { activeTemplateField = DebridTemplateField.DESCRIPTION }, ) SettingsGroupDivider(isTablet = isTablet) - DebridPreferenceRow( - isTablet = isTablet, - title = "Badge URLs", - description = "Manage imported label badge JSON URLs.", - value = badgeRulesPreview(settings.streamBadgeRules), - enabled = settings.canResolvePlayableLinks, - onClick = { showBadgeImportDialog = true }, - ) - SettingsGroupDivider(isTablet = isTablet) DebridPreferenceRow( isTablet = isTablet, title = stringResource(Res.string.settings_debrid_formatter_reset_title), @@ -556,12 +535,6 @@ internal fun LazyListScope.debridSettingsContent( null -> Unit } - if (showBadgeImportDialog) { - BadgeUrlManagerDialog( - currentRules = settings.streamBadgeRules, - onDismiss = { showBadgeImportDialog = false }, - ) - } } debridLearnMoreFooterItem(isTablet) @@ -613,15 +586,6 @@ private fun templatePreview(value: String, defaultValue: String): String { return if (firstLine.length <= 28) firstLine else "${firstLine.take(28)}..." } -private fun badgeRulesPreview(rules: StreamBadgeRules): String { - val normalizedRules = rules.normalized() - return if (normalizedRules.hasImport) { - "${normalizedRules.imports.size}/$STREAM_BADGE_IMPORT_LIMIT URLs, ${normalizedRules.enabledFilterCount} active badges" - } else { - "Not imported" - } -} - @Composable private fun prepareCountLabel(limit: Int): String = if (limit == 1) { @@ -785,355 +749,6 @@ private fun DebridTemplateDialog( } } -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun BadgeUrlManagerDialog( - currentRules: StreamBadgeRules, - onDismiss: () -> Unit, -) { - val scope = rememberCoroutineScope() - 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 = "Badge URLs") { - Text( - 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, - ) - OutlinedTextField( - value = draftUrl, - onValueChange = { - draftUrl = it - errorMessage = null - }, - modifier = Modifier.fillMaxWidth(), - label = { Text("Badge JSON URL") }, - singleLine = false, - minLines = 2, - maxLines = 4, - enabled = !isImporting, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - disabledContainerColor = MaterialTheme.colorScheme.surface, - ), - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "${imports.size}/$STREAM_BADGE_IMPORT_LIMIT URLs imported", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - Button( - enabled = !isImporting && draftUrl.isNotBlank(), - onClick = { - scope.launch { - isImporting = true - errorMessage = null - when (val result = DebridSettingsRepository.importStreamBadgeRulesFromUrl(draftUrl)) { - is StreamBadgeImportResult.Success -> { - draftUrl = "" - isImporting = false - } - is StreamBadgeImportResult.Error -> { - errorMessage = result.message - isImporting = false - } - } - } - }, - ) { - if (isImporting) { - CircularProgressIndicator( - strokeWidth = 2.dp, - modifier = Modifier.size(16.dp), - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Text(text = "Import", maxLines = 1) - } - } - } - 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 -> - BadgeUrlRow( - 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 -> - BadgePreviewDialog( - import = import, - onDismiss = { previewImport = null }, - ) - } -} - -@Composable -private fun BadgeUrlRow( - import: StreamBadgeImport, - 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 BadgePreviewDialog( - import: StreamBadgeImport, - 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 -> - ImportedBadgeChip( - imageURL = filter.imageURL, - name = filter.name, - tagColor = filter.tagColor, - tagStyle = filter.tagStyle, - borderColor = filter.borderColor, - size = ImportedBadgeChipSize.PREVIEW, - ) - } - } - } - } - } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = onDismiss) { - Text(text = "Close", maxLines = 1) - } - } - } - } -} - -private data class BadgePreviewSection( - val id: String, - val title: String, - val filters: 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() - import.groups.forEachIndexed { index, group -> - val groupFilters = filtersByGroupId[group.id].orEmpty() - if (groupFilters.isNotEmpty()) { - usedGroupIds += group.id - sections += BadgePreviewSection( - 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 += BadgePreviewSection( - id = "other", - title = "Other badges", - filters = ungroupedFilters, - ) - } - return sections -} - @Composable private fun DebridPreferenceRow( isTablet: Boolean, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt index a6eb2a40..006d8768 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt @@ -25,6 +25,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_playback import nuvio.composeapp.generated.resources.compose_settings_page_plugins import nuvio.composeapp.generated.resources.compose_settings_page_poster_customization import nuvio.composeapp.generated.resources.compose_settings_page_root +import nuvio.composeapp.generated.resources.compose_settings_page_streams import nuvio.composeapp.generated.resources.compose_settings_page_supporters_contributors import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment import nuvio.composeapp.generated.resources.compose_settings_page_trakt @@ -70,6 +71,11 @@ internal enum class SettingsPage( category = SettingsCategory.General, parentPage = Root, ), + Streams( + titleRes = Res.string.compose_settings_page_streams, + category = SettingsCategory.General, + parentPage = Root, + ), Appearance( titleRes = Res.string.compose_settings_page_appearance, category = SettingsCategory.General, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt index 71580c35..9ce3a1ff 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt @@ -14,6 +14,7 @@ import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.People import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Style import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.ui.Modifier @@ -29,6 +30,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_integrations import nuvio.composeapp.generated.resources.compose_settings_page_licenses_attributions import nuvio.composeapp.generated.resources.compose_settings_page_notifications import nuvio.composeapp.generated.resources.compose_settings_page_playback +import nuvio.composeapp.generated.resources.compose_settings_page_streams import nuvio.composeapp.generated.resources.compose_settings_page_supporters_contributors import nuvio.composeapp.generated.resources.compose_settings_root_account_description import nuvio.composeapp.generated.resources.compose_settings_root_appearance_description @@ -40,6 +42,7 @@ import nuvio.composeapp.generated.resources.compose_settings_root_downloads_titl import nuvio.composeapp.generated.resources.compose_settings_root_general_section import nuvio.composeapp.generated.resources.compose_settings_root_integrations_description import nuvio.composeapp.generated.resources.compose_settings_root_notifications_description +import nuvio.composeapp.generated.resources.compose_settings_root_streams_description import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile_description import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile_title import nuvio.composeapp.generated.resources.compose_settings_root_trakt_description @@ -55,6 +58,7 @@ import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.settingsRootContent( isTablet: Boolean, onPlaybackClick: () -> Unit, + onStreamsClick: () -> Unit, onAppearanceClick: () -> Unit, onNotificationsClick: () -> Unit, onContentDiscoveryClick: () -> Unit, @@ -145,6 +149,14 @@ internal fun LazyListScope.settingsRootContent( onClick = onPlaybackClick, ) SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_page_streams), + description = stringResource(Res.string.compose_settings_root_streams_description), + icon = Icons.Rounded.Style, + isTablet = isTablet, + onClick = onStreamsClick, + ) + SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( title = stringResource(Res.string.compose_settings_page_integrations), description = stringResource(Res.string.compose_settings_root_integrations_description), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 66ef8bce..88cf0b01 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -494,6 +494,7 @@ private fun MobileSettingsScreen( settingsRootContent( isTablet = false, onPlaybackClick = { onPageChange(SettingsPage.Playback) }, + onStreamsClick = { onPageChange(SettingsPage.Streams) }, onAppearanceClick = { onPageChange(SettingsPage.Appearance) }, onNotificationsClick = { onPageChange(SettingsPage.Notifications) }, onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) }, @@ -534,6 +535,9 @@ private fun MobileSettingsScreen( useLibass = useLibass, libassRenderType = libassRenderType, ) + SettingsPage.Streams -> streamsSettingsContent( + isTablet = false, + ) SettingsPage.Appearance -> appearanceSettingsContent( isTablet = false, selectedTheme = selectedTheme, @@ -863,6 +867,7 @@ private fun TabletSettingsScreen( settingsRootContent( isTablet = true, onPlaybackClick = { openInlinePage(SettingsPage.Playback) }, + onStreamsClick = { openInlinePage(SettingsPage.Streams) }, onAppearanceClick = { openInlinePage(SettingsPage.Appearance) }, onNotificationsClick = { openInlinePage(SettingsPage.Notifications) }, onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) }, @@ -906,6 +911,9 @@ private fun TabletSettingsScreen( useLibass = useLibass, libassRenderType = libassRenderType, ) + SettingsPage.Streams -> streamsSettingsContent( + isTablet = true, + ) SettingsPage.Appearance -> appearanceSettingsContent( isTablet = true, selectedTheme = selectedTheme, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt index 03187cba..9b3a4c9b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt @@ -92,6 +92,7 @@ internal fun settingsSearchEntries( val contentDiscoveryPage = stringResource(Res.string.compose_settings_page_content_discovery) val downloadsPage = stringResource(Res.string.compose_settings_root_downloads_title) val playbackPage = stringResource(Res.string.compose_settings_page_playback) + val streamsPage = stringResource(Res.string.compose_settings_page_streams) val integrationsPage = stringResource(Res.string.compose_settings_page_integrations) val notificationsPage = stringResource(Res.string.compose_settings_page_notifications) val supportersPage = stringResource(Res.string.compose_settings_page_supporters_contributors) @@ -228,6 +229,13 @@ internal fun settingsSearchEntries( description = stringResource(Res.string.settings_playback_subtitle), icon = Icons.Rounded.PlayArrow, ) + addPage( + page = SettingsPage.Streams, + key = "streams", + title = streamsPage, + description = stringResource(Res.string.compose_settings_root_streams_description), + icon = Icons.Rounded.Style, + ) addPage( page = SettingsPage.Integrations, key = "integrations", @@ -424,6 +432,15 @@ internal fun settingsSearchEntries( val playbackSubtitleRendering = stringResource(Res.string.settings_playback_section_subtitle_rendering) val playbackSkipSegments = stringResource(Res.string.settings_playback_section_skip_segments) val playbackNextEpisode = stringResource(Res.string.settings_playback_section_next_episode) + addRow( + page = SettingsPage.Streams, + key = "stream-badge-urls", + title = stringResource(Res.string.settings_stream_badge_urls_title), + description = stringResource(Res.string.settings_stream_badge_urls_search_description), + pageLabel = streamsPage, + section = stringResource(Res.string.settings_stream_badges_section), + icon = Icons.Rounded.Style, + ) addPlaybackRows( addRow = ::addRow, pageLabel = playbackPage, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt new file mode 100644 index 00000000..369d34e9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt @@ -0,0 +1,481 @@ +package com.nuvio.app.features.settings + +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 +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.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.Delete +import androidx.compose.material.icons.rounded.Style +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +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 +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.features.streams.STREAM_BADGE_IMPORT_LIMIT +import com.nuvio.app.features.streams.StreamBadgeChip +import com.nuvio.app.features.streams.StreamBadgeChipSize +import com.nuvio.app.features.streams.StreamBadgeFilter +import com.nuvio.app.features.streams.StreamBadgeImport +import com.nuvio.app.features.streams.StreamBadgeImportResult +import com.nuvio.app.features.streams.StreamBadgeRules +import com.nuvio.app.features.streams.StreamBadgeSettingsRepository +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.action_delete +import nuvio.composeapp.generated.resources.settings_stream_badge_urls_description +import nuvio.composeapp.generated.resources.settings_stream_badge_urls_title +import nuvio.composeapp.generated.resources.settings_stream_badges_section +import org.jetbrains.compose.resources.stringResource + +internal fun LazyListScope.streamsSettingsContent(isTablet: Boolean) { + item { + val currentRules by remember { + StreamBadgeSettingsRepository.ensureLoaded() + StreamBadgeSettingsRepository.uiState + }.collectAsStateWithLifecycle() + var showBadgeImportDialog by rememberSaveable { mutableStateOf(false) } + + SettingsSection( + title = stringResource(Res.string.settings_stream_badges_section), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsNavigationRow( + title = stringResource(Res.string.settings_stream_badge_urls_title), + description = badgeRulesPreview(currentRules), + icon = Icons.Rounded.Style, + isTablet = isTablet, + onClick = { showBadgeImportDialog = true }, + ) + } + } + + if (showBadgeImportDialog) { + BadgeUrlManagerDialog( + currentRules = currentRules, + onDismiss = { showBadgeImportDialog = false }, + ) + } + } +} + +private fun badgeRulesPreview(rules: StreamBadgeRules): String { + val normalizedRules = rules.normalized() + return if (normalizedRules.hasImport) { + "${normalizedRules.imports.size}/$STREAM_BADGE_IMPORT_LIMIT URLs, ${normalizedRules.enabledFilterCount} active badges" + } else { + "No badge URLs imported." + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun BadgeUrlManagerDialog( + currentRules: StreamBadgeRules, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + 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) { + SettingsDialogSurface(title = stringResource(Res.string.settings_stream_badge_urls_title)) { + Text( + text = stringResource(Res.string.settings_stream_badge_urls_description, STREAM_BADGE_IMPORT_LIMIT), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = draftUrl, + onValueChange = { + draftUrl = it + errorMessage = null + }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Badge JSON URL") }, + singleLine = false, + minLines = 2, + maxLines = 4, + enabled = !isImporting, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${imports.size}/$STREAM_BADGE_IMPORT_LIMIT URLs imported", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Button( + enabled = !isImporting && draftUrl.isNotBlank(), + onClick = { + scope.launch { + isImporting = true + errorMessage = null + when (val result = StreamBadgeSettingsRepository.importStreamBadgeRulesFromUrl(draftUrl)) { + is StreamBadgeImportResult.Success -> { + draftUrl = "" + isImporting = false + } + is StreamBadgeImportResult.Error -> { + errorMessage = result.message + isImporting = false + } + } + } + }, + ) { + if (isImporting) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(16.dp), + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text(text = "Import", maxLines = 1) + } + } + } + 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 -> + BadgeUrlRow( + import = import, + showActiveChoice = imports.size > 1, + enabled = !isImporting, + onActivate = { + StreamBadgeSettingsRepository.setActiveStreamBadgeRulesSource(import.sourceUrl) + }, + onPreview = { previewImport = import }, + onDelete = { + StreamBadgeSettingsRepository.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 -> + BadgePreviewDialog( + import = import, + onDismiss = { previewImport = null }, + ) + } +} + +@Composable +private fun BadgeUrlRow( + import: StreamBadgeImport, + 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 BadgePreviewDialog( + import: StreamBadgeImport, + onDismiss: () -> Unit, +) { + val sections = remember(import) { badgePreviewSections(import) } + val badgeCount = sections.sumOf { it.filters.size } + + BasicAlertDialog(onDismissRequest = onDismiss) { + SettingsDialogSurface(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 -> + StreamBadgeChip( + imageURL = filter.imageURL, + name = filter.name, + tagColor = filter.tagColor, + tagStyle = filter.tagStyle, + borderColor = filter.borderColor, + size = StreamBadgeChipSize.PREVIEW, + ) + } + } + } + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(text = "Close", maxLines = 1) + } + } + } + } +} + +@Composable +private fun SettingsDialogSurface( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + content() + Spacer(modifier = Modifier.height(2.dp)) + } + } +} + +private data class BadgePreviewSection( + val id: String, + val title: String, + val filters: 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() + import.groups.forEachIndexed { index, group -> + val groupFilters = filtersByGroupId[group.id].orEmpty() + if (groupFilters.isNotEmpty()) { + usedGroupIds += group.id + sections += BadgePreviewSection( + 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 += BadgePreviewSection( + id = "other", + title = "Other badges", + filters = ungroupedFilters, + ) + } + return sections +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt index 5b56c1ea..6d9682e6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt @@ -129,10 +129,14 @@ object AddonStreamWarmupRepository { groups = listOf(checkingGroup), eligibleGroupIds = eligibleGroupIds, ).firstOrNull() ?: checkingGroup - DebridStreamPresentation.apply( + val badgeGroup = StreamBadgePresentation.apply( groups = listOf(availabilityGroup), - settings = key.settings, + rules = key.streamBadgeRules, ).firstOrNull() ?: availabilityGroup + DebridStreamPresentation.apply( + groups = listOf(badgeGroup), + settings = key.settings, + ).firstOrNull() ?: badgeGroup } }.awaitAll() }.let { groups -> @@ -211,6 +215,7 @@ object AddonStreamWarmupRepository { DebridSettingsRepository.ensureLoaded() val settings = DebridSettingsRepository.snapshot() if (!settings.canResolvePlayableLinks || settings.torboxApiKey.isBlank()) return null + val streamBadgeRules = StreamBadgeSettingsRepository.snapshot() AddonRepository.initialize() val addonTargets = AddonRepository.uiState.value.addons @@ -225,7 +230,9 @@ object AddonStreamWarmupRepository { episode = episode, addonFingerprint = addonTargets.joinToString("|") { it.fingerprint }, settingsFingerprint = settings.warmupFingerprint(), + streamBadgeRulesFingerprint = streamBadgeRules.toString(), settings = settings, + streamBadgeRules = streamBadgeRules, addonTargets = addonTargets, ) } @@ -249,7 +256,9 @@ private data class AddonStreamWarmupKey( val episode: Int?, val addonFingerprint: String, val settingsFingerprint: String, + val streamBadgeRulesFingerprint: String, val settings: DebridSettings, + val streamBadgeRules: StreamBadgeRules, val addonTargets: List, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/ImportedBadgeChip.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeChip.kt similarity index 92% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/ImportedBadgeChip.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeChip.kt index 452e43ef..e7222c4e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/ImportedBadgeChip.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeChip.kt @@ -1,4 +1,4 @@ -package com.nuvio.app.features.debrid +package com.nuvio.app.features.streams import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -19,7 +19,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage -internal object BadgeChipDefaults { +internal object StreamBadgeChipDefaults { val shape = RoundedCornerShape(6.dp) val fileSizeHorizontalPadding = 6.dp val fileSizeFontSize: TextUnit = 10.sp @@ -27,7 +27,7 @@ internal object BadgeChipDefaults { val fileSizeLetterSpacing: TextUnit = 0.sp } -internal enum class ImportedBadgeChipSize( +internal enum class StreamBadgeChipSize( val containerHeight: Dp, val imageHeight: Dp, val minImageWidth: Dp, @@ -54,13 +54,13 @@ internal enum class ImportedBadgeChipSize( } @Composable -internal fun ImportedBadgeChip( +internal fun StreamBadgeChip( imageURL: String, name: String, tagColor: String, tagStyle: String, borderColor: String, - size: ImportedBadgeChipSize, + size: StreamBadgeChipSize, modifier: Modifier = Modifier, ) { val backgroundColorArgb = if (tagStyle.equals("filled", ignoreCase = true)) { @@ -69,7 +69,7 @@ internal fun ImportedBadgeChip( null } val outlineColorArgb = borderColor.toBadgeColorArgbOrNull() - val shape = BadgeChipDefaults.shape + val shape = StreamBadgeChipDefaults.shape var chipModifier = modifier.height(size.containerHeight) if (backgroundColorArgb != null) { chipModifier = chipModifier.background(Color(backgroundColorArgb), shape) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgePresentation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgePresentation.kt new file mode 100644 index 00000000..925e7c14 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgePresentation.kt @@ -0,0 +1,29 @@ +package com.nuvio.app.features.streams + +object StreamBadgePresentation { + fun apply(groups: List, rules: StreamBadgeRules): List { + val filters = StreamBadgeMatcher.compile(rules) + if (filters.isEmpty()) return groups + return groups.map { group -> + group.copy( + streams = group.streams.map { stream -> + val matchedBadges = StreamBadgeMatcher.matchedBadges(stream, filters) + if (matchedBadges.isEmpty()) { + stream + } else { + stream.copy(badges = mergeBadges(stream.badges, matchedBadges)) + } + }, + ) + } + } + + private fun mergeBadges(existing: List, matched: List): List { + if (existing.isEmpty()) return matched + val seenKeys = existing.mapTo(mutableSetOf()) { it.dedupeKey() } + return existing + matched.filter { badge -> seenKeys.add(badge.dedupeKey()) } + } + + private fun StreamBadge.dedupeKey(): String = + (imageURL.takeIf { it.isNotBlank() } ?: name).lowercase() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/StreamBadgeRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeRules.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/StreamBadgeRules.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeRules.kt index eb47d17c..671d43cc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/StreamBadgeRules.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeRules.kt @@ -1,7 +1,5 @@ -package com.nuvio.app.features.debrid +package com.nuvio.app.features.streams -import com.nuvio.app.features.streams.StreamItem -import com.nuvio.app.features.streams.StreamBadge import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException @@ -185,7 +183,7 @@ internal object StreamBadgeRulesParser { } } -internal object StreamBadgeMatcher { +object StreamBadgeMatcher { fun compile(rules: StreamBadgeRules): List { if (!rules.hasImport) return emptyList() return rules.normalized().imports.filter { it.isActive }.flatMap { import -> @@ -232,7 +230,7 @@ internal object StreamBadgeMatcher { return matched.values.toList() } - private fun badgeMatchCandidates(stream: StreamItem): List { + fun badgeMatchCandidates(stream: StreamItem): List { val resolve = stream.clientResolve val raw = resolve?.stream?.raw val parsed = raw?.parsed @@ -278,7 +276,7 @@ data class CompiledStreamBadgeFilter( ) private fun StreamBadge.dedupeKey(): String = - imageURL.takeIf { it.isNotBlank() } ?: name + (imageURL.takeIf { it.isNotBlank() } ?: name).lowercase() @Serializable private data class StreamBadgePayload( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt new file mode 100644 index 00000000..f0d34ff3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt @@ -0,0 +1,173 @@ +package com.nuvio.app.features.streams + +import com.nuvio.app.features.addons.httpGetText +import kotlinx.coroutines.CancellationException +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 +import kotlinx.serialization.json.Json + +object StreamBadgeSettingsRepository { + private val _uiState = MutableStateFlow(StreamBadgeRules()) + val uiState: StateFlow = _uiState.asStateFlow() + + @OptIn(ExperimentalSerializationApi::class) + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + + private var hasLoaded = false + private var streamBadgeRules = StreamBadgeRules() + + fun ensureLoaded() { + if (hasLoaded) return + loadFromDisk() + } + + fun onProfileChanged() { + loadFromDisk() + } + + fun clearLocalState() { + hasLoaded = false + streamBadgeRules = StreamBadgeRules() + _uiState.value = streamBadgeRules + } + + fun snapshot(): StreamBadgeRules { + ensureLoaded() + return _uiState.value + } + + suspend fun importStreamBadgeRulesFromUrl(url: String): StreamBadgeImportResult { + ensureLoaded() + val normalizedUrl = url.trim() + if (normalizedUrl.isBlank()) { + return StreamBadgeImportResult.Error("Enter a badge JSON URL.") + } + if (!normalizedUrl.startsWith("https://", ignoreCase = true) && + !normalizedUrl.startsWith("http://", ignoreCase = true) + ) { + return StreamBadgeImportResult.Error("Badge URL must start with http:// or https://.") + } + + return try { + val currentRules = streamBadgeRules.normalized() + val isExistingImport = currentRules.imports.any { import -> + import.sourceUrl.equals(normalizedUrl, ignoreCase = true) + } + 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 = StreamBadgeRulesParser.parse( + sourceUrl = normalizedUrl, + payload = payload, + ) + streamBadgeRules = currentRules.upsert(parsedImport, activate = true) + publish() + saveStreamBadgeRules() + StreamBadgeImportResult.Success(streamBadgeRules) + } catch (error: Exception) { + if (error is CancellationException) throw error + StreamBadgeImportResult.Error(error.message ?: "Badge import failed.") + } + } + + fun setActiveStreamBadgeRulesSource(sourceUrl: String) { + ensureLoaded() + val currentRules = streamBadgeRules.normalized() + val nextRules = currentRules.setActiveSource(sourceUrl) + if (nextRules == currentRules) return + streamBadgeRules = nextRules + publish() + 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 loadFromDisk() { + hasLoaded = true + val storedRules = parseStreamBadgeRules(StreamBadgeSettingsStorage.loadStreamBadgeRules()) + val legacyRules = if (storedRules == null) { + parseStreamBadgeRules(StreamBadgeSettingsStorage.loadLegacyDebridStreamBadgeRules()) + } else { + null + } + streamBadgeRules = storedRules ?: legacyRules ?: StreamBadgeRules() + if (legacyRules != null) { + saveStreamBadgeRules() + StreamBadgeSettingsStorage.clearLegacyDebridStreamBadgeRules() + } + publish() + } + + private fun publish() { + _uiState.value = streamBadgeRules + } + + private fun saveStreamBadgeRules() { + val normalizedRules = streamBadgeRules.normalized() + val payload = if (normalizedRules.hasImport) { + json.encodeToString(normalizedRules) + } else { + "" + } + StreamBadgeSettingsStorage.saveStreamBadgeRules(payload) + } + + private fun parseStreamBadgeRules(value: String?): StreamBadgeRules? { + if (value.isNullOrBlank()) return null + 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 + } +} + +@Serializable +private data class LegacyStreamBadgeRules( + val sourceUrl: String = "", + val filters: List = emptyList(), + val groups: List = emptyList(), +) { + fun toBadgeRules(): StreamBadgeRules = + StreamBadgeRules( + imports = listOf( + StreamBadgeImport( + sourceUrl = sourceUrl, + filters = filters, + groups = groups, + ), + ), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt new file mode 100644 index 00000000..a7d3a7f0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt @@ -0,0 +1,12 @@ +package com.nuvio.app.features.streams + +import kotlinx.serialization.json.JsonObject + +internal expect object StreamBadgeSettingsStorage { + fun loadStreamBadgeRules(): String? + fun saveStreamBadgeRules(rules: String) + fun loadLegacyDebridStreamBadgeRules(): String? + fun clearLegacyDebridStreamBadgeRules() + fun exportToSyncPayload(): JsonObject + fun replaceFromSyncPayload(payload: JsonObject) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 1aff8fcb..bc7bc787 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -108,6 +108,7 @@ object StreamsRepository { PlayerSettingsRepository.ensureLoaded() val playerSettings = PlayerSettingsRepository.uiState.value val debridSettings = DebridSettingsRepository.snapshot() + val streamBadgeRules = StreamBadgeSettingsRepository.snapshot() val autoPlayMode = playerSettings.streamAutoPlayMode val isAutoPlayEnabled = !manualSelection && autoPlayMode != StreamAutoPlayMode.MANUAL && !(autoPlayMode == StreamAutoPlayMode.REGEX_MATCH && @@ -145,9 +146,13 @@ object StreamsRepository { streams = embeddedStreams, isLoading = false, ) + val presentedGroup = StreamBadgePresentation.apply( + groups = listOf(group), + rules = streamBadgeRules, + ).firstOrNull() ?: group _uiState.value = StreamsUiState( requestToken = requestToken, - groups = listOf(group), + groups = listOf(presentedGroup), activeAddonIds = setOf("embedded"), isAnyLoading = false, ) @@ -256,11 +261,16 @@ object StreamsRepository { log.d { "Ignoring late stream load completion after channel close" } } } - fun presentDebridGroup(group: AddonStreamGroup): AddonStreamGroup = - DebridStreamPresentation.apply( + fun presentStreamGroup(group: AddonStreamGroup): AddonStreamGroup { + val badgeGroup = StreamBadgePresentation.apply( groups = listOf(group), - settings = debridSettings, + rules = streamBadgeRules, ).firstOrNull() ?: group + return DebridStreamPresentation.apply( + groups = listOf(badgeGroup), + settings = debridSettings, + ).firstOrNull() ?: badgeGroup + } fun publishAddonGroup(group: AddonStreamGroup) { _uiState.update { current -> @@ -281,7 +291,7 @@ object StreamsRepository { fun publishAddonGroupAfterCacheCheck(group: AddonStreamGroup) { if (group.addonId !in installedAddonIds || group.streams.isEmpty()) { - publishAddonGroup(presentDebridGroup(group)) + publishAddonGroup(presentStreamGroup(group)) return } @@ -291,7 +301,7 @@ object StreamsRepository { eligibleGroupIds = eligibleGroupIds, ) if (!shouldWaitForCacheCheck) { - publishAddonGroup(presentDebridGroup(group)) + publishAddonGroup(presentStreamGroup(group)) return } @@ -305,7 +315,7 @@ object StreamsRepository { groups = listOf(checkingGroup), eligibleGroupIds = eligibleGroupIds, ).firstOrNull() ?: checkingGroup - publishAddonGroup(presentDebridGroup(availabilityGroup)) + publishAddonGroup(presentStreamGroup(availabilityGroup)) // Early binge-group match right after this addon's availability is resolved if (isDirectAutoPlayFlow && !autoSelectTriggered && persistedBingeGroup != null && !timeoutElapsed) { 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 347a56cf..1d58598a 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,9 +90,6 @@ 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 @@ -1045,7 +1042,7 @@ private fun StreamCard( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { badgeImages.forEach { badge -> - StreamImportedBadge(badge = badge) + StreamBadgeImage(badge = badge) } StreamFileSizeBadge(stream = stream) } @@ -1217,14 +1214,14 @@ private fun StreamItem.instantServiceLabel(): String? { } @Composable -private fun StreamImportedBadge(badge: StreamBadge) { - ImportedBadgeChip( +private fun StreamBadgeImage(badge: StreamBadge) { + StreamBadgeChip( imageURL = badge.imageURL, name = badge.name, tagColor = badge.tagColor, tagStyle = badge.tagStyle, borderColor = badge.borderColor, - size = ImportedBadgeChipSize.STREAM, + size = StreamBadgeChipSize.STREAM, ) } @@ -1240,23 +1237,23 @@ private fun StreamFileSizeBadge(stream: StreamItem) { "${round(mib).toInt()} ${localizedByteUnit("MB")}" } - val badgeShape = BadgeChipDefaults.shape + val badgeShape = StreamBadgeChipDefaults.shape Box( modifier = Modifier - .height(ImportedBadgeChipSize.STREAM.containerHeight) + .height(StreamBadgeChipSize.STREAM.containerHeight) .clip(badgeShape) .background(Color(0xFF0A0C0C)) .border(1.dp, Color(0xFF0A0C0C), badgeShape) - .padding(horizontal = BadgeChipDefaults.fileSizeHorizontalPadding), + .padding(horizontal = StreamBadgeChipDefaults.fileSizeHorizontalPadding), contentAlignment = Alignment.Center, ) { Text( text = stringResource(Res.string.streams_size, sizeLabel), style = MaterialTheme.typography.labelSmall.copy( - fontSize = BadgeChipDefaults.fileSizeFontSize, - lineHeight = BadgeChipDefaults.fileSizeLineHeight, + fontSize = StreamBadgeChipDefaults.fileSizeFontSize, + lineHeight = StreamBadgeChipDefaults.fileSizeLineHeight, fontWeight = FontWeight.SemiBold, - letterSpacing = BadgeChipDefaults.fileSizeLetterSpacing, + letterSpacing = StreamBadgeChipDefaults.fileSizeLetterSpacing, ), color = Color.White, ) 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 50b1bcba..c4d1294b 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 @@ -6,6 +6,7 @@ import com.nuvio.app.features.streams.StreamClientResolve import com.nuvio.app.features.streams.StreamClientResolveParsed import com.nuvio.app.features.streams.StreamClientResolveRaw import com.nuvio.app.features.streams.StreamClientResolveStream +import com.nuvio.app.features.streams.StreamBadge import com.nuvio.app.features.streams.StreamDebridCacheState import com.nuvio.app.features.streams.StreamDebridCacheStatus import com.nuvio.app.features.streams.StreamItem @@ -62,10 +63,17 @@ class DebridStreamPresentationTest { } @Test - fun `formats imported badge matches from fusion badge rules`() { + fun `formats existing stream badges in template values`() { val stream = localTorboxStream( filename = "Movie.2024.2160p.BluRay.REMUX.TrueHD.7.1-GRP.mkv", size = 40_000_000_000, + ).copy( + badges = listOf( + StreamBadge( + name = "TRUEHD", + imageURL = "https://example.test/truehd.png", + ), + ), ) val formatted = DebridStreamFormatter().format( @@ -74,135 +82,16 @@ class DebridStreamPresentationTest { enabled = true, providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"), streamNameTemplate = "{stream.rseMatched::join(' | ')}", - streamDescriptionTemplate = "{stream.regexMatched::~REMUX[\"has-remux\"||\"missing-remux\"]}", - streamBadgeRules = StreamBadgeRules( - imports = listOf( - StreamBadgeImport( - sourceUrl = "https://example.test/media-badges.json", - isActive = false, - filters = listOf( - StreamBadgeFilter( - name = "REMUX", - pattern = "(?i)\\bremux\\b", - imageURL = "https://example.test/remux.png", - tagColor = "#27C04F", - tagStyle = "filled", - textColor = "#FFFFFF", - borderColor = "#27C04F", - ), - StreamBadgeFilter( - name = "Disabled", - pattern = "(?i)\\bbluray\\b", - isEnabled = false, - ), - ), - ), - StreamBadgeImport( - sourceUrl = "https://example.test/audio-badges.json", - isActive = true, - filters = listOf( - StreamBadgeFilter( - name = "TRUEHD", - pattern = "(?i)\\btruehd\\b", - imageURL = "https://example.test/truehd.png", - tagColor = "#B968FF", - tagStyle = "filled", - textColor = "#FFFFFF", - borderColor = "#B968FF", - ), - ), - ), - ), - ), + streamDescriptionTemplate = "{stream.regexMatched::~TRUEHD[\"has-truehd\"||\"missing-truehd\"]}", ), ) assertEquals("TRUEHD", formatted.name) - assertEquals("missing-remux", formatted.description) + assertEquals("has-truehd", 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 importedRules = StreamBadgeRulesParser.parse( - sourceUrl = "https://example.test/fusion-tags-ume.json", - payload = """ - { - "filters": [ - { - "borderColor": "#27C04F", - "groupId": "media", - "id": "remux", - "imageURL": "https://example.test/remux.png", - "isEnabled": true, - "name": "REMUX", - "pattern": "(?i)\\bremux\\b", - "tagColor": "#27C04F", - "tagStyle": "filled", - "textColor": "#FFFFFF", - "type": "filter" - } - ], - "groups": [ - { - "color": "#96CEB4", - "id": "media", - "isExpanded": true, - "name": "Media Source" - } - ] - } - """.trimIndent(), - ) - - 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 - fun `attaches imported badge urls to presented debrid streams`() { - val stream = localTorboxStream( - filename = "Movie.2024.2160p.BluRay.REMUX.TrueHD.7.1-GRP.mkv", - size = 40_000_000_000, - ) - - val presented = DebridStreamPresentation.apply( - groups = listOf( - AddonStreamGroup( - addonName = "Addon", - addonId = "addon:test", - streams = listOf(stream), - ), - ), - settings = DebridSettings( - enabled = true, - providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"), - streamBadgeRules = StreamBadgeRules( - imports = listOf( - StreamBadgeImport( - sourceUrl = "https://example.test/badges.json", - filters = listOf( - StreamBadgeFilter( - name = "REMUX 1", - pattern = "(?i)\\bremux\\b", - imageURL = "https://example.test/remux-t1.png", - ), - ), - ), - ), - ), - ), - ).single().streams.single() - - assertEquals(listOf("REMUX 1"), presented.badges.map { it.name }) - assertEquals("https://example.test/remux-t1.png", presented.badges.single().imageURL) - } - @Test fun `default formatter replaces addon source labels for managed streams`() { val stream = premiumizeDirectStream( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamBadgePresentationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamBadgePresentationTest.kt new file mode 100644 index 00000000..a3f983e1 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamBadgePresentationTest.kt @@ -0,0 +1,179 @@ +package com.nuvio.app.features.streams + +import com.nuvio.app.features.debrid.DebridProviders +import kotlin.test.Test +import kotlin.test.assertEquals + +class StreamBadgePresentationTest { + @Test + fun `parses fusion badge url payload shape`() { + val importedRules = StreamBadgeRulesParser.parse( + sourceUrl = "https://example.test/fusion-tags-ume.json", + payload = """ + { + "filters": [ + { + "borderColor": "#27C04F", + "groupId": "media", + "id": "remux", + "imageURL": "https://example.test/remux.png", + "isEnabled": true, + "name": "REMUX", + "pattern": "(?i)\\bremux\\b", + "tagColor": "#27C04F", + "tagStyle": "filled", + "textColor": "#FFFFFF", + "type": "filter" + } + ], + "groups": [ + { + "color": "#96CEB4", + "id": "media", + "isExpanded": true, + "name": "Media Source" + } + ] + } + """.trimIndent(), + ) + + 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 + fun `attaches badges to every supported stream shape`() { + val rules = StreamBadgeRules( + imports = listOf( + StreamBadgeImport( + sourceUrl = "https://example.test/badges.json", + filters = listOf( + StreamBadgeFilter( + name = "WEB", + pattern = "(?i)web-dl", + imageURL = "https://example.test/web.png", + ), + StreamBadgeFilter( + name = "REMUX", + pattern = "(?i)\\bremux\\b", + imageURL = "https://example.test/remux.png", + ), + StreamBadgeFilter( + name = "PLUGIN", + pattern = "(?i)plugin-source", + imageURL = "https://example.test/plugin.png", + ), + StreamBadgeFilter( + name = "TRUEHD", + pattern = "(?i)truehd", + imageURL = "https://example.test/truehd.png", + ), + ), + ), + ), + ) + val normalUrlStream = StreamItem( + name = "Movie.2026.1080p.WEB-DL-GRP", + url = "https://example.test/movie.mp4", + addonName = "Direct", + addonId = "direct", + badges = listOf(StreamBadge(name = "EXISTING", imageURL = "https://example.test/existing.png")), + ) + val addonTorrentStream = StreamItem( + infoHash = "abcdef1234567890abcdef1234567890abcdef12", + addonName = "Addon", + addonId = "addon:test", + behaviorHints = StreamBehaviorHints(filename = "Movie.2026.2160p.REMUX-GRP.mkv"), + ) + val pluginStream = StreamItem( + title = "Plugin result", + externalUrl = "https://example.test/plugin.m3u8", + sourceName = "plugin-source", + addonName = "Plugin Source", + addonId = "plugin:test", + ) + val debridStream = StreamItem( + name = "Cached", + infoHash = "1234567890abcdef1234567890abcdef12345678", + addonName = "Addon", + addonId = "addon:test", + debridCacheStatus = StreamDebridCacheStatus( + providerId = DebridProviders.TORBOX_ID, + providerName = DebridProviders.Torbox.displayName, + state = StreamDebridCacheState.CACHED, + cachedName = "Movie.2026.TrueHD.7.1-GRP.mkv", + ), + ) + + val presented = StreamBadgePresentation.apply( + groups = listOf( + AddonStreamGroup( + addonName = "Mixed", + addonId = "mixed", + streams = listOf(normalUrlStream, addonTorrentStream, pluginStream, debridStream), + ), + ), + rules = rules, + ).single().streams + + assertEquals(listOf("EXISTING", "WEB"), presented[0].badges.map { it.name }) + assertEquals(listOf("REMUX"), presented[1].badges.map { it.name }) + assertEquals(listOf("PLUGIN"), presented[2].badges.map { it.name }) + assertEquals(listOf("TRUEHD"), presented[3].badges.map { it.name }) + } + + @Test + fun `uses only active badge import`() { + val rules = StreamBadgeRules( + imports = listOf( + StreamBadgeImport( + sourceUrl = "https://example.test/inactive.json", + isActive = false, + filters = listOf( + StreamBadgeFilter( + name = "INACTIVE", + pattern = "(?i)web-dl", + imageURL = "https://example.test/inactive.png", + ), + ), + ), + StreamBadgeImport( + sourceUrl = "https://example.test/active.json", + isActive = true, + filters = listOf( + StreamBadgeFilter( + name = "ACTIVE", + pattern = "(?i)web-dl", + imageURL = "https://example.test/active.png", + ), + ), + ), + ), + ) + + val presented = StreamBadgePresentation.apply( + groups = listOf( + AddonStreamGroup( + addonName = "Direct", + addonId = "direct", + streams = listOf( + StreamItem( + name = "Movie.2026.1080p.WEB-DL-GRP", + url = "https://example.test/movie.mp4", + addonName = "Direct", + addonId = "direct", + ), + ), + ), + ), + rules = rules, + ).single().streams.single() + + assertEquals(listOf("ACTIVE"), presented.badges.map { it.name }) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt index a2244fd1..288ffdf2 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt @@ -34,6 +34,8 @@ internal actual object PlatformLocalAccountDataCleaner { "subtitle_bottom_offset", "stream_reuse_last_link_enabled", "stream_reuse_last_link_cache_hours", + "stream_badge_rules", + "debrid_stream_badge_rules", "p2p_enabled", "enable_upload", "hide_torrent_stats", diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt index b53748f1..d11d9c64 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt @@ -28,7 +28,6 @@ actual object DebridSettingsStorage { private const val streamPreferencesKey = "debrid_stream_preferences" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" - private const val streamBadgeRulesKey = "debrid_stream_badge_rules" private fun syncKeys(): List = listOf( enabledKey, @@ -143,12 +142,6 @@ actual object DebridSettingsStorage { saveString(streamDescriptionTemplateKey, template) } - actual fun loadStreamBadgeRules(): String? = loadString(streamBadgeRulesKey) - - actual fun saveStreamBadgeRules(rules: String) { - saveString(streamBadgeRulesKey, rules) - } - private fun loadBoolean(key: String): Boolean? { val defaults = NSUserDefaults.standardUserDefaults val scopedKey = ProfileScopedKey.of(key) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt new file mode 100644 index 00000000..0fd09b71 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt @@ -0,0 +1,47 @@ +package com.nuvio.app.features.streams + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncString +import com.nuvio.app.core.sync.encodeSyncString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import platform.Foundation.NSUserDefaults + +actual object StreamBadgeSettingsStorage { + private const val streamBadgeRulesKey = "stream_badge_rules" + private const val legacyDebridStreamBadgeRulesKey = "debrid_stream_badge_rules" + private val syncKeys = listOf(streamBadgeRulesKey) + + actual fun loadStreamBadgeRules(): String? = loadString(streamBadgeRulesKey) + + actual fun saveStreamBadgeRules(rules: String) { + saveString(streamBadgeRulesKey, rules) + } + + actual fun loadLegacyDebridStreamBadgeRules(): String? = + loadString(legacyDebridStreamBadgeRulesKey) + + actual fun clearLegacyDebridStreamBadgeRules() { + NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(legacyDebridStreamBadgeRulesKey)) + } + + private fun loadString(key: String): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(key)) + + private fun saveString(key: String, value: String) { + NSUserDefaults.standardUserDefaults.setObject(value, forKey = ProfileScopedKey.of(key)) + } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadStreamBadgeRules()?.let { put(streamBadgeRulesKey, encodeSyncString(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + syncKeys.forEach { key -> + NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(key)) + } + + payload.decodeSyncString(streamBadgeRulesKey)?.let(::saveStreamBadgeRules) + } +}