Merge branch 'globalstreambadge' into cmp-rewrite

This commit is contained in:
tapframe 2026-05-31 15:49:14 +05:30
commit 03d2c05208
34 changed files with 1142 additions and 689 deletions

View file

@ -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$* { *; }

View file

@ -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)

View file

@ -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",

View file

@ -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<String> =
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)

View file

@ -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)
}
}

View file

@ -418,6 +418,7 @@
<string name="compose_settings_page_plugins">Plugins</string>
<string name="compose_settings_page_poster_customization">Poster Card Style</string>
<string name="compose_settings_page_root">Settings</string>
<string name="compose_settings_page_streams">Streams</string>
<string name="compose_settings_page_supporters_contributors">Supporters &amp; Contributors</string>
<string name="compose_settings_page_tmdb_enrichment">TMDB Enrichment</string>
<string name="compose_settings_page_trakt">Trakt</string>
@ -433,6 +434,7 @@
<string name="compose_settings_root_general_section">GENERAL</string>
<string name="compose_settings_root_integrations_description">Manage available integrations</string>
<string name="compose_settings_root_notifications_description">Manage episode release alerts and send a test notification.</string>
<string name="compose_settings_root_streams_description">Stream result display and badge URL rules.</string>
<string name="compose_settings_root_switch_profile_description">Change to a different profile.</string>
<string name="compose_settings_root_switch_profile_title">Switch Profile</string>
<string name="compose_settings_root_trakt_description">Open Trakt connection screen</string>
@ -666,6 +668,10 @@
<string name="settings_debrid_description_template_description">Controls the metadata shown under each result. Leave blank to use the original result details.</string>
<string name="settings_debrid_formatter_reset_title">Reset formatting</string>
<string name="settings_debrid_formatter_reset_subtitle">Restore default result formatting.</string>
<string name="settings_stream_badges_section">Stream Badges</string>
<string name="settings_stream_badge_urls_title">Badge URLs</string>
<string name="settings_stream_badge_urls_description">Import up to %1$d stream badge JSON URLs. Each URL can be updated or deleted separately.</string>
<string name="settings_stream_badge_urls_search_description">Manage imported stream badge JSON URLs.</string>
<string name="settings_debrid_key_valid">API key validated.</string>
<string name="settings_debrid_key_invalid">Could not validate this API key.</string>
<string name="settings_mdb_add_api_key_first">Add your MDBList API key below before turning ratings on.</string>

View file

@ -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()

View file

@ -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()),

View file

@ -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)

View file

@ -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 <reified T : Enum<T>> enumValueOrDefault(value: String?, default: T): T =
runCatching { enumValueOf<T>(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<StreamBadgeRules>(value).normalized()
} catch (_: SerializationException) {
null
} catch (_: IllegalArgumentException) {
null
}
if (decodedRules?.hasImport == true) return decodedRules
val legacyRules = try {
json.decodeFromString<LegacyStreamBadgeRules>(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<StreamBadgeFilter> = emptyList(),
val groups: List<StreamBadgeGroup> = emptyList(),
) {
fun toBadgeRules(): StreamBadgeRules =
StreamBadgeRules(
imports = listOf(
StreamBadgeImport(
sourceUrl = sourceUrl,
filters = filters,
groups = groups,
),
),
)
}
internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences =
copy(
maxResults = normalizeDebridStreamMaxResults(maxResults),

View file

@ -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)
}

View file

@ -13,11 +13,9 @@ class DebridStreamFormatter(
fun format(
stream: StreamItem,
settings: DebridSettings,
compiledBadgeFilters: List<CompiledStreamBadgeFilter> =
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,
)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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()

View file

@ -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<DebridTemplateField?>(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<String?>(null) }
var isImporting by rememberSaveable { mutableStateOf(false) }
var previewImport by remember { mutableStateOf<StreamBadgeImport?>(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<StreamBadgeFilter>,
)
private fun badgePreviewSections(import: StreamBadgeImport): List<BadgePreviewSection> {
val filters = import.filters.filter { it.imageURL.isNotBlank() }
if (filters.isEmpty()) return emptyList()
val filtersByGroupId = filters.groupBy { it.groupId }
val usedGroupIds = mutableSetOf<String>()
val sections = mutableListOf<BadgePreviewSection>()
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,

View file

@ -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,

View file

@ -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),

View file

@ -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,

View file

@ -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,

View file

@ -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<String?>(null) }
var isImporting by rememberSaveable { mutableStateOf(false) }
var previewImport by remember { mutableStateOf<StreamBadgeImport?>(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<StreamBadgeFilter>,
)
private fun badgePreviewSections(import: StreamBadgeImport): List<BadgePreviewSection> {
val filters = import.filters.filter { it.imageURL.isNotBlank() }
if (filters.isEmpty()) return emptyList()
val filtersByGroupId = filters.groupBy { it.groupId }
val usedGroupIds = mutableSetOf<String>()
val sections = mutableListOf<BadgePreviewSection>()
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
}

View file

@ -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<AddonStreamWarmupTarget>,
)

View file

@ -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)

View file

@ -0,0 +1,29 @@
package com.nuvio.app.features.streams
object StreamBadgePresentation {
fun apply(groups: List<AddonStreamGroup>, rules: StreamBadgeRules): List<AddonStreamGroup> {
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<StreamBadge>, matched: List<StreamBadge>): List<StreamBadge> {
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()
}

View file

@ -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<CompiledStreamBadgeFilter> {
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<String> {
fun badgeMatchCandidates(stream: StreamItem): List<String> {
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(

View file

@ -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<StreamBadgeRules> = _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<StreamBadgeRules>(value).normalized()
} catch (_: SerializationException) {
null
} catch (_: IllegalArgumentException) {
null
}
if (decodedRules?.hasImport == true) return decodedRules
val legacyRules = try {
json.decodeFromString<LegacyStreamBadgeRules>(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<StreamBadgeFilter> = emptyList(),
val groups: List<StreamBadgeGroup> = emptyList(),
) {
fun toBadgeRules(): StreamBadgeRules =
StreamBadgeRules(
imports = listOf(
StreamBadgeImport(
sourceUrl = sourceUrl,
filters = filters,
groups = groups,
),
),
)
}

View file

@ -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)
}

View file

@ -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) {

View file

@ -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,
)

View file

@ -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(

View file

@ -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 })
}
}

View file

@ -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",

View file

@ -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<String> =
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)

View file

@ -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)
}
}