mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-30 16:19:25 +00:00
Merge branch 'globalstreambadge' into cmp-rewrite
This commit is contained in:
commit
03d2c05208
34 changed files with 1142 additions and 689 deletions
8
composeApp/proguard-rules.pro
vendored
8
composeApp/proguard-rules.pro
vendored
|
|
@ -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$* { *; }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue