diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
index e4e5c4d6..94036653 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
@@ -44,6 +44,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.BingeGroupCacheStorage
import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentStorage
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
import com.nuvio.app.features.watchprogress.ResumePromptStorage
@@ -87,6 +88,7 @@ class MainActivity : AppCompatActivity() {
EpisodeReleaseNotificationsStorage.initialize(applicationContext)
WatchProgressStorage.initialize(applicationContext)
StreamLinkCacheStorage.initialize(applicationContext)
+ BingeGroupCacheStorage.initialize(applicationContext)
PluginStorage.initialize(applicationContext)
CollectionMobileSettingsStorage.initialize(applicationContext)
CollectionStorage.initialize(applicationContext)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt
index 2ae1bccc..d1ff44e5 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt
@@ -19,6 +19,13 @@ actual object DebridSettingsStorage {
private const val torboxApiKeyKey = "debrid_torbox_api_key"
private const val realDebridApiKeyKey = "debrid_real_debrid_api_key"
private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit"
+ private const val streamMaxResultsKey = "debrid_stream_max_results"
+ private const val streamSortModeKey = "debrid_stream_sort_mode"
+ private const val streamMinimumQualityKey = "debrid_stream_minimum_quality"
+ private const val streamDolbyVisionFilterKey = "debrid_stream_dolby_vision_filter"
+ private const val streamHdrFilterKey = "debrid_stream_hdr_filter"
+ private const val streamCodecFilterKey = "debrid_stream_codec_filter"
+ private const val streamPreferencesKey = "debrid_stream_preferences"
private const val streamNameTemplateKey = "debrid_stream_name_template"
private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
private val syncKeys = listOf(
@@ -26,6 +33,13 @@ actual object DebridSettingsStorage {
torboxApiKeyKey,
realDebridApiKeyKey,
instantPlaybackPreparationLimitKey,
+ streamMaxResultsKey,
+ streamSortModeKey,
+ streamMinimumQualityKey,
+ streamDolbyVisionFilterKey,
+ streamHdrFilterKey,
+ streamCodecFilterKey,
+ streamPreferencesKey,
streamNameTemplateKey,
streamDescriptionTemplateKey,
)
@@ -60,6 +74,48 @@ actual object DebridSettingsStorage {
saveInt(instantPlaybackPreparationLimitKey, limit)
}
+ actual fun loadStreamMaxResults(): Int? = loadInt(streamMaxResultsKey)
+
+ actual fun saveStreamMaxResults(maxResults: Int) {
+ saveInt(streamMaxResultsKey, maxResults)
+ }
+
+ actual fun loadStreamSortMode(): String? = loadString(streamSortModeKey)
+
+ actual fun saveStreamSortMode(mode: String) {
+ saveString(streamSortModeKey, mode)
+ }
+
+ actual fun loadStreamMinimumQuality(): String? = loadString(streamMinimumQualityKey)
+
+ actual fun saveStreamMinimumQuality(quality: String) {
+ saveString(streamMinimumQualityKey, quality)
+ }
+
+ actual fun loadStreamDolbyVisionFilter(): String? = loadString(streamDolbyVisionFilterKey)
+
+ actual fun saveStreamDolbyVisionFilter(filter: String) {
+ saveString(streamDolbyVisionFilterKey, filter)
+ }
+
+ actual fun loadStreamHdrFilter(): String? = loadString(streamHdrFilterKey)
+
+ actual fun saveStreamHdrFilter(filter: String) {
+ saveString(streamHdrFilterKey, filter)
+ }
+
+ actual fun loadStreamCodecFilter(): String? = loadString(streamCodecFilterKey)
+
+ actual fun saveStreamCodecFilter(filter: String) {
+ saveString(streamCodecFilterKey, filter)
+ }
+
+ actual fun loadStreamPreferences(): String? = loadString(streamPreferencesKey)
+
+ actual fun saveStreamPreferences(preferences: String) {
+ saveString(streamPreferencesKey, preferences)
+ }
+
actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey)
actual fun saveStreamNameTemplate(template: String) {
@@ -121,6 +177,13 @@ actual object DebridSettingsStorage {
loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) }
loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) }
loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) }
+ loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) }
+ loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) }
+ loadStreamMinimumQuality()?.let { put(streamMinimumQualityKey, encodeSyncString(it)) }
+ loadStreamDolbyVisionFilter()?.let { put(streamDolbyVisionFilterKey, encodeSyncString(it)) }
+ loadStreamHdrFilter()?.let { put(streamHdrFilterKey, encodeSyncString(it)) }
+ loadStreamCodecFilter()?.let { put(streamCodecFilterKey, encodeSyncString(it)) }
+ loadStreamPreferences()?.let { put(streamPreferencesKey, encodeSyncString(it)) }
loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) }
loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) }
}
@@ -134,6 +197,13 @@ actual object DebridSettingsStorage {
payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey)
payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey)
payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit)
+ payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults)
+ payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode)
+ payload.decodeSyncString(streamMinimumQualityKey)?.let(::saveStreamMinimumQuality)
+ payload.decodeSyncString(streamDolbyVisionFilterKey)?.let(::saveStreamDolbyVisionFilter)
+ payload.decodeSyncString(streamHdrFilterKey)?.let(::saveStreamHdrFilter)
+ payload.decodeSyncString(streamCodecFilterKey)?.let(::saveStreamCodecFilter)
+ payload.decodeSyncString(streamPreferencesKey)?.let(::saveStreamPreferences)
payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate)
payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate)
}
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
index 5cb861a8..743c27f8 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
@@ -51,11 +51,26 @@ actual object PlayerSettingsStorage {
private const val introSubmitEnabledKey = "intro_submit_enabled"
private const val streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled"
private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group"
+ private const val streamAutoPlayReuseBingeGroupKey = "stream_auto_play_reuse_binge_group"
private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode"
private const val nextEpisodeThresholdPercentKey = "next_episode_threshold_percent_v2"
private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2"
private const val useLibassKey = "use_libass"
private const val libassRenderTypeKey = "libass_render_type"
+ private const val iosVideoOutputPresetKey = "ios_video_output_preset"
+ private const val iosToneMappingModeKey = "ios_tone_mapping_mode"
+ private const val iosTargetPrimariesKey = "ios_target_primaries"
+ private const val iosTargetTransferKey = "ios_target_transfer"
+ private const val iosHardwareDecoderModeKey = "ios_hardware_decoder_mode"
+ private const val iosExtendedDynamicRangeEnabledKey = "ios_extended_dynamic_range_enabled"
+ private const val iosTargetColorspaceHintEnabledKey = "ios_target_colorspace_hint_enabled"
+ private const val iosHdrComputePeakEnabledKey = "ios_hdr_compute_peak_enabled"
+ private const val iosDebandEnabledKey = "ios_deband_enabled"
+ private const val iosInterpolationEnabledKey = "ios_interpolation_enabled"
+ private const val iosBrightnessKey = "ios_brightness"
+ private const val iosContrastKey = "ios_contrast"
+ private const val iosSaturationKey = "ios_saturation"
+ private const val iosGammaKey = "ios_gamma"
private val syncKeys = listOf(
showLoadingOverlayKey,
resizeModeKey,
@@ -87,11 +102,26 @@ actual object PlayerSettingsStorage {
animeSkipClientIdKey,
streamAutoPlayNextEpisodeEnabledKey,
streamAutoPlayPreferBingeGroupKey,
+ streamAutoPlayReuseBingeGroupKey,
nextEpisodeThresholdModeKey,
nextEpisodeThresholdPercentKey,
nextEpisodeThresholdMinutesBeforeEndKey,
useLibassKey,
libassRenderTypeKey,
+ iosVideoOutputPresetKey,
+ iosToneMappingModeKey,
+ iosTargetPrimariesKey,
+ iosTargetTransferKey,
+ iosHardwareDecoderModeKey,
+ iosExtendedDynamicRangeEnabledKey,
+ iosTargetColorspaceHintEnabledKey,
+ iosHdrComputePeakEnabledKey,
+ iosDebandEnabledKey,
+ iosInterpolationEnabledKey,
+ iosBrightnessKey,
+ iosContrastKey,
+ iosSaturationKey,
+ iosGammaKey,
)
private var preferences: SharedPreferences? = null
@@ -581,6 +611,23 @@ actual object PlayerSettingsStorage {
?.apply()
}
+ actual fun loadStreamAutoPlayReuseBingeGroup(): Boolean? =
+ preferences?.let { sharedPreferences ->
+ val key = ProfileScopedKey.of(streamAutoPlayReuseBingeGroupKey)
+ if (sharedPreferences.contains(key)) {
+ sharedPreferences.getBoolean(key, true)
+ } else {
+ null
+ }
+ }
+
+ actual fun saveStreamAutoPlayReuseBingeGroup(enabled: Boolean) {
+ preferences
+ ?.edit()
+ ?.putBoolean(ProfileScopedKey.of(streamAutoPlayReuseBingeGroupKey), enabled)
+ ?.apply()
+ }
+
actual fun loadNextEpisodeThresholdMode(): String? =
preferences?.getString(ProfileScopedKey.of(nextEpisodeThresholdModeKey), null)
@@ -652,6 +699,120 @@ actual object PlayerSettingsStorage {
?.apply()
}
+ actual fun loadIosVideoOutputPreset(): String? =
+ preferences?.getString(ProfileScopedKey.of(iosVideoOutputPresetKey), null)
+
+ actual fun saveIosVideoOutputPreset(preset: String) {
+ preferences?.edit()?.putString(ProfileScopedKey.of(iosVideoOutputPresetKey), preset)?.apply()
+ }
+
+ actual fun loadIosToneMappingMode(): String? =
+ preferences?.getString(ProfileScopedKey.of(iosToneMappingModeKey), null)
+
+ actual fun saveIosToneMappingMode(mode: String) {
+ preferences?.edit()?.putString(ProfileScopedKey.of(iosToneMappingModeKey), mode)?.apply()
+ }
+
+ actual fun loadIosTargetPrimaries(): String? =
+ preferences?.getString(ProfileScopedKey.of(iosTargetPrimariesKey), null)
+
+ actual fun saveIosTargetPrimaries(primaries: String) {
+ preferences?.edit()?.putString(ProfileScopedKey.of(iosTargetPrimariesKey), primaries)?.apply()
+ }
+
+ actual fun loadIosTargetTransfer(): String? =
+ preferences?.getString(ProfileScopedKey.of(iosTargetTransferKey), null)
+
+ actual fun saveIosTargetTransfer(transfer: String) {
+ preferences?.edit()?.putString(ProfileScopedKey.of(iosTargetTransferKey), transfer)?.apply()
+ }
+
+ actual fun loadIosHardwareDecoderMode(): String? =
+ preferences?.getString(ProfileScopedKey.of(iosHardwareDecoderModeKey), null)
+
+ actual fun saveIosHardwareDecoderMode(mode: String) {
+ preferences?.edit()?.putString(ProfileScopedKey.of(iosHardwareDecoderModeKey), mode)?.apply()
+ }
+
+ private fun loadIosBoolean(keyBase: String, defaultValue: Boolean): Boolean? =
+ preferences?.let { sharedPreferences ->
+ val key = ProfileScopedKey.of(keyBase)
+ if (sharedPreferences.contains(key)) sharedPreferences.getBoolean(key, defaultValue) else null
+ }
+
+ private fun saveIosBoolean(keyBase: String, enabled: Boolean) {
+ preferences?.edit()?.putBoolean(ProfileScopedKey.of(keyBase), enabled)?.apply()
+ }
+
+ private fun loadIosInt(keyBase: String): Int? =
+ preferences?.let { sharedPreferences ->
+ val key = ProfileScopedKey.of(keyBase)
+ if (sharedPreferences.contains(key)) sharedPreferences.getInt(key, 0) else null
+ }
+
+ private fun saveIosInt(keyBase: String, value: Int) {
+ preferences?.edit()?.putInt(ProfileScopedKey.of(keyBase), value)?.apply()
+ }
+
+ actual fun loadIosExtendedDynamicRangeEnabled(): Boolean? =
+ loadIosBoolean(iosExtendedDynamicRangeEnabledKey, true)
+
+ actual fun saveIosExtendedDynamicRangeEnabled(enabled: Boolean) {
+ saveIosBoolean(iosExtendedDynamicRangeEnabledKey, enabled)
+ }
+
+ actual fun loadIosTargetColorspaceHintEnabled(): Boolean? =
+ loadIosBoolean(iosTargetColorspaceHintEnabledKey, true)
+
+ actual fun saveIosTargetColorspaceHintEnabled(enabled: Boolean) {
+ saveIosBoolean(iosTargetColorspaceHintEnabledKey, enabled)
+ }
+
+ actual fun loadIosHdrComputePeakEnabled(): Boolean? =
+ loadIosBoolean(iosHdrComputePeakEnabledKey, true)
+
+ actual fun saveIosHdrComputePeakEnabled(enabled: Boolean) {
+ saveIosBoolean(iosHdrComputePeakEnabledKey, enabled)
+ }
+
+ actual fun loadIosDebandEnabled(): Boolean? =
+ loadIosBoolean(iosDebandEnabledKey, false)
+
+ actual fun saveIosDebandEnabled(enabled: Boolean) {
+ saveIosBoolean(iosDebandEnabledKey, enabled)
+ }
+
+ actual fun loadIosInterpolationEnabled(): Boolean? =
+ loadIosBoolean(iosInterpolationEnabledKey, false)
+
+ actual fun saveIosInterpolationEnabled(enabled: Boolean) {
+ saveIosBoolean(iosInterpolationEnabledKey, enabled)
+ }
+
+ actual fun loadIosBrightness(): Int? = loadIosInt(iosBrightnessKey)
+
+ actual fun saveIosBrightness(value: Int) {
+ saveIosInt(iosBrightnessKey, value)
+ }
+
+ actual fun loadIosContrast(): Int? = loadIosInt(iosContrastKey)
+
+ actual fun saveIosContrast(value: Int) {
+ saveIosInt(iosContrastKey, value)
+ }
+
+ actual fun loadIosSaturation(): Int? = loadIosInt(iosSaturationKey)
+
+ actual fun saveIosSaturation(value: Int) {
+ saveIosInt(iosSaturationKey, value)
+ }
+
+ actual fun loadIosGamma(): Int? = loadIosInt(iosGammaKey)
+
+ actual fun saveIosGamma(value: Int) {
+ saveIosInt(iosGammaKey, value)
+ }
+
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) }
loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) }
@@ -683,11 +844,26 @@ actual object PlayerSettingsStorage {
loadAnimeSkipClientId()?.let { put(animeSkipClientIdKey, encodeSyncString(it)) }
loadStreamAutoPlayNextEpisodeEnabled()?.let { put(streamAutoPlayNextEpisodeEnabledKey, encodeSyncBoolean(it)) }
loadStreamAutoPlayPreferBingeGroup()?.let { put(streamAutoPlayPreferBingeGroupKey, encodeSyncBoolean(it)) }
+ loadStreamAutoPlayReuseBingeGroup()?.let { put(streamAutoPlayReuseBingeGroupKey, encodeSyncBoolean(it)) }
loadNextEpisodeThresholdMode()?.let { put(nextEpisodeThresholdModeKey, encodeSyncString(it)) }
loadNextEpisodeThresholdPercent()?.let { put(nextEpisodeThresholdPercentKey, encodeSyncFloat(it)) }
loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) }
loadUseLibass()?.let { put(useLibassKey, encodeSyncBoolean(it)) }
loadLibassRenderType()?.let { put(libassRenderTypeKey, encodeSyncString(it)) }
+ loadIosVideoOutputPreset()?.let { put(iosVideoOutputPresetKey, encodeSyncString(it)) }
+ loadIosToneMappingMode()?.let { put(iosToneMappingModeKey, encodeSyncString(it)) }
+ loadIosTargetPrimaries()?.let { put(iosTargetPrimariesKey, encodeSyncString(it)) }
+ loadIosTargetTransfer()?.let { put(iosTargetTransferKey, encodeSyncString(it)) }
+ loadIosHardwareDecoderMode()?.let { put(iosHardwareDecoderModeKey, encodeSyncString(it)) }
+ loadIosExtendedDynamicRangeEnabled()?.let { put(iosExtendedDynamicRangeEnabledKey, encodeSyncBoolean(it)) }
+ loadIosTargetColorspaceHintEnabled()?.let { put(iosTargetColorspaceHintEnabledKey, encodeSyncBoolean(it)) }
+ loadIosHdrComputePeakEnabled()?.let { put(iosHdrComputePeakEnabledKey, encodeSyncBoolean(it)) }
+ loadIosDebandEnabled()?.let { put(iosDebandEnabledKey, encodeSyncBoolean(it)) }
+ loadIosInterpolationEnabled()?.let { put(iosInterpolationEnabledKey, encodeSyncBoolean(it)) }
+ loadIosBrightness()?.let { put(iosBrightnessKey, encodeSyncInt(it)) }
+ loadIosContrast()?.let { put(iosContrastKey, encodeSyncInt(it)) }
+ loadIosSaturation()?.let { put(iosSaturationKey, encodeSyncInt(it)) }
+ loadIosGamma()?.let { put(iosGammaKey, encodeSyncInt(it)) }
}
actual fun replaceFromSyncPayload(payload: JsonObject) {
@@ -727,10 +903,25 @@ actual object PlayerSettingsStorage {
payload.decodeSyncBoolean(introSubmitEnabledKey)?.let(::saveIntroSubmitEnabled)
payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled)
payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup)
+ payload.decodeSyncBoolean(streamAutoPlayReuseBingeGroupKey)?.let(::saveStreamAutoPlayReuseBingeGroup)
payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode)
payload.decodeSyncFloat(nextEpisodeThresholdPercentKey)?.let(::saveNextEpisodeThresholdPercent)
payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd)
payload.decodeSyncBoolean(useLibassKey)?.let(::saveUseLibass)
payload.decodeSyncString(libassRenderTypeKey)?.let(::saveLibassRenderType)
+ payload.decodeSyncString(iosVideoOutputPresetKey)?.let(::saveIosVideoOutputPreset)
+ payload.decodeSyncString(iosToneMappingModeKey)?.let(::saveIosToneMappingMode)
+ payload.decodeSyncString(iosTargetPrimariesKey)?.let(::saveIosTargetPrimaries)
+ payload.decodeSyncString(iosTargetTransferKey)?.let(::saveIosTargetTransfer)
+ payload.decodeSyncString(iosHardwareDecoderModeKey)?.let(::saveIosHardwareDecoderMode)
+ payload.decodeSyncBoolean(iosExtendedDynamicRangeEnabledKey)?.let(::saveIosExtendedDynamicRangeEnabled)
+ payload.decodeSyncBoolean(iosTargetColorspaceHintEnabledKey)?.let(::saveIosTargetColorspaceHintEnabled)
+ payload.decodeSyncBoolean(iosHdrComputePeakEnabledKey)?.let(::saveIosHdrComputePeakEnabled)
+ payload.decodeSyncBoolean(iosDebandEnabledKey)?.let(::saveIosDebandEnabled)
+ payload.decodeSyncBoolean(iosInterpolationEnabledKey)?.let(::saveIosInterpolationEnabled)
+ payload.decodeSyncInt(iosBrightnessKey)?.let(::saveIosBrightness)
+ payload.decodeSyncInt(iosContrastKey)?.let(::saveIosContrast)
+ payload.decodeSyncInt(iosSaturationKey)?.let(::saveIosSaturation)
+ payload.decodeSyncInt(iosGammaKey)?.let(::saveIosGamma)
}
}
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.android.kt
new file mode 100644
index 00000000..253c3594
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.android.kt
@@ -0,0 +1,32 @@
+package com.nuvio.app.features.streams
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.nuvio.app.core.storage.ProfileScopedKey
+
+actual object BingeGroupCacheStorage {
+ private const val preferencesName = "nuvio_binge_group_cache"
+
+ private var preferences: SharedPreferences? = null
+
+ fun initialize(context: Context) {
+ preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
+ }
+
+ actual fun load(hashedKey: String): String? =
+ preferences?.getString(ProfileScopedKey.of(hashedKey), null)
+
+ actual fun save(hashedKey: String, value: String) {
+ preferences
+ ?.edit()
+ ?.putString(ProfileScopedKey.of(hashedKey), value)
+ ?.apply()
+ }
+
+ actual fun remove(hashedKey: String) {
+ preferences
+ ?.edit()
+ ?.remove(ProfileScopedKey.of(hashedKey))
+ ?.apply()
+ }
+}
diff --git a/composeApp/src/commonMain/composeResources/values-nb/strings.xml b/composeApp/src/commonMain/composeResources/values-nb/strings.xml
index 4a1efbc3..06b43610 100644
--- a/composeApp/src/commonMain/composeResources/values-nb/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-nb/strings.xml
@@ -1331,4 +1331,4 @@
KB
MB
GB
-
\ No newline at end of file
+
diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml
index 00af8afd..330c2cd6 100644
--- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml
@@ -1,4 +1,5 @@
+ Źródła danych, podziękowania i licencje platformy
Osoby wspierające i współtworzące projekt
Wstecz
Anuluj
@@ -17,6 +18,8 @@
Wznów
Ponów
Zapisz
+ Zapisywanie…
+ Sprawdź
Instalowanie
Dodatki
Aktywny
@@ -110,29 +113,38 @@
Produkcja
Stacja
Kolekcja
+ Osoba
+ Reżyser
Niestandardowy
Wybierz gotowe źródło. Możesz je edytować lub usunąć po dodaniu.
Wklej publiczny URL listy TMDB lub sam numer z URL.
Wyszukaj po nazwie studia lub wklej ID/URL firmy TMDB i dodaj bezpośrednio.
Wprowadź ID stacji. Popularne stacje są dostępne w szablonach i filtrach.
Wyszukaj nazwę kolekcji filmów lub wklej ID kolekcji z TMDB.
+ Wprowadź ID osoby TMDB lub URL, aby zbudować wiersz z filmografii aktora.
+ Wprowadź ID osoby TMDB lub URL, aby zbudować wiersz z filmografii reżysera.
Zbuduj dynamiczny wiersz TMDB z opcjonalnymi filtrami. Zostaw pola puste, gdy nie potrzebujesz danego filtra.
Publiczna lista TMDB
ID stacji
ID kolekcji
+ ID osoby
Nazwa firmy produkcyjnej, ID lub URL
ID lub URL TMDB
https://www.themoviedb.org/list/8504994 lub 8504994
213 dla Netflix, 49 dla HBO, 2739 dla Disney+
10 dla kolekcji Star Wars
Marvel Studios, 420 lub URL firmy
+ 31 dla Toma Hanksa lub URL osoby
Przykłady: Marvel Studios, 420 lub https://www.themoviedb.org/company/420.
Przykład: Star Wars Collection, Harry Potter Collection lub URL kolekcji.
Przykładowe ID: Netflix 213, HBO 49, Disney+ 2739.
Przykład: https://www.themoviedb.org/list/8504994 lub 8504994.
+ Przykład: https://www.themoviedb.org/person/31-tom-hanks lub 31.
Wyświetlany tytuł
Wyświetlany jako nazwa wiersza/karty. Jeśli pusty, Nuvio utworzy go ze źródła.
Filmy Marvela, Oryginały Netflix, Pixar
+ Filmy Toma Hanksa, Ulubieni aktorzy
+ Filmy Christophera Nolana, Ulubieni reżyserzy
Najlepsze filmy akcji, Koreańskie dramy, Animacja 2024
Wyniki wyszukiwania
Kolekcja TMDB
@@ -179,6 +191,27 @@
Szablony
Szukaj
Dodaj źródło
+ Dodaj listę Trakt
+ Edytuj listę Trakt
+ Listy Trakt
+ Lista Trakt
+ Szukaj tytułu, URL Trakt lub ID listy
+ Użyj publicznego URL listy Trakt, numerycznego ID listy lub wyszukaj po nazwie.
+ Weekendowe filmy, Laureaci nagród
+ Wyniki wyszukiwania
+ Popularne teraz
+ Popularne listy
+ Kierunek
+ Rosnąco
+ Malejąco
+ Kolejność listy
+ Ostatnio dodane
+ Tytuł
+ Data premiery
+ Czas trwania
+ Popularne
+ Procent
+ Głosy
Akcja
Przygodowy
Animacja
@@ -212,13 +245,29 @@
Disney+
Prime Video
Hulu
+ Oryginalna
Popularne
Najwyżej oceniane
Ostatnie
+ Najczęściej głosowane
+ Region dostępności
+ Kod kraju ISO 3166-1, w którym tytuł jest dostępny. Przykład: US, GB.
+ Popularne regiony dostępności
+ ID platform streamingowych
+ Użyj ID platform TMDB. Oddziel wiele przecinkami dla AND lub pionowymi kreskami dla OR.
+ 8|337|350
+ Popularne platformy streamingowe
+ Netflix
+ Prime Video
+ Disney+
+ Apple TV+
+ Hulu
Lista TMDB
Kolekcja filmów TMDB
Produkcja
Stacja
+ Osoba
+ Reżyser
TMDB Discover
Utwórz jedną, aby uporządkować katalogi.
Brak kolekcji
@@ -331,8 +380,10 @@
Wygląd
Treści i odkrywanie
Kontynuuj oglądanie
+ Debrid
Ekran główny
Integracje
+ Licencje i atrybucje
Oceny MDBList
Ekran metadanych
Powiadomienia
@@ -358,6 +409,31 @@
Przełącz na inny profil.
Przełącz profil
Połącz Trakt, synchronizuj listy obserwowanych i zapisuj tytuły bezpośrednio w Trakt.
+ Nie znaleziono ustawień.
+ Szukaj ustawień...
+ WYNIKI
+ LICENCJA APLIKACJI
+ DANE I USŁUGI
+ LICENCJA ODTWARZANIA
+ Nuvio Mobile
+ Kod źródłowy i warunki licencji są dostępne w repozytorium projektu.
+ Licencjonowany na podstawie GNU General Public License v3.0.
+ The Movie Database (TMDB)
+ Nuvio korzysta z API TMDB do metadanych filmów i seriali, grafik, zwiastunów, obsady, szczegółów produkcji, kolekcji i rekomendacji. Ten produkt korzysta z API TMDB, ale nie jest wspierany ani certyfikowany przez TMDB.
+ Niekomercyjne zbiory danych IMDb
+ Nuvio korzysta z niekomercyjnych zbiorów danych IMDb, w tym title.ratings.tsv.gz, do ocen i liczby głosów IMDb. Informacje dzięki uprzejmości IMDb (https://www.imdb.com). Wykorzystywane za zgodą. Dane IMDb służą do użytku osobistego i niekomercyjnego zgodnie z warunkami IMDb.
+ Trakt
+ Nuvio łączy się z Trakt w celu uwierzytelniania konta, historii oglądania, synchronizacji postępu, danych biblioteki, ocen, list i komentarzy. Nuvio nie jest powiązane z Trakt ani przez nie wspierane.
+ MDBList
+ Nuvio korzysta z MDBList do ocen i danych zewnętrznych dostawców ocen. Nuvio nie jest powiązane z MDBList ani przez nie wspierane.
+ IntroDB
+ Nuvio korzysta z API IntroDB do dostarczanych przez społeczność znaczników intro, podsumowań, napisów końcowych i podglądów używanych przez kontrolki pomijania. Nuvio nie jest powiązane z IntroDB ani przez nie wspierane.
+ MPVKit
+ Używany do odtwarzania w wersjach na iOS.
+ Kod źródłowy MPVKit jest licencjonowany na podstawie LGPL v3.0. Pakiety MPVKit, w tym biblioteki libmpv i FFmpeg, są również licencjonowane na podstawie LGPL v3.0.
+ AndroidX Media3 ExoPlayer 1.8.0
+ Używany do odtwarzania w wersjach na Androida.
+ Licencjonowany na podstawie Apache License, wersja 2.0.
Ładowanie list Trakt…
Wybierz, gdzie zapisać ten tytuł w Trakt
Wesprzyj
@@ -416,6 +492,8 @@
Język aplikacji
Wybierz język
Pokaż, ukryj i stylizuj półkę Kontynuuj oglądanie.
+ Liquid Glass
+ Użyj natywnego paska kart iPhone na iOS 26 i nowszych. Szybkie przełączanie profili z paska kart jest niedostępne, gdy ta opcja jest włączona.
Dostosuj szerokość i zaokrąglenie rogów kart plakatów.
WYŚWIETLANIE
EKRAN GŁÓWNY
@@ -442,9 +520,14 @@
%1$d z %2$d wybranych
Pokaż Hero
Wyświetl wyróżnioną karuzelę hero na górze ekranu głównego. Wybierz do 2 katalogów źródłowych poniżej.
+ Ukryj niewydane treści
+ Ukryj filmy i seriale, które nie zostały jeszcze wydane.
+ Ukryj podkreślenie katalogu
+ Usuń linię akcentu pod tytułami katalogów i kolekcji w całej aplikacji.
%1$d z %2$d katalogów widocznych • %3$d źródeł hero wybranych
Otwórz katalog tylko wtedy, gdy chcesz zmienić jego nazwę lub kolejność.
Widoczne
+ Ukryj wartość
Odtwarzacz, napisy i automatyczne odtwarzanie
Zaokrąglenie karty
STYL KARTY PLAKATU
@@ -469,8 +552,19 @@
Gęsty
Duży
Standardowy
+ Pokaż wartość
Pokaż okno kontynuowania od miejsca, w którym skończyłeś, po otwarciu aplikacji po wyjściu z odtwarzacza.
Monit o wznowienie przy uruchomieniu
+ Rozmyj miniatury następnych odcinków w Kontynuuj oglądanie, aby uniknąć spoilerów.
+ Rozmyj nieobejrzane w Kontynuuj oglądanie
+ Uwzględnij nadchodzące odcinki w Kontynuuj oglądanie przed ich emisją.
+ Pokaż niewyemitowane następne odcinki
+ KOLEJNOŚĆ SORTOWANIA
+ Kolejność sortowania
+ Domyślna
+ Sortuj wszystkie elementy według czasu
+ Styl streamingowy
+ Wydane najpierw, nadchodzące na końcu
STYL KARTY
PRZY URUCHOMIENIU
ZACHOWANIE NASTĘPNEGO
@@ -483,6 +577,8 @@
Pozioma karta z informacjami
Gdy włączone, Następny zawsze kontynuuje od najdalej obejrzanego odcinka. Gdy wyłączone, kontynuuje od ostatnio obejrzanego. Przydatne przy ponownym oglądaniu wcześniejszych odcinków.
Następny od najdalszego odcinka
+ Preferuj miniatury odcinków, gdy są dostępne.
+ Preferuj miniatury odcinków w Kontynuuj oglądanie
EKRAN GŁÓWNY
ŹRÓDŁA
Instaluj, usuwaj, odświeżaj i sortuj źródła treści.
@@ -493,6 +589,34 @@
INTEGRACJE
Wzbogać strony szczegółów grafikami TMDB, obsadą, metadanymi odcinków i nie tylko.
Dodaj oceny IMDb, Rotten Tomatoes, Metacritic i inne zewnętrzne oceny do stron szczegółów.
+ Eksperymentalne źródła z kont chmurowych
+ Debrid
+ Obsługa Debrid jest eksperymentalna i może zostać zachowana, zmieniona lub usunięta w przyszłości.
+ Włącz źródła
+ Pokaż odtwarzalne wyniki z połączonych kont.
+ Najpierw dodaj klucz API.
+ Konto
+ Połącz swoje konto Torbox.
+ Klucz API Torbox
+ Wprowadź swój klucz API Torbox.
+ Wprowadź klucz API Torbox
+ Nie ustawiono
+ Natychmiastowe odtwarzanie
+ Przygotuj linki
+ Rozwiąż pierwsze źródła przed rozpoczęciem odtwarzania.
+ Źródła do przygotowania
+ Używaj niższej liczby, gdy to możliwe. Usługi Debrid mogą ograniczać liczbę linków rozwiązywanych w danym okresie. Otwarcie filmu lub odcinka może się wliczać do tych limitów, nawet jeśli nie naciśniesz Odtwórz, ponieważ linki są przygotowywane z wyprzedzeniem.
+ 1 źródło
+ %1$d źródeł
+ Formatowanie
+ Szablon nazwy
+ Kontroluje sposób wyświetlania nazw źródeł.
+ Szablon opisu
+ Kontroluje metadane wyświetlane pod każdym źródłem.
+ Resetuj formatowanie
+ Przywróć domyślne formatowanie źródeł.
+ Klucz API zweryfikowany.
+ Nie udało się zweryfikować tego klucza API.
Dodaj klucz API MDBList poniżej przed włączeniem ocen.
Pobierz klucz z https://mdblist.com/preferences i wklej go tutaj.
Klucz API
@@ -522,6 +646,8 @@
Karty ze szczegółami na pierwszym planie
Odcinki
Sezony i lista odcinków dla seriali.
+ Rozmyj nieobejrzane odcinki
+ Rozmyj miniatury odcinków do momentu obejrzenia, aby uniknąć spoilerów.
Grupa %1$d
Podobne
Wiersz rekomendacji.
@@ -588,6 +714,10 @@
Anime Skip
ID klienta AnimeSkip
Wprowadź ID klienta API AnimeSkip. Pobierz je na anime-skip.com.
+ Włącz przesyłanie intro
+ Pokaż przycisk do przesyłania znaczników intro/outro do bazy społeczności.
+ Klucz API IntroDB
+ Wprowadź klucz API IntroDB, aby przesyłać znaczniki czasowe. Wymagany do przesyłania.
Wyszukuj również w AnimeSkip znaczniki pomijania (wymaga ID klienta).
Automatyczne odtwarzanie następnego odcinka
Automatycznie znajdź i odtwórz następny odcinek po osiągnięciu progu.
@@ -603,6 +733,11 @@
%1$d godzin
Włącz libass
Użyj libass do renderowania napisów ASS/SSA zamiast domyślnego renderera.
+ Zewnętrzny odtwarzacz
+ Aplikacja zewnętrznego odtwarzacza
+ Otwórz nowe odtwarzanie w domyślnej aplikacji wideo Androida lub selektorze systemowym.
+ Otwórz nowe odtwarzanie w wybranym zainstalowanym odtwarzaczu.
+ Brak zainstalowanych obsługiwanych zewnętrznych odtwarzaczy
Prędkość przy przytrzymaniu
Przytrzymaj, aby przyspieszyć
Przytrzymaj dowolne miejsce na powierzchni odtwarzacza, aby tymczasowo zwiększyć prędkość odtwarzania.
@@ -621,6 +756,8 @@
Brak
Preferuj grupę binge
Przy automatycznym odtwarzaniu preferuj strumień z tej samej grupy binge co bieżący.
+ Ponownie użyj grupy binge
+ Zapamiętaj i ponownie użyj ostatniej grupy binge między sesjami (Kontynuuj oglądanie, Szczegóły itp.).
Preferowany język audio
Preferowany język napisów
Szablony
@@ -744,6 +881,28 @@
Otwórz logowanie Trakt
Twoje akcje zapisywania mogą teraz celować w listę obserwowanych i osobiste listy Trakt.
Zaloguj się w Trakt, aby włączyć zapisywanie na listach i tryb biblioteki Trakt.
+ Źródło biblioteki
+ Wybierz, której biblioteki używać do zapisywania i przeglądania kolekcji
+ Źródło biblioteki
+ Wybierz, gdzie zapisywać i zarządzać elementami biblioteki
+ Trakt
+ Biblioteka Nuvio
+ Wybrano bibliotekę Trakt
+ Wybrano bibliotekę Nuvio
+ Postęp oglądania
+ Wybierz, które źródło postępu obsługuje wznawianie i Kontynuuj oglądanie
+ Postęp oglądania
+ Wybierz, czy wznawianie i Kontynuuj oglądanie powinno korzystać z Trakt czy Nuvio Sync, podczas gdy scrobblowanie Trakt pozostaje aktywne.
+ Trakt
+ Nuvio Sync
+ Źródło postępu ustawione na Trakt
+ Źródło postępu ustawione na Nuvio Sync
+ Okno Kontynuuj oglądanie
+ Historia Trakt uwzględniana w Kontynuuj oglądanie
+ Okno Kontynuuj oglądanie
+ Wybierz, ile aktywności Trakt ma się pojawiać w Kontynuuj oglądanie.
+ Cała historia
+ %1$d dni
Ocena widzów
IMDb
Letterboxd
@@ -934,9 +1093,14 @@
Zablokowane. Spróbuj ponownie za %1$ds
Opcje awatarów pojawią się tutaj po załadowaniu katalogu.
Awatar: %1$s
+ Wprowadź prawidłowy URL obrazu http:// lub https://.
Wybierz awatar
Wybierz awatar poniżej.
Utwórz profil
+ Wybrano niestandardowy URL awatara.
+ Niestandardowy URL awatara
+ Wklej link do obrazu lub zostaw puste, aby użyć wbudowanego katalogu awatarów.
+ https://example.com/avatar.png
Wszystkie dane profilu „%1$s" zostaną trwale usunięte.
Usuń profil
Dodaj profil
@@ -968,6 +1132,8 @@
Sprawdzanie kolejnych dodatków…
Kopiuj link strumienia
Pobierz plik
+ Otwórz w zewnętrznym odtwarzaczu
+ Otwórz w wewnętrznym odtwarzaczu
Zainstalowane dodatki strumieni nie zwróciły prawidłowej odpowiedzi.
Nie można załadować strumieni
Najpierw zainstaluj dodatek, aby załadować strumienie dla tego tytułu.
@@ -987,6 +1153,13 @@
Wznów od %1$d%
Wznów od %1$s
ROZMIAR %1$s
+ Ten typ strumienia nie jest obsługiwany
+ Dodaj klucz API Debrid w Ustawieniach.
+ Ten wynik Debrid wygasł. Odświeżanie strumieni.
+ Nie udało się rozwiązać tego strumienia Debrid.
+ Nie udało się otworzyć zewnętrznego odtwarzacza
+ Najpierw wybierz zewnętrzny odtwarzacz w ustawieniach
+ Brak dostępnego zewnętrznego odtwarzacza
Zamknij zwiastun
Nie można odtworzyć zwiastuna
Nie udało się załadować list Trakt
@@ -1032,6 +1205,7 @@
Pobieranie nie powiodło się
Wstrzymano %1$s
Usuń
+ Usunąć %1$s z %2$s?
Usunąć %1$s z biblioteki?
Usunąć z biblioteki?
Film
@@ -1075,6 +1249,7 @@
Folder %1$d w „%2$s" ma puste id.
Folder „%1$s" w „%2$s" ma pusty tytuł.
Źródło %1$d w folderze „%2$s" ma puste pola.
+ Źródło %1$d w folderze '%2$s' nie ma ID listy Trakt.
Nieprawidłowy JSON: %1$s
Nie znaleziono dodatku: %1$s
Styczeń
@@ -1148,6 +1323,14 @@
Nowy odcinek jest już dostępny
%1$s jest już dostępny
Premiery odcinków
+ Alkohol/Narkotyki
+ Przerażające
+ Nagość
+ Wulgaryzmy
+ Łagodne
+ Umiarkowane
+ Intensywne
+ Przemoc
Twórca
Reżyser
Scenarzysta
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 955e47cd..601a432c 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -18,6 +18,7 @@
Resume
Retry
Save
+ Saving…
Validate
Installing
Addons
@@ -596,10 +597,15 @@
Add an API key first.
Account
Connect your Torbox account.
+ Torbox API Key
+ Enter your Torbox API key.
+ Enter Torbox API key
+ Not set
Instant Playback
Prepare links
Resolve the first sources before playback starts.
Sources to prepare
+ Use a lower count when possible. Debrid services may rate-limit how many links can be resolved in a time period. Opening a movie or episode can count toward those limits even if you do not press Watch, because the links are prepared ahead of time.
1 source
%1$d sources
Formatting
@@ -607,6 +613,8 @@
Controls how source names appear.
Description template
Controls the metadata shown under each source.
+ Reset formatting
+ Restore default source formatting.
API key validated.
Could not validate this API key.
Add your MDBList API key below before turning ratings on.
@@ -748,6 +756,8 @@
None
Prefer Binge Group (Next Episode)
Try the same source profile first (same addon/quality group) before normal auto-play rules.
+ Reuse Binge Group
+ Remember and reuse the last binge group across sessions (Continue Watching, Details, etc.).
Preferred Audio Language
Preferred Language
Presets
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index 4058c118..e486476d 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -1527,6 +1527,7 @@ private fun MainAppContent(
StreamsRepository.reload(
type = launch.type,
videoId = effectiveVideoId,
+ parentMetaId = launch.parentMetaId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
manualSelection = launch.manualSelection,
@@ -1636,6 +1637,7 @@ private fun MainAppContent(
StreamsRepository.reload(
type = launch.type,
videoId = effectiveVideoId,
+ parentMetaId = launch.parentMetaId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
manualSelection = launch.manualSelection,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt
index e226f637..86520e0a 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt
@@ -6,6 +6,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -32,6 +33,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.nuvio.app.core.format.formatReleaseDateForDisplay
@@ -151,6 +153,20 @@ fun NuvioAnimatedWatchedBadge(
}
}
+@Composable
+fun BoxScope.NuvioPosterWatchedOverlay(
+ isWatched: Boolean,
+ modifier: Modifier = Modifier,
+ padding: Dp = 6.dp,
+) {
+ NuvioAnimatedWatchedBadge(
+ isVisible = isWatched,
+ modifier = modifier
+ .align(Alignment.TopEnd)
+ .padding(padding),
+ )
+}
+
@Composable
private fun PosterSheetHeader(
item: MetaPreview,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt
index ace10d77..b0cf02fd 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt
@@ -179,12 +179,7 @@ fun NuvioPosterCard(
}
}
- NuvioAnimatedWatchedBadge(
- isVisible = isWatched,
- modifier = Modifier
- .align(Alignment.TopEnd)
- .padding(6.dp),
- )
+ NuvioPosterWatchedOverlay(isWatched = isWatched)
}
if (shouldShowTitleBelow) {
Text(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt
index c611b161..e068aa4e 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt
@@ -47,6 +47,7 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import coil3.compose.AsyncImage
import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.core.ui.NuvioBackButton
+import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
import com.nuvio.app.core.ui.posterCardClickable
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
@@ -55,6 +56,8 @@ import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.home.stableKey
+import com.nuvio.app.features.watched.WatchedRepository
+import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
@@ -79,6 +82,10 @@ fun CatalogScreen(
val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
val posterCardStyle = rememberPosterCardStyleUiState()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
+ val watchedUiState by remember {
+ WatchedRepository.ensureLoaded()
+ WatchedRepository.uiState
+ }.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState()
var headerHeightPx by remember { mutableIntStateOf(0) }
var observedOfflineState by remember { mutableStateOf(false) }
@@ -187,6 +194,10 @@ fun CatalogScreen(
item = item,
cornerRadiusDp = posterCardStyle.cornerRadiusDp,
hideLabels = posterCardStyle.hideLabelsEnabled,
+ isWatched = WatchingState.isPosterWatched(
+ watchedKeys = watchedUiState.watchedKeys,
+ item = item,
+ ),
onClick = onPosterClick?.let { { it(item) } },
onLongClick = onPosterLongClick?.let { { it(item) } },
)
@@ -258,6 +269,7 @@ private fun CatalogPosterTile(
item: MetaPreview,
cornerRadiusDp: Int,
hideLabels: Boolean,
+ isWatched: Boolean,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
) {
@@ -280,6 +292,7 @@ private fun CatalogPosterTile(
contentScale = ContentScale.Crop,
)
}
+ NuvioPosterWatchedOverlay(isWatched = isWatched)
}
if (!hideLabels) {
Text(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt
index 6101d18a..5881ce2e 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt
@@ -61,6 +61,8 @@ import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.home.canOpenCatalog
import com.nuvio.app.features.home.stableKey
import com.nuvio.app.features.home.components.HomeCatalogRowSection
+import com.nuvio.app.features.watched.WatchedRepository
+import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
@@ -79,6 +81,10 @@ fun FolderDetailScreen(
onPosterClick: (MetaPreview) -> Unit,
) {
val uiState by FolderDetailRepository.uiState.collectAsState()
+ val watchedUiState by remember {
+ WatchedRepository.ensureLoaded()
+ WatchedRepository.uiState
+ }.collectAsState()
val folder = uiState.folder
val coverImageUrl = folder?.coverImageUrl?.takeIf { it.isNotBlank() }
val density = LocalDensity.current
@@ -160,18 +166,21 @@ fun FolderDetailScreen(
when (uiState.viewMode) {
FolderViewMode.TABBED_GRID -> TabbedGridContent(
uiState = uiState,
+ watchedKeys = watchedUiState.watchedKeys,
modifier = Modifier.weight(1f).then(contentModifier),
onTabSelected = { FolderDetailRepository.selectTab(it) },
onPosterClick = onPosterClick,
)
FolderViewMode.ROWS -> RowsContent(
uiState = uiState,
+ watchedKeys = watchedUiState.watchedKeys,
modifier = Modifier.weight(1f).then(contentModifier),
onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick,
)
FolderViewMode.FOLLOW_LAYOUT -> RowsContent(
uiState = uiState,
+ watchedKeys = watchedUiState.watchedKeys,
modifier = Modifier.weight(1f).then(contentModifier),
onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick,
@@ -199,6 +208,7 @@ private fun FolderCoverImage(
@Composable
private fun TabbedGridContent(
uiState: FolderDetailUiState,
+ watchedKeys: Set,
modifier: Modifier = Modifier,
onTabSelected: (Int) -> Unit,
onPosterClick: (MetaPreview) -> Unit,
@@ -285,6 +295,10 @@ private fun TabbedGridContent(
imageUrl = item.poster,
shape = NuvioPosterShape.Poster,
detailLine = item.releaseInfo,
+ isWatched = WatchingState.isPosterWatched(
+ watchedKeys = watchedKeys,
+ item = item,
+ ),
onClick = { onPosterClick(item) },
)
}
@@ -304,6 +318,7 @@ private fun TabbedGridContent(
@Composable
private fun RowsContent(
uiState: FolderDetailUiState,
+ watchedKeys: Set,
modifier: Modifier = Modifier,
onCatalogClick: (HomeCatalogSection) -> Unit,
onPosterClick: (MetaPreview) -> Unit,
@@ -340,6 +355,7 @@ private fun RowsContent(
} else {
null
},
+ watchedKeys = watchedKeys,
onPosterClick = { onPosterClick(it) },
)
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt
index b2d40b0f..6e48cc07 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt
@@ -1,10 +1,19 @@
package com.nuvio.app.features.debrid
+import kotlinx.serialization.Serializable
+
data class DebridSettings(
val enabled: Boolean = false,
val torboxApiKey: String = "",
val realDebridApiKey: String = "",
val instantPlaybackPreparationLimit: Int = 0,
+ val streamMaxResults: Int = 0,
+ val streamSortMode: DebridStreamSortMode = DebridStreamSortMode.DEFAULT,
+ val streamMinimumQuality: DebridStreamMinimumQuality = DebridStreamMinimumQuality.ANY,
+ val streamDolbyVisionFilter: DebridStreamFeatureFilter = DebridStreamFeatureFilter.ANY,
+ val streamHdrFilter: DebridStreamFeatureFilter = DebridStreamFeatureFilter.ANY,
+ val streamCodecFilter: DebridStreamCodecFilter = DebridStreamCodecFilter.ANY,
+ val streamPreferences: DebridStreamPreferences = DebridStreamPreferences(),
val streamNameTemplate: String = DebridStreamFormatterDefaults.NAME_TEMPLATE,
val streamDescriptionTemplate: String = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE,
) {
@@ -12,8 +21,236 @@ data class DebridSettings(
get() = DebridProviders.configuredServices(this).isNotEmpty()
}
-internal const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2
-internal const val DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT = 5
+const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2
+const val DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT = 5
-internal fun normalizeDebridInstantPlaybackPreparationLimit(value: Int): Int =
+enum class DebridStreamSortMode {
+ DEFAULT,
+ QUALITY_DESC,
+ SIZE_DESC,
+ SIZE_ASC,
+}
+
+enum class DebridStreamMinimumQuality(val minResolution: Int) {
+ ANY(0),
+ P720(720),
+ P1080(1080),
+ P2160(2160),
+}
+
+enum class DebridStreamFeatureFilter {
+ ANY,
+ EXCLUDE,
+ ONLY,
+}
+
+enum class DebridStreamCodecFilter {
+ ANY,
+ H264,
+ HEVC,
+ AV1,
+}
+
+@Serializable
+data class DebridStreamPreferences(
+ val maxResults: Int = 0,
+ val maxPerResolution: Int = 0,
+ val maxPerQuality: Int = 0,
+ val sizeMinGb: Int = 0,
+ val sizeMaxGb: Int = 0,
+ val preferredResolutions: List = DebridStreamResolution.defaultOrder,
+ val requiredResolutions: List = emptyList(),
+ val excludedResolutions: List = emptyList(),
+ val preferredQualities: List = DebridStreamQuality.defaultOrder,
+ val requiredQualities: List = emptyList(),
+ val excludedQualities: List = emptyList(),
+ val preferredVisualTags: List = DebridStreamVisualTag.defaultOrder,
+ val requiredVisualTags: List = emptyList(),
+ val excludedVisualTags: List = emptyList(),
+ val preferredAudioTags: List = DebridStreamAudioTag.defaultOrder,
+ val requiredAudioTags: List = emptyList(),
+ val excludedAudioTags: List = emptyList(),
+ val preferredAudioChannels: List = DebridStreamAudioChannel.defaultOrder,
+ val requiredAudioChannels: List = emptyList(),
+ val excludedAudioChannels: List = emptyList(),
+ val preferredEncodes: List = DebridStreamEncode.defaultOrder,
+ val requiredEncodes: List = emptyList(),
+ val excludedEncodes: List = emptyList(),
+ val preferredLanguages: List = emptyList(),
+ val requiredLanguages: List = emptyList(),
+ val excludedLanguages: List = emptyList(),
+ val requiredReleaseGroups: List = emptyList(),
+ val excludedReleaseGroups: List = emptyList(),
+ val sortCriteria: List = DebridStreamSortCriterion.defaultOrder,
+)
+
+@Serializable
+enum class DebridStreamResolution(val label: String, val value: Int) {
+ P2160("2160p", 2160),
+ P1440("1440p", 1440),
+ P1080("1080p", 1080),
+ P720("720p", 720),
+ P576("576p", 576),
+ P480("480p", 480),
+ P360("360p", 360),
+ UNKNOWN("Unknown", 0);
+
+ companion object {
+ val defaultOrder = listOf(P2160, P1440, P1080, P720, P576, P480, P360, UNKNOWN)
+ }
+}
+
+@Serializable
+enum class DebridStreamQuality(val label: String) {
+ BLURAY_REMUX("BluRay REMUX"),
+ BLURAY("BluRay"),
+ WEB_DL("WEB-DL"),
+ WEBRIP("WEBRip"),
+ HDRIP("HDRip"),
+ HD_RIP("HC HD-Rip"),
+ DVDRIP("DVDRip"),
+ HDTV("HDTV"),
+ CAM("CAM"),
+ TS("TS"),
+ TC("TC"),
+ SCR("SCR"),
+ UNKNOWN("Unknown");
+
+ companion object {
+ val defaultOrder = listOf(BLURAY_REMUX, BLURAY, WEB_DL, WEBRIP, HDRIP, HD_RIP, DVDRIP, HDTV, CAM, TS, TC, SCR, UNKNOWN)
+ }
+}
+
+@Serializable
+enum class DebridStreamVisualTag(val label: String) {
+ HDR_DV("HDR+DV"),
+ DV_ONLY("DV Only"),
+ HDR_ONLY("HDR Only"),
+ HDR10_PLUS("HDR10+"),
+ HDR10("HDR10"),
+ DV("DV"),
+ HDR("HDR"),
+ HLG("HLG"),
+ TEN_BIT("10bit"),
+ THREE_D("3D"),
+ IMAX("IMAX"),
+ AI("AI"),
+ SDR("SDR"),
+ H_OU("H-OU"),
+ H_SBS("H-SBS"),
+ UNKNOWN("Unknown");
+
+ companion object {
+ val defaultOrder = listOf(HDR_DV, DV_ONLY, HDR_ONLY, HDR10_PLUS, HDR10, DV, HDR, HLG, TEN_BIT, IMAX, SDR, THREE_D, AI, H_OU, H_SBS, UNKNOWN)
+ }
+}
+
+@Serializable
+enum class DebridStreamAudioTag(val label: String) {
+ ATMOS("Atmos"),
+ DD_PLUS("DD+"),
+ DD("DD"),
+ DTS_X("DTS:X"),
+ DTS_HD_MA("DTS-HD MA"),
+ DTS_HD("DTS-HD"),
+ DTS_ES("DTS-ES"),
+ DTS("DTS"),
+ TRUEHD("TrueHD"),
+ OPUS("OPUS"),
+ FLAC("FLAC"),
+ AAC("AAC"),
+ UNKNOWN("Unknown");
+
+ companion object {
+ val defaultOrder = listOf(ATMOS, DD_PLUS, DD, DTS_X, DTS_HD_MA, DTS_HD, DTS_ES, DTS, TRUEHD, OPUS, FLAC, AAC, UNKNOWN)
+ }
+}
+
+@Serializable
+enum class DebridStreamAudioChannel(val label: String) {
+ CH_2_0("2.0"),
+ CH_5_1("5.1"),
+ CH_6_1("6.1"),
+ CH_7_1("7.1"),
+ UNKNOWN("Unknown");
+
+ companion object {
+ val defaultOrder = listOf(CH_7_1, CH_6_1, CH_5_1, CH_2_0, UNKNOWN)
+ }
+}
+
+@Serializable
+enum class DebridStreamEncode(val label: String) {
+ AV1("AV1"),
+ HEVC("HEVC"),
+ AVC("AVC"),
+ XVID("XviD"),
+ DIVX("DivX"),
+ UNKNOWN("Unknown");
+
+ companion object {
+ val defaultOrder = listOf(AV1, HEVC, AVC, XVID, DIVX, UNKNOWN)
+ }
+}
+
+@Serializable
+enum class DebridStreamLanguage(val code: String, val label: String) {
+ EN("en", "English"),
+ HI("hi", "Hindi"),
+ IT("it", "Italian"),
+ ES("es", "Spanish"),
+ FR("fr", "French"),
+ DE("de", "German"),
+ PT("pt", "Portuguese"),
+ PL("pl", "Polish"),
+ CS("cs", "Czech"),
+ LA("la", "Latino"),
+ JA("ja", "Japanese"),
+ KO("ko", "Korean"),
+ ZH("zh", "Chinese"),
+ MULTI("multi", "Multi"),
+ UNKNOWN("unknown", "Unknown"),
+}
+
+@Serializable
+data class DebridStreamSortCriterion(
+ val key: DebridStreamSortKey = DebridStreamSortKey.RESOLUTION,
+ val direction: DebridStreamSortDirection = DebridStreamSortDirection.DESC,
+) {
+ companion object {
+ val defaultOrder = listOf(
+ DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.VISUAL_TAG, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_CHANNEL, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.ENCODE, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC),
+ )
+ }
+}
+
+@Serializable
+enum class DebridStreamSortKey(val label: String) {
+ RESOLUTION("Resolution"),
+ QUALITY("Quality"),
+ VISUAL_TAG("Visual tag"),
+ AUDIO_TAG("Audio"),
+ AUDIO_CHANNEL("Audio channel"),
+ ENCODE("Encode"),
+ SIZE("Size"),
+ LANGUAGE("Language"),
+ RELEASE_GROUP("Release group"),
+}
+
+@Serializable
+enum class DebridStreamSortDirection {
+ ASC,
+ DESC,
+}
+
+fun normalizeDebridInstantPlaybackPreparationLimit(value: Int): Int =
value.coerceIn(0, DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT)
+
+fun normalizeDebridStreamMaxResults(value: Int): Int =
+ if (value <= 0) 0 else value.coerceIn(1, 100)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt
index 17938a41..d8c7625b 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt
@@ -3,16 +3,34 @@ package com.nuvio.app.features.debrid
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
object DebridSettingsRepository {
private val _uiState = MutableStateFlow(DebridSettings())
val uiState: StateFlow = _uiState.asStateFlow()
+ @OptIn(ExperimentalSerializationApi::class)
+ private val json = Json {
+ ignoreUnknownKeys = true
+ explicitNulls = false
+ }
+
private var hasLoaded = false
private var enabled = false
private var torboxApiKey = ""
private var realDebridApiKey = ""
private var instantPlaybackPreparationLimit = 0
+ private var streamMaxResults = 0
+ private var streamSortMode = DebridStreamSortMode.DEFAULT
+ private var streamMinimumQuality = DebridStreamMinimumQuality.ANY
+ private var streamDolbyVisionFilter = DebridStreamFeatureFilter.ANY
+ private var streamHdrFilter = DebridStreamFeatureFilter.ANY
+ private var streamCodecFilter = DebridStreamCodecFilter.ANY
+ private var streamPreferences = DebridStreamPreferences()
private var streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE
private var streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE
@@ -68,6 +86,78 @@ object DebridSettingsRepository {
DebridSettingsStorage.saveInstantPlaybackPreparationLimit(normalized)
}
+ fun setStreamMaxResults(value: Int) {
+ ensureLoaded()
+ val normalized = normalizeDebridStreamMaxResults(value)
+ if (streamMaxResults == normalized && streamPreferences.maxResults == normalized) return
+ streamMaxResults = normalized
+ streamPreferences = streamPreferences.copy(maxResults = normalized).normalized()
+ publish()
+ DebridSettingsStorage.saveStreamMaxResults(normalized)
+ saveStreamPreferences()
+ }
+
+ fun setStreamSortMode(value: DebridStreamSortMode) {
+ ensureLoaded()
+ if (streamSortMode == value && streamPreferences.sortCriteria == sortCriteriaForLegacyMode(value)) return
+ streamSortMode = value
+ streamPreferences = streamPreferences.copy(sortCriteria = sortCriteriaForLegacyMode(value)).normalized()
+ publish()
+ DebridSettingsStorage.saveStreamSortMode(value.name)
+ saveStreamPreferences()
+ }
+
+ fun setStreamMinimumQuality(value: DebridStreamMinimumQuality) {
+ ensureLoaded()
+ if (streamMinimumQuality == value && streamPreferences.requiredResolutions == resolutionsForMinimumQuality(value)) return
+ streamMinimumQuality = value
+ streamPreferences = streamPreferences.copy(requiredResolutions = resolutionsForMinimumQuality(value)).normalized()
+ publish()
+ DebridSettingsStorage.saveStreamMinimumQuality(value.name)
+ saveStreamPreferences()
+ }
+
+ fun setStreamDolbyVisionFilter(value: DebridStreamFeatureFilter) {
+ ensureLoaded()
+ if (streamDolbyVisionFilter == value) return
+ streamDolbyVisionFilter = value
+ streamPreferences = streamPreferences.applyDolbyVisionFilter(value).normalized()
+ publish()
+ DebridSettingsStorage.saveStreamDolbyVisionFilter(value.name)
+ saveStreamPreferences()
+ }
+
+ fun setStreamHdrFilter(value: DebridStreamFeatureFilter) {
+ ensureLoaded()
+ if (streamHdrFilter == value) return
+ streamHdrFilter = value
+ streamPreferences = streamPreferences.applyHdrFilter(value).normalized()
+ publish()
+ DebridSettingsStorage.saveStreamHdrFilter(value.name)
+ saveStreamPreferences()
+ }
+
+ fun setStreamCodecFilter(value: DebridStreamCodecFilter) {
+ ensureLoaded()
+ if (streamCodecFilter == value) return
+ streamCodecFilter = value
+ streamPreferences = streamPreferences.applyCodecFilter(value).normalized()
+ publish()
+ DebridSettingsStorage.saveStreamCodecFilter(value.name)
+ saveStreamPreferences()
+ }
+
+ fun setStreamPreferences(value: DebridStreamPreferences) {
+ ensureLoaded()
+ val normalized = value.normalized()
+ if (streamPreferences == normalized) return
+ streamPreferences = normalized
+ streamMaxResults = normalized.maxResults
+ publish()
+ DebridSettingsStorage.saveStreamMaxResults(streamMaxResults)
+ saveStreamPreferences()
+ }
+
fun setStreamNameTemplate(value: String) {
ensureLoaded()
val normalized = value.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE }
@@ -86,15 +176,22 @@ object DebridSettingsRepository {
DebridSettingsStorage.saveStreamDescriptionTemplate(normalized)
}
- fun resetStreamTemplates() {
+ fun setStreamTemplates(nameTemplate: String, descriptionTemplate: String) {
ensureLoaded()
- streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE
- streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE
+ streamNameTemplate = nameTemplate.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE }
+ streamDescriptionTemplate = descriptionTemplate.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE }
publish()
DebridSettingsStorage.saveStreamNameTemplate(streamNameTemplate)
DebridSettingsStorage.saveStreamDescriptionTemplate(streamDescriptionTemplate)
}
+ fun resetStreamTemplates() {
+ setStreamTemplates(
+ nameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE,
+ descriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE,
+ )
+ }
+
private fun disableIfNoKeys() {
if (!hasVisibleApiKey()) {
enabled = false
@@ -114,6 +211,36 @@ object DebridSettingsRepository {
instantPlaybackPreparationLimit = normalizeDebridInstantPlaybackPreparationLimit(
DebridSettingsStorage.loadInstantPlaybackPreparationLimit() ?: 0,
)
+ streamMaxResults = normalizeDebridStreamMaxResults(DebridSettingsStorage.loadStreamMaxResults() ?: 0)
+ streamSortMode = enumValueOrDefault(
+ DebridSettingsStorage.loadStreamSortMode(),
+ DebridStreamSortMode.DEFAULT,
+ )
+ streamMinimumQuality = enumValueOrDefault(
+ DebridSettingsStorage.loadStreamMinimumQuality(),
+ DebridStreamMinimumQuality.ANY,
+ )
+ streamDolbyVisionFilter = enumValueOrDefault(
+ DebridSettingsStorage.loadStreamDolbyVisionFilter(),
+ DebridStreamFeatureFilter.ANY,
+ )
+ streamHdrFilter = enumValueOrDefault(
+ DebridSettingsStorage.loadStreamHdrFilter(),
+ DebridStreamFeatureFilter.ANY,
+ )
+ streamCodecFilter = enumValueOrDefault(
+ DebridSettingsStorage.loadStreamCodecFilter(),
+ DebridStreamCodecFilter.ANY,
+ )
+ streamPreferences = parseStreamPreferences(DebridSettingsStorage.loadStreamPreferences())
+ ?: legacyStreamPreferences(
+ maxResults = streamMaxResults,
+ sortMode = streamSortMode,
+ minimumQuality = streamMinimumQuality,
+ dolbyVisionFilter = streamDolbyVisionFilter,
+ hdrFilter = streamHdrFilter,
+ codecFilter = streamCodecFilter,
+ )
streamNameTemplate = DebridSettingsStorage.loadStreamNameTemplate()
?.takeIf { it.isNotBlank() }
?: DebridStreamFormatterDefaults.NAME_TEMPLATE
@@ -129,8 +256,164 @@ object DebridSettingsRepository {
torboxApiKey = torboxApiKey,
realDebridApiKey = realDebridApiKey,
instantPlaybackPreparationLimit = instantPlaybackPreparationLimit,
+ streamMaxResults = streamMaxResults,
+ streamSortMode = streamSortMode,
+ streamMinimumQuality = streamMinimumQuality,
+ streamDolbyVisionFilter = streamDolbyVisionFilter,
+ streamHdrFilter = streamHdrFilter,
+ streamCodecFilter = streamCodecFilter,
+ streamPreferences = streamPreferences,
streamNameTemplate = streamNameTemplate,
streamDescriptionTemplate = streamDescriptionTemplate,
)
}
+
+ private fun saveStreamPreferences() {
+ DebridSettingsStorage.saveStreamPreferences(json.encodeToString(streamPreferences.normalized()))
+ }
+
+ private inline fun > enumValueOrDefault(value: String?, default: T): T =
+ runCatching { enumValueOf(value.orEmpty()) }.getOrDefault(default)
+
+ private fun parseStreamPreferences(value: String?): DebridStreamPreferences? {
+ if (value.isNullOrBlank()) return null
+ return try {
+ json.decodeFromString(value).normalized()
+ } catch (_: SerializationException) {
+ null
+ } catch (_: IllegalArgumentException) {
+ null
+ }
+ }
}
+
+internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences =
+ copy(
+ maxResults = normalizeDebridStreamMaxResults(maxResults),
+ maxPerResolution = maxPerResolution.coerceIn(0, 100),
+ maxPerQuality = maxPerQuality.coerceIn(0, 100),
+ sizeMinGb = sizeMinGb.coerceIn(0, 100),
+ sizeMaxGb = sizeMaxGb.coerceIn(0, 100),
+ preferredResolutions = preferredResolutions.ifEmpty { DebridStreamResolution.defaultOrder },
+ requiredResolutions = requiredResolutions,
+ excludedResolutions = excludedResolutions,
+ preferredQualities = preferredQualities.ifEmpty { DebridStreamQuality.defaultOrder },
+ requiredQualities = requiredQualities,
+ excludedQualities = excludedQualities,
+ preferredVisualTags = preferredVisualTags.ifEmpty { DebridStreamVisualTag.defaultOrder },
+ requiredVisualTags = requiredVisualTags,
+ excludedVisualTags = excludedVisualTags,
+ preferredAudioTags = preferredAudioTags.ifEmpty { DebridStreamAudioTag.defaultOrder },
+ requiredAudioTags = requiredAudioTags,
+ excludedAudioTags = excludedAudioTags,
+ preferredAudioChannels = preferredAudioChannels.ifEmpty { DebridStreamAudioChannel.defaultOrder },
+ requiredAudioChannels = requiredAudioChannels,
+ excludedAudioChannels = excludedAudioChannels,
+ preferredEncodes = preferredEncodes.ifEmpty { DebridStreamEncode.defaultOrder },
+ requiredEncodes = requiredEncodes,
+ excludedEncodes = excludedEncodes,
+ preferredLanguages = preferredLanguages,
+ requiredLanguages = requiredLanguages,
+ excludedLanguages = excludedLanguages,
+ requiredReleaseGroups = requiredReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(),
+ excludedReleaseGroups = excludedReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(),
+ sortCriteria = sortCriteria.ifEmpty { DebridStreamSortCriterion.defaultOrder },
+ )
+
+private fun legacyStreamPreferences(
+ maxResults: Int,
+ sortMode: DebridStreamSortMode,
+ minimumQuality: DebridStreamMinimumQuality,
+ dolbyVisionFilter: DebridStreamFeatureFilter,
+ hdrFilter: DebridStreamFeatureFilter,
+ codecFilter: DebridStreamCodecFilter,
+): DebridStreamPreferences =
+ DebridStreamPreferences(
+ maxResults = normalizeDebridStreamMaxResults(maxResults),
+ sortCriteria = sortCriteriaForLegacyMode(sortMode),
+ requiredResolutions = resolutionsForMinimumQuality(minimumQuality),
+ )
+ .applyDolbyVisionFilter(dolbyVisionFilter)
+ .applyHdrFilter(hdrFilter)
+ .applyCodecFilter(codecFilter)
+ .normalized()
+
+private fun DebridStreamPreferences.applyDolbyVisionFilter(
+ filter: DebridStreamFeatureFilter,
+): DebridStreamPreferences =
+ when (filter) {
+ DebridStreamFeatureFilter.ANY -> copy(
+ requiredVisualTags = requiredVisualTags - dolbyVisionTags.toSet(),
+ excludedVisualTags = excludedVisualTags - dolbyVisionTags.toSet(),
+ )
+ DebridStreamFeatureFilter.EXCLUDE -> copy(
+ requiredVisualTags = requiredVisualTags - dolbyVisionTags.toSet(),
+ excludedVisualTags = (excludedVisualTags + dolbyVisionTags).distinct(),
+ )
+ DebridStreamFeatureFilter.ONLY -> copy(
+ requiredVisualTags = (requiredVisualTags + dolbyVisionTags).distinct(),
+ excludedVisualTags = excludedVisualTags - dolbyVisionTags.toSet(),
+ )
+ }
+
+private fun DebridStreamPreferences.applyHdrFilter(
+ filter: DebridStreamFeatureFilter,
+): DebridStreamPreferences =
+ when (filter) {
+ DebridStreamFeatureFilter.ANY -> copy(
+ requiredVisualTags = requiredVisualTags - hdrTags.toSet(),
+ excludedVisualTags = excludedVisualTags - hdrTags.toSet(),
+ )
+ DebridStreamFeatureFilter.EXCLUDE -> copy(
+ requiredVisualTags = requiredVisualTags - hdrTags.toSet(),
+ excludedVisualTags = (excludedVisualTags + hdrTags).distinct(),
+ )
+ DebridStreamFeatureFilter.ONLY -> copy(
+ requiredVisualTags = (requiredVisualTags + hdrTags).distinct(),
+ excludedVisualTags = excludedVisualTags - hdrTags.toSet(),
+ )
+ }
+
+private fun DebridStreamPreferences.applyCodecFilter(
+ filter: DebridStreamCodecFilter,
+): DebridStreamPreferences =
+ copy(
+ requiredEncodes = when (filter) {
+ DebridStreamCodecFilter.ANY -> emptyList()
+ DebridStreamCodecFilter.H264 -> listOf(DebridStreamEncode.AVC)
+ DebridStreamCodecFilter.HEVC -> listOf(DebridStreamEncode.HEVC)
+ DebridStreamCodecFilter.AV1 -> listOf(DebridStreamEncode.AV1)
+ },
+ )
+
+private fun resolutionsForMinimumQuality(quality: DebridStreamMinimumQuality): List =
+ DebridStreamResolution.defaultOrder.filter {
+ it.value >= quality.minResolution && it != DebridStreamResolution.UNKNOWN
+ }
+
+private fun sortCriteriaForLegacyMode(mode: DebridStreamSortMode): List =
+ when (mode) {
+ DebridStreamSortMode.DEFAULT -> DebridStreamSortCriterion.defaultOrder
+ DebridStreamSortMode.QUALITY_DESC -> listOf(
+ DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC),
+ )
+ DebridStreamSortMode.SIZE_DESC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC))
+ DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC))
+ }
+
+private val dolbyVisionTags = listOf(
+ DebridStreamVisualTag.DV,
+ DebridStreamVisualTag.DV_ONLY,
+ DebridStreamVisualTag.HDR_DV,
+)
+
+private val hdrTags = listOf(
+ DebridStreamVisualTag.HDR,
+ DebridStreamVisualTag.HDR10,
+ DebridStreamVisualTag.HDR10_PLUS,
+ DebridStreamVisualTag.HLG,
+ DebridStreamVisualTag.HDR_ONLY,
+ DebridStreamVisualTag.HDR_DV,
+)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt
index 6c4f238f..62fddac4 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt
@@ -11,6 +11,20 @@ internal expect object DebridSettingsStorage {
fun saveRealDebridApiKey(apiKey: String)
fun loadInstantPlaybackPreparationLimit(): Int?
fun saveInstantPlaybackPreparationLimit(limit: Int)
+ fun loadStreamMaxResults(): Int?
+ fun saveStreamMaxResults(maxResults: Int)
+ fun loadStreamSortMode(): String?
+ fun saveStreamSortMode(mode: String)
+ fun loadStreamMinimumQuality(): String?
+ fun saveStreamMinimumQuality(quality: String)
+ fun loadStreamDolbyVisionFilter(): String?
+ fun saveStreamDolbyVisionFilter(filter: String)
+ fun loadStreamHdrFilter(): String?
+ fun saveStreamHdrFilter(filter: String)
+ fun loadStreamCodecFilter(): String?
+ fun saveStreamCodecFilter(filter: String)
+ fun loadStreamPreferences(): String?
+ fun saveStreamPreferences(preferences: String)
fun loadStreamNameTemplate(): String?
fun saveStreamNameTemplate(template: String)
fun loadStreamDescriptionTemplate(): String?
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt
index 28c03dde..6647d607 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt
@@ -5,8 +5,8 @@ import com.nuvio.app.features.streams.StreamItem
object DirectDebridStreamFilter {
const val FALLBACK_SOURCE_NAME = "Direct Debrid"
- fun filterInstant(streams: List): List =
- streams
+ fun filterInstant(streams: List, settings: DebridSettings? = null): List {
+ val instantStreams = streams
.filter(::isInstantCandidate)
.map { stream ->
val providerId = stream.clientResolve?.service
@@ -27,6 +27,8 @@ object DirectDebridStreamFilter {
stream.title,
).joinToString("|")
}
+ return if (settings == null) instantStreams else applyPreferences(instantStreams, settings)
+ }
fun isInstantCandidate(stream: StreamItem): Boolean {
val resolve = stream.clientResolve ?: return false
@@ -37,5 +39,387 @@ object DirectDebridStreamFilter {
fun isDirectDebridSourceName(addonName: String): Boolean =
DebridProviders.all().any { addonName == DebridProviders.instantName(it.id) }
-}
+ private fun applyPreferences(streams: List, settings: DebridSettings): List {
+ val preferences = effectivePreferences(settings)
+ return streams.map { it to streamFacts(it, preferences) }
+ .filter { (_, facts) -> facts.matchesFilters(preferences) }
+ .sortedWith { left, right -> compareFacts(left.second, right.second, preferences.sortCriteria) }
+ .let { sorted -> applyLimits(sorted, preferences) }
+ .map { it.first }
+ }
+
+ private fun effectivePreferences(settings: DebridSettings): DebridStreamPreferences {
+ val default = DebridStreamPreferences()
+ if (settings.streamPreferences != default) return settings.streamPreferences.normalized()
+ if (
+ settings.streamMaxResults == 0 &&
+ settings.streamSortMode == DebridStreamSortMode.DEFAULT &&
+ settings.streamMinimumQuality == DebridStreamMinimumQuality.ANY &&
+ settings.streamDolbyVisionFilter == DebridStreamFeatureFilter.ANY &&
+ settings.streamHdrFilter == DebridStreamFeatureFilter.ANY &&
+ settings.streamCodecFilter == DebridStreamCodecFilter.ANY
+ ) {
+ return default
+ }
+ var preferences = default.copy(
+ maxResults = settings.streamMaxResults,
+ sortCriteria = when (settings.streamSortMode) {
+ DebridStreamSortMode.DEFAULT -> default.sortCriteria
+ DebridStreamSortMode.QUALITY_DESC -> listOf(
+ DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC),
+ )
+ DebridStreamSortMode.SIZE_DESC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC))
+ DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC))
+ },
+ requiredResolutions = DebridStreamResolution.defaultOrder.filter {
+ it.value >= settings.streamMinimumQuality.minResolution && it != DebridStreamResolution.UNKNOWN
+ },
+ )
+ preferences = when (settings.streamDolbyVisionFilter) {
+ DebridStreamFeatureFilter.ANY -> preferences
+ DebridStreamFeatureFilter.EXCLUDE -> preferences.copy(
+ excludedVisualTags = preferences.excludedVisualTags + listOf(
+ DebridStreamVisualTag.DV,
+ DebridStreamVisualTag.DV_ONLY,
+ DebridStreamVisualTag.HDR_DV,
+ ),
+ )
+ DebridStreamFeatureFilter.ONLY -> preferences.copy(
+ requiredVisualTags = preferences.requiredVisualTags + listOf(
+ DebridStreamVisualTag.DV,
+ DebridStreamVisualTag.DV_ONLY,
+ DebridStreamVisualTag.HDR_DV,
+ ),
+ )
+ }
+ preferences = when (settings.streamHdrFilter) {
+ DebridStreamFeatureFilter.ANY -> preferences
+ DebridStreamFeatureFilter.EXCLUDE -> preferences.copy(
+ excludedVisualTags = preferences.excludedVisualTags + listOf(
+ DebridStreamVisualTag.HDR,
+ DebridStreamVisualTag.HDR10,
+ DebridStreamVisualTag.HDR10_PLUS,
+ DebridStreamVisualTag.HLG,
+ DebridStreamVisualTag.HDR_ONLY,
+ DebridStreamVisualTag.HDR_DV,
+ ),
+ )
+ DebridStreamFeatureFilter.ONLY -> preferences.copy(
+ requiredVisualTags = preferences.requiredVisualTags + listOf(
+ DebridStreamVisualTag.HDR,
+ DebridStreamVisualTag.HDR10,
+ DebridStreamVisualTag.HDR10_PLUS,
+ DebridStreamVisualTag.HLG,
+ DebridStreamVisualTag.HDR_ONLY,
+ DebridStreamVisualTag.HDR_DV,
+ ),
+ )
+ }
+ return when (settings.streamCodecFilter) {
+ DebridStreamCodecFilter.ANY -> preferences
+ DebridStreamCodecFilter.H264 -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.AVC))
+ DebridStreamCodecFilter.HEVC -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.HEVC))
+ DebridStreamCodecFilter.AV1 -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.AV1))
+ }.normalized()
+ }
+
+ private fun applyLimits(
+ streams: List>,
+ preferences: DebridStreamPreferences,
+ ): List> {
+ val resolutionCounts = mutableMapOf()
+ val qualityCounts = mutableMapOf()
+ val result = mutableListOf>()
+ for (stream in streams) {
+ if (preferences.maxResults > 0 && result.size >= preferences.maxResults) break
+ if (preferences.maxPerResolution > 0) {
+ val count = resolutionCounts[stream.second.resolution] ?: 0
+ if (count >= preferences.maxPerResolution) continue
+ }
+ if (preferences.maxPerQuality > 0) {
+ val count = qualityCounts[stream.second.quality] ?: 0
+ if (count >= preferences.maxPerQuality) continue
+ }
+ resolutionCounts[stream.second.resolution] = (resolutionCounts[stream.second.resolution] ?: 0) + 1
+ qualityCounts[stream.second.quality] = (qualityCounts[stream.second.quality] ?: 0) + 1
+ result += stream
+ }
+ return result
+ }
+
+ private fun StreamFacts.matchesFilters(preferences: DebridStreamPreferences): Boolean {
+ if (preferences.requiredResolutions.isNotEmpty() && resolution !in preferences.requiredResolutions) return false
+ if (resolution in preferences.excludedResolutions) return false
+ if (preferences.requiredQualities.isNotEmpty() && quality !in preferences.requiredQualities) return false
+ if (quality in preferences.excludedQualities) return false
+ if (preferences.requiredVisualTags.isNotEmpty() && visualTags.none { it in preferences.requiredVisualTags }) return false
+ if (visualTags.any { it in preferences.excludedVisualTags }) return false
+ if (preferences.requiredAudioTags.isNotEmpty() && audioTags.none { it in preferences.requiredAudioTags }) return false
+ if (audioTags.any { it in preferences.excludedAudioTags }) return false
+ if (preferences.requiredAudioChannels.isNotEmpty() && audioChannels.none { it in preferences.requiredAudioChannels }) return false
+ if (audioChannels.any { it in preferences.excludedAudioChannels }) return false
+ if (preferences.requiredEncodes.isNotEmpty() && encode !in preferences.requiredEncodes) return false
+ if (encode in preferences.excludedEncodes) return false
+ if (preferences.requiredLanguages.isNotEmpty() && languages.none { it in preferences.requiredLanguages }) return false
+ if (languages.isNotEmpty() && languages.all { it in preferences.excludedLanguages }) return false
+ if (preferences.requiredReleaseGroups.isNotEmpty() && preferences.requiredReleaseGroups.none { releaseGroup.equals(it, ignoreCase = true) }) return false
+ if (preferences.excludedReleaseGroups.any { releaseGroup.equals(it, ignoreCase = true) }) return false
+ if (preferences.sizeMinGb > 0 && size != null && size < preferences.sizeMinGb.gigabytes()) return false
+ if (preferences.sizeMaxGb > 0 && size != null && size > preferences.sizeMaxGb.gigabytes()) return false
+ return true
+ }
+
+ private fun compareFacts(
+ left: StreamFacts,
+ right: StreamFacts,
+ criteria: List,
+ ): Int {
+ for (criterion in criteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }) {
+ val comparison = compareKey(left, right, criterion)
+ if (comparison != 0) return comparison
+ }
+ return 0
+ }
+
+ private fun compareKey(
+ left: StreamFacts,
+ right: StreamFacts,
+ criterion: DebridStreamSortCriterion,
+ ): Int {
+ val direction = if (criterion.direction == DebridStreamSortDirection.ASC) 1 else -1
+ return when (criterion.key) {
+ DebridStreamSortKey.RESOLUTION -> left.resolutionRank.compareTo(right.resolutionRank) * -direction
+ DebridStreamSortKey.QUALITY -> left.qualityRank.compareTo(right.qualityRank) * -direction
+ DebridStreamSortKey.VISUAL_TAG -> left.visualRank.compareTo(right.visualRank) * -direction
+ DebridStreamSortKey.AUDIO_TAG -> left.audioRank.compareTo(right.audioRank) * -direction
+ DebridStreamSortKey.AUDIO_CHANNEL -> left.channelRank.compareTo(right.channelRank) * -direction
+ DebridStreamSortKey.ENCODE -> left.encodeRank.compareTo(right.encodeRank) * -direction
+ DebridStreamSortKey.SIZE -> (left.size ?: 0L).compareTo(right.size ?: 0L) * direction
+ DebridStreamSortKey.LANGUAGE -> left.languageRank.compareTo(right.languageRank) * -direction
+ DebridStreamSortKey.RELEASE_GROUP -> left.releaseGroup.compareTo(right.releaseGroup, ignoreCase = true)
+ }
+ }
+
+ private fun streamFacts(stream: StreamItem, preferences: DebridStreamPreferences): StreamFacts {
+ val parsed = stream.clientResolve?.stream?.raw?.parsed
+ val searchText = streamSearchText(stream)
+ val resolution = streamResolution(parsed?.resolution, parsed?.quality, searchText)
+ val quality = streamQuality(parsed?.quality, searchText)
+ val visualTags = streamVisualTags(parsed?.hdr.orEmpty(), searchText)
+ val audioTags = streamAudioTags(parsed?.audio.orEmpty(), searchText)
+ val audioChannels = streamAudioChannels(parsed?.channels.orEmpty(), searchText)
+ val encode = streamEncode(parsed?.codec, searchText)
+ val languages = parsed?.languages.orEmpty().mapNotNull { languageFor(it) }.ifEmpty {
+ DebridStreamLanguage.entries.filter { searchText.hasToken(it.code) }
+ }
+ val releaseGroup = parsed?.group?.takeIf { it.isNotBlank() } ?: releaseGroupFromText(searchText)
+ return StreamFacts(
+ resolution = resolution,
+ quality = quality,
+ visualTags = visualTags,
+ audioTags = audioTags,
+ audioChannels = audioChannels,
+ encode = encode,
+ languages = languages,
+ releaseGroup = releaseGroup,
+ size = streamSize(stream),
+ resolutionRank = rank(resolution, preferences.preferredResolutions),
+ qualityRank = rank(quality, preferences.preferredQualities),
+ visualRank = rankAny(visualTags, preferences.preferredVisualTags),
+ audioRank = rankAny(audioTags, preferences.preferredAudioTags),
+ channelRank = rankAny(audioChannels, preferences.preferredAudioChannels),
+ encodeRank = rank(encode, preferences.preferredEncodes),
+ languageRank = if (languages.isEmpty()) Int.MAX_VALUE else languages.minOf { rank(it, preferences.preferredLanguages) },
+ )
+ }
+
+ private fun streamResolution(vararg values: String?): DebridStreamResolution =
+ values.firstNotNullOfOrNull { resolutionValue(it) } ?: DebridStreamResolution.UNKNOWN
+
+ private fun resolutionValue(value: String?): DebridStreamResolution? {
+ val normalized = value?.lowercase().orEmpty()
+ return when {
+ normalized.hasResolutionToken("2160p?", "4k", "uhd") -> DebridStreamResolution.P2160
+ normalized.hasResolutionToken("1440p?", "2k") -> DebridStreamResolution.P1440
+ normalized.hasResolutionToken("1080p?", "fhd") -> DebridStreamResolution.P1080
+ normalized.hasResolutionToken("720p?", "hd") -> DebridStreamResolution.P720
+ normalized.hasResolutionToken("576p?") -> DebridStreamResolution.P576
+ normalized.hasResolutionToken("480p?", "sd") -> DebridStreamResolution.P480
+ normalized.hasResolutionToken("360p?") -> DebridStreamResolution.P360
+ else -> null
+ }
+ }
+
+ private fun streamQuality(parsedQuality: String?, searchText: String): DebridStreamQuality {
+ val text = listOfNotNull(parsedQuality, searchText).joinToString(" ").lowercase()
+ return when {
+ text.contains("remux") -> DebridStreamQuality.BLURAY_REMUX
+ text.contains("blu-ray") || text.contains("bluray") || text.contains("bdrip") || text.contains("brrip") -> DebridStreamQuality.BLURAY
+ text.contains("web-dl") || text.contains("webdl") -> DebridStreamQuality.WEB_DL
+ text.contains("webrip") || text.contains("web-rip") -> DebridStreamQuality.WEBRIP
+ text.contains("hdrip") -> DebridStreamQuality.HDRIP
+ text.contains("hd-rip") || text.contains("hcrip") -> DebridStreamQuality.HD_RIP
+ text.contains("dvdrip") -> DebridStreamQuality.DVDRIP
+ text.contains("hdtv") -> DebridStreamQuality.HDTV
+ text.hasToken("cam") -> DebridStreamQuality.CAM
+ text.hasToken("ts") -> DebridStreamQuality.TS
+ text.hasToken("tc") -> DebridStreamQuality.TC
+ text.hasToken("scr") -> DebridStreamQuality.SCR
+ else -> DebridStreamQuality.UNKNOWN
+ }
+ }
+
+ private fun streamVisualTags(parsedHdr: List, searchText: String): List {
+ val text = (parsedHdr + searchText).joinToString(" ").lowercase()
+ val tags = mutableListOf()
+ val hasDv = parsedHdr.any { it.isDolbyVisionToken() } ||
+ Regex("(^|[^a-z0-9])(dv|dovi|dolby[ ._-]?vision)([^a-z0-9]|$)").containsMatchIn(searchText)
+ val hasHdr = parsedHdr.any { it.isHdrToken() } ||
+ Regex("(^|[^a-z0-9])(hdr|hdr10|hdr10plus|hdr10\\+|hlg)([^a-z0-9]|$)").containsMatchIn(searchText)
+ if (hasDv && hasHdr) tags += DebridStreamVisualTag.HDR_DV
+ if (hasDv && !hasHdr) tags += DebridStreamVisualTag.DV_ONLY
+ if (hasHdr && !hasDv) tags += DebridStreamVisualTag.HDR_ONLY
+ if (text.contains("hdr10+") || text.contains("hdr10plus")) tags += DebridStreamVisualTag.HDR10_PLUS
+ if (text.contains("hdr10")) tags += DebridStreamVisualTag.HDR10
+ if (hasDv) tags += DebridStreamVisualTag.DV
+ if (hasHdr) tags += DebridStreamVisualTag.HDR
+ if (text.hasToken("hlg")) tags += DebridStreamVisualTag.HLG
+ if (text.contains("10bit") || text.contains("10 bit")) tags += DebridStreamVisualTag.TEN_BIT
+ if (text.hasToken("3d")) tags += DebridStreamVisualTag.THREE_D
+ if (text.hasToken("imax")) tags += DebridStreamVisualTag.IMAX
+ if (text.hasToken("ai")) tags += DebridStreamVisualTag.AI
+ if (text.hasToken("sdr")) tags += DebridStreamVisualTag.SDR
+ if (text.contains("h-ou")) tags += DebridStreamVisualTag.H_OU
+ if (text.contains("h-sbs")) tags += DebridStreamVisualTag.H_SBS
+ return tags.distinct().ifEmpty { listOf(DebridStreamVisualTag.UNKNOWN) }
+ }
+
+ private fun streamAudioTags(parsedAudio: List, searchText: String): List {
+ val text = (parsedAudio + searchText).joinToString(" ").lowercase()
+ val tags = mutableListOf()
+ if (text.hasToken("atmos")) tags += DebridStreamAudioTag.ATMOS
+ if (text.contains("dd+") || text.contains("ddp") || text.contains("dolby digital plus")) tags += DebridStreamAudioTag.DD_PLUS
+ if (text.hasToken("dd") || text.contains("ac3") || text.contains("dolby digital")) tags += DebridStreamAudioTag.DD
+ if (text.contains("dts:x") || text.contains("dtsx")) tags += DebridStreamAudioTag.DTS_X
+ if (text.contains("dts-hd ma") || text.contains("dtshd ma")) tags += DebridStreamAudioTag.DTS_HD_MA
+ if (text.contains("dts-hd") || text.contains("dtshd")) tags += DebridStreamAudioTag.DTS_HD
+ if (text.contains("dts-es") || text.contains("dtses")) tags += DebridStreamAudioTag.DTS_ES
+ if (text.hasToken("dts")) tags += DebridStreamAudioTag.DTS
+ if (text.contains("truehd") || text.contains("true hd")) tags += DebridStreamAudioTag.TRUEHD
+ if (text.hasToken("opus")) tags += DebridStreamAudioTag.OPUS
+ if (text.hasToken("flac")) tags += DebridStreamAudioTag.FLAC
+ if (text.hasToken("aac")) tags += DebridStreamAudioTag.AAC
+ return tags.distinct().ifEmpty { listOf(DebridStreamAudioTag.UNKNOWN) }
+ }
+
+ private fun streamAudioChannels(parsedChannels: List, searchText: String): List {
+ val text = (parsedChannels + searchText).joinToString(" ").lowercase()
+ val channels = mutableListOf()
+ if (text.hasToken("7.1")) channels += DebridStreamAudioChannel.CH_7_1
+ if (text.hasToken("6.1")) channels += DebridStreamAudioChannel.CH_6_1
+ if (text.hasToken("5.1") || text.hasToken("6ch")) channels += DebridStreamAudioChannel.CH_5_1
+ if (text.hasToken("2.0")) channels += DebridStreamAudioChannel.CH_2_0
+ return channels.distinct().ifEmpty { listOf(DebridStreamAudioChannel.UNKNOWN) }
+ }
+
+ private fun streamEncode(parsedCodec: String?, searchText: String): DebridStreamEncode {
+ val text = listOfNotNull(parsedCodec, searchText).joinToString(" ").lowercase()
+ return when {
+ text.hasToken("av1") -> DebridStreamEncode.AV1
+ text.hasToken("hevc") || text.hasToken("h265") || text.hasToken("x265") -> DebridStreamEncode.HEVC
+ text.hasToken("avc") || text.hasToken("h264") || text.hasToken("x264") -> DebridStreamEncode.AVC
+ text.hasToken("xvid") -> DebridStreamEncode.XVID
+ text.hasToken("divx") -> DebridStreamEncode.DIVX
+ else -> DebridStreamEncode.UNKNOWN
+ }
+ }
+
+ private fun languageFor(value: String): DebridStreamLanguage? {
+ val normalized = value.lowercase()
+ return DebridStreamLanguage.entries.firstOrNull {
+ normalized == it.code || normalized == it.label.lowercase()
+ }
+ }
+
+ private fun releaseGroupFromText(text: String): String =
+ Regex("-([a-z0-9][a-z0-9._]{1,24})($|\\.)", RegexOption.IGNORE_CASE)
+ .find(text)
+ ?.groupValues
+ ?.getOrNull(1)
+ .orEmpty()
+
+ private fun rank(value: T, preferred: List): Int {
+ val index = preferred.indexOf(value)
+ return if (index >= 0) index else Int.MAX_VALUE
+ }
+
+ private fun rankAny(values: List, preferred: List): Int =
+ values.minOfOrNull { rank(it, preferred) } ?: Int.MAX_VALUE
+
+ private fun String.hasResolutionToken(vararg tokens: String): Boolean =
+ Regex("(^|[^a-z0-9])(${tokens.joinToString("|")})([^a-z0-9]|\$)").containsMatchIn(this)
+
+ private fun String.hasToken(token: String): Boolean =
+ Regex("(^|[^a-z0-9])${Regex.escape(token.lowercase())}([^a-z0-9]|\$)").containsMatchIn(lowercase())
+
+ private fun String.isDolbyVisionToken(): Boolean {
+ val normalized = lowercase().replace(Regex("[^a-z0-9]"), "")
+ return normalized == "dv" || normalized == "dovi" || normalized == "dolbyvision"
+ }
+
+ private fun String.isHdrToken(): Boolean {
+ val normalized = lowercase().replace(Regex("[^a-z0-9+]"), "")
+ return normalized == "hdr" ||
+ normalized == "hdr10" ||
+ normalized == "hdr10+" ||
+ normalized == "hdr10plus" ||
+ normalized == "hlg"
+ }
+
+ private fun streamSize(stream: StreamItem): Long? =
+ stream.clientResolve?.stream?.raw?.size ?: stream.behaviorHints.videoSize
+
+ private fun streamSearchText(stream: StreamItem): String {
+ val resolve = stream.clientResolve
+ val raw = resolve?.stream?.raw
+ val parsed = raw?.parsed
+ return listOfNotNull(
+ stream.name,
+ stream.title,
+ stream.description,
+ resolve?.torrentName,
+ resolve?.filename,
+ raw?.torrentName,
+ raw?.filename,
+ parsed?.resolution,
+ parsed?.quality,
+ parsed?.codec,
+ parsed?.hdr?.joinToString(" "),
+ parsed?.audio?.joinToString(" "),
+ ).joinToString(" ").lowercase()
+ }
+
+ private fun Int.gigabytes(): Long = this * 1_000_000_000L
+
+ private data class StreamFacts(
+ val resolution: DebridStreamResolution,
+ val quality: DebridStreamQuality,
+ val visualTags: List,
+ val audioTags: List,
+ val audioChannels: List,
+ val encode: DebridStreamEncode,
+ val languages: List,
+ val releaseGroup: String,
+ val size: Long?,
+ val resolutionRank: Int,
+ val qualityRank: Int,
+ val visualRank: Int,
+ val audioRank: Int,
+ val channelRank: Int,
+ val encodeRank: Int,
+ val languageRank: Int,
+ )
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt
index 7cd0e03a..6cd5573d 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt
@@ -4,9 +4,20 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.streams.AddonStreamGroup
import com.nuvio.app.features.streams.StreamParser
+import com.nuvio.app.features.streams.epochMs
import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
private const val DIRECT_DEBRID_TAG = "DirectDebridStreams"
+private const val STREAM_CACHE_TTL_MS = 5L * 60L * 1000L
data class DirectDebridStreamTarget(
val provider: DebridProvider,
@@ -20,6 +31,10 @@ object DirectDebridStreamSource {
private val log = Logger.withTag(DIRECT_DEBRID_TAG)
private val encoder = DirectDebridConfigEncoder()
private val formatter = DebridStreamFormatter()
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ private val mutex = Mutex()
+ private val streamCache = mutableMapOf()
+ private val inFlightFetches = mutableMapOf>()
fun configuredTargets(): List {
DebridSettingsRepository.ensureLoaded()
@@ -33,6 +48,12 @@ object DirectDebridStreamSource {
}
}
+ fun sourceNames(): List =
+ configuredTargets().map { it.addonName }
+
+ fun isEnabled(): Boolean =
+ sourceNames().isNotEmpty()
+
fun placeholders(): List =
configuredTargets().map { target ->
AddonStreamGroup(
@@ -43,6 +64,36 @@ object DirectDebridStreamSource {
)
}
+ fun preloadStreams(type: String, videoId: String) {
+ if (type.isBlank() || videoId.isBlank()) return
+ configuredTargets().forEach { target ->
+ scope.launch {
+ runCatching { fetchProviderStreams(type, videoId, target) }
+ }
+ }
+ }
+
+ suspend fun fetchStreams(type: String, videoId: String): DirectDebridStreamFetchResult {
+ val targets = configuredTargets()
+ if (targets.isEmpty()) return DirectDebridStreamFetchResult.Disabled
+
+ val results = mutableListOf()
+ val errors = mutableListOf()
+ targets.forEach { target ->
+ val group = fetchProviderStreams(type, videoId, target)
+ when {
+ group.streams.isNotEmpty() -> results += group
+ !group.error.isNullOrBlank() -> errors += group.error
+ }
+ }
+
+ return when {
+ results.isNotEmpty() -> DirectDebridStreamFetchResult.Success(results)
+ errors.isNotEmpty() -> DirectDebridStreamFetchResult.Error(errors.first())
+ else -> DirectDebridStreamFetchResult.Empty
+ }
+ }
+
suspend fun fetchProviderStreams(
type: String,
videoId: String,
@@ -54,6 +105,89 @@ object DirectDebridStreamSource {
return target.emptyGroup()
}
+ val cacheKey = DirectDebridStreamCacheKey(
+ providerId = target.provider.id,
+ type = type.trim().lowercase(),
+ videoId = videoId.trim(),
+ baseUrl = baseUrl,
+ settingsFingerprint = settings.toString(),
+ )
+ cachedGroup(cacheKey)?.let { return it }
+
+ var ownsFetch = false
+ val newFetch = scope.async(start = CoroutineStart.LAZY) {
+ fetchProviderStreamsUncached(
+ baseUrl = baseUrl,
+ type = type,
+ videoId = videoId,
+ target = target,
+ settings = settings,
+ )
+ }
+ val activeFetch = mutex.withLock {
+ cachedGroupLocked(cacheKey)?.let { cached ->
+ return@withLock null to cached
+ }
+ val existing = inFlightFetches[cacheKey]
+ if (existing != null) {
+ existing to null
+ } else {
+ inFlightFetches[cacheKey] = newFetch
+ ownsFetch = true
+ newFetch to null
+ }
+ }
+ activeFetch.second?.let {
+ newFetch.cancel()
+ return it
+ }
+ val deferred = activeFetch.first ?: return target.errorGroup("Could not start Direct Debrid fetch")
+ if (!ownsFetch) newFetch.cancel()
+ if (ownsFetch) deferred.start()
+
+ return try {
+ val result = deferred.await()
+ if (ownsFetch && result.streams.isNotEmpty() && result.error == null) {
+ mutex.withLock {
+ streamCache[cacheKey] = CachedDirectDebridStreams(
+ group = result,
+ createdAtMs = epochMs(),
+ )
+ }
+ }
+ result
+ } finally {
+ if (ownsFetch) {
+ mutex.withLock {
+ if (inFlightFetches[cacheKey] === deferred) {
+ inFlightFetches.remove(cacheKey)
+ }
+ }
+ }
+ }
+ }
+
+ private suspend fun cachedGroup(cacheKey: DirectDebridStreamCacheKey): AddonStreamGroup? =
+ mutex.withLock { cachedGroupLocked(cacheKey) }
+
+ private fun cachedGroupLocked(cacheKey: DirectDebridStreamCacheKey): AddonStreamGroup? {
+ val cached = streamCache[cacheKey] ?: return null
+ val age = epochMs() - cached.createdAtMs
+ return if (age in 0..STREAM_CACHE_TTL_MS) {
+ cached.group
+ } else {
+ streamCache.remove(cacheKey)
+ null
+ }
+ }
+
+ private suspend fun fetchProviderStreamsUncached(
+ baseUrl: String,
+ type: String,
+ videoId: String,
+ target: DirectDebridStreamTarget,
+ settings: DebridSettings,
+ ): AddonStreamGroup {
val credential = DebridServiceCredential(target.provider, target.apiKey)
val url = "$baseUrl/${encoder.encode(credential)}/client-stream/${encodePathSegment(type)}/${encodePathSegment(videoId)}.json"
return try {
@@ -63,7 +197,7 @@ object DirectDebridStreamSource {
addonName = DirectDebridStreamFilter.FALLBACK_SOURCE_NAME,
addonId = target.addonId,
)
- .let(DirectDebridStreamFilter::filterInstant)
+ .let { DirectDebridStreamFilter.filterInstant(it, settings) }
.filter { stream -> stream.clientResolve?.service.equals(target.provider.id, ignoreCase = true) }
.map { stream -> formatter.format(stream.copy(addonId = target.addonId), settings) }
@@ -76,13 +210,7 @@ object DirectDebridStreamSource {
} catch (error: Exception) {
if (error is CancellationException) throw error
log.w(error) { "Direct debrid ${target.provider.id} stream fetch failed" }
- AddonStreamGroup(
- addonName = target.addonName,
- addonId = target.addonId,
- streams = emptyList(),
- isLoading = false,
- error = error.message,
- )
+ target.errorGroup(error.message)
}
}
@@ -93,4 +221,33 @@ object DirectDebridStreamSource {
streams = emptyList(),
isLoading = false,
)
+
+ private fun DirectDebridStreamTarget.errorGroup(message: String?): AddonStreamGroup =
+ AddonStreamGroup(
+ addonName = addonName,
+ addonId = addonId,
+ streams = emptyList(),
+ isLoading = false,
+ error = message,
+ )
+}
+
+private data class DirectDebridStreamCacheKey(
+ val providerId: String,
+ val type: String,
+ val videoId: String,
+ val baseUrl: String,
+ val settingsFingerprint: String,
+)
+
+private data class CachedDirectDebridStreams(
+ val group: AddonStreamGroup,
+ val createdAtMs: Long,
+)
+
+sealed class DirectDebridStreamFetchResult {
+ data object Disabled : DirectDebridStreamFetchResult()
+ data object Empty : DirectDebridStreamFetchResult()
+ data class Success(val streams: List) : DirectDebridStreamFetchResult()
+ data class Error(val message: String) : DirectDebridStreamFetchResult()
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt
index d8bfbf27..99493f36 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt
@@ -62,6 +62,7 @@ import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.ui.NuvioBackButton
import com.nuvio.app.core.ui.TraktListPickerDialog
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
+import com.nuvio.app.features.debrid.DirectDebridStreamSource
import com.nuvio.app.features.details.components.DetailActionButtons
import com.nuvio.app.features.details.components.CommentDetailSheet
import com.nuvio.app.features.details.components.DetailAdditionalInfoSection
@@ -372,6 +373,16 @@ fun MetaDetailsScreen(
seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId
}
val hasEpisodes = meta.videos.any { it.season != null || it.episode != null }
+ val debridPreloadVideoId = remember(meta.id, meta.type, hasEpisodes, seriesStreamVideoId, seriesAction?.videoId) {
+ if (meta.isSeriesLikeForDebridPreload(hasEpisodes)) {
+ seriesStreamVideoId ?: seriesAction?.videoId ?: meta.id
+ } else {
+ meta.id
+ }
+ }
+ LaunchedEffect(meta.type, debridPreloadVideoId) {
+ DirectDebridStreamSource.preloadStreams(meta.type, debridPreloadVideoId)
+ }
val hasProductionSection = remember(meta) {
meta.productionCompanies.isNotEmpty() || meta.networks.isNotEmpty()
}
@@ -1259,3 +1270,8 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp =
} else {
(maxWidth * 0.6f).coerceIn(520.dp, 680.dp)
}
+
+private fun MetaDetails.isSeriesLikeForDebridPreload(hasEpisodes: Boolean): Boolean =
+ hasEpisodes || type.equals("series", ignoreCase = true) ||
+ type.equals("show", ignoreCase = true) ||
+ type.equals("tv", ignoreCase = true)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt
index 769456b6..f209f7f2 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt
@@ -53,6 +53,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.sp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
@@ -63,6 +64,7 @@ import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
import com.nuvio.app.features.details.components.DetailPosterRailSection
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.tmdb.TmdbMetadataService
+import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
@@ -89,6 +91,10 @@ fun PersonDetailScreen(
modifier: Modifier = Modifier,
) {
var uiState by remember(personId) { mutableStateOf(PersonDetailUiState.Loading) }
+ val watchedUiState by remember {
+ WatchedRepository.ensureLoaded()
+ WatchedRepository.uiState
+ }.collectAsStateWithLifecycle()
val resolvedAvatarTransitionKey = avatarTransitionKey ?: castAvatarSharedTransitionKey(personId)
LaunchedEffect(personId) {
@@ -127,6 +133,7 @@ fun PersonDetailScreen(
)
is PersonDetailUiState.Success -> PersonDetailContent(
person = state.personDetail,
+ watchedKeys = watchedUiState.watchedKeys,
onOpenMeta = onOpenMeta,
initialProfilePhoto = initialProfilePhoto,
avatarTransitionKey = resolvedAvatarTransitionKey,
@@ -156,6 +163,7 @@ fun PersonDetailScreen(
@OptIn(ExperimentalSharedTransitionApi::class)
private fun PersonDetailContent(
person: PersonDetail,
+ watchedKeys: Set,
onOpenMeta: (MetaPreview) -> Unit,
initialProfilePhoto: String? = null,
avatarTransitionKey: String,
@@ -274,7 +282,7 @@ private fun PersonDetailContent(
DetailPosterRailSection(
title = stringResource(Res.string.person_popular),
items = popularCredits,
- watchedKeys = emptySet(),
+ watchedKeys = watchedKeys,
headerHorizontalPadding = 20.dp,
onPosterClick = onOpenMeta,
)
@@ -285,7 +293,7 @@ private fun PersonDetailContent(
DetailPosterRailSection(
title = stringResource(Res.string.person_latest),
items = latestCredits,
- watchedKeys = emptySet(),
+ watchedKeys = watchedKeys,
headerHorizontalPadding = 20.dp,
onPosterClick = onOpenMeta,
)
@@ -296,7 +304,7 @@ private fun PersonDetailContent(
DetailPosterRailSection(
title = stringResource(Res.string.person_upcoming),
items = upcomingCredits,
- watchedKeys = emptySet(),
+ watchedKeys = watchedKeys,
headerHorizontalPadding = 20.dp,
onPosterClick = onOpenMeta,
)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt
index 2c03a8fe..04abb4ca 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt
@@ -42,6 +42,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -55,6 +56,7 @@ import com.nuvio.app.features.tmdb.TmdbEntityKind
import com.nuvio.app.features.tmdb.TmdbEntityMediaType
import com.nuvio.app.features.tmdb.TmdbEntityRailType
import com.nuvio.app.features.tmdb.TmdbMetadataService
+import com.nuvio.app.features.watched.WatchedRepository
private sealed interface EntityBrowseUiState {
data object Loading : EntityBrowseUiState
@@ -75,6 +77,10 @@ fun TmdbEntityBrowseScreen(
var uiState by remember(entityKind, entityId) {
mutableStateOf(EntityBrowseUiState.Loading)
}
+ val watchedUiState by remember {
+ WatchedRepository.ensureLoaded()
+ WatchedRepository.uiState
+ }.collectAsStateWithLifecycle()
val loadFailedMessage = stringResource(Res.string.details_browse_load_failed, entityName)
LaunchedEffect(entityKind, entityId) {
@@ -106,6 +112,7 @@ fun TmdbEntityBrowseScreen(
is EntityBrowseUiState.Success -> EntityBrowseContent(
data = state.data,
sourceType = sourceType,
+ watchedKeys = watchedUiState.watchedKeys,
onOpenMeta = onOpenMeta,
)
}
@@ -131,6 +138,7 @@ fun TmdbEntityBrowseScreen(
private fun EntityBrowseContent(
data: TmdbEntityBrowseData,
sourceType: String,
+ watchedKeys: Set,
onOpenMeta: (MetaPreview) -> Unit,
) {
val backgroundUrl = remember(data.rails, sourceType) {
@@ -208,7 +216,7 @@ private fun EntityBrowseContent(
DetailPosterRailSection(
title = railTitle,
items = rail.items,
- watchedKeys = emptySet(),
+ watchedKeys = watchedKeys,
headerHorizontalPadding = 20.dp,
onPosterClick = onOpenMeta,
)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt
index 202af87a..c5bd40ad 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt
@@ -145,6 +145,7 @@ object HomeCatalogSettingsRepository {
enforcePinnedCollectionsAtTop()
publish()
persist()
+ HomeRepository.applyCurrentSettings()
}
internal fun snapshot(): HomeCatalogSettingsSnapshot {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt
index 4573db3c..083355a5 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt
@@ -1,7 +1,14 @@
package com.nuvio.app.features.home
import com.nuvio.app.features.addons.ManagedAddon
+import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.fetchCatalogPage
+import com.nuvio.app.features.collection.Collection
+import com.nuvio.app.features.collection.CollectionRepository
+import com.nuvio.app.features.collection.CollectionSource
+import com.nuvio.app.features.collection.TmdbCollectionSourceResolver
+import com.nuvio.app.features.collection.findCollectionCatalog
+import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -27,6 +34,10 @@ object HomeRepository {
private var lastRequestKey: String? = null
private var currentDefinitions: List = emptyList()
private var cachedSections: Map = emptyMap()
+ private var cachedCollectionHeroItems: List = emptyList()
+ private var collectionHeroJob: Job? = null
+ private var collectionHeroRequestKey: String? = null
+ private var lastPublishedCatalogHeroEmpty: Boolean = true
private var lastErrorMessage: String? = null
fun refresh(addons: List, force: Boolean = false) {
@@ -55,10 +66,14 @@ object HomeRepository {
activeRequestKey = null
cachedSections = emptyMap()
lastErrorMessage = null
- _uiState.value = HomeUiState(
+ publishCurrentState(
isLoading = false,
- sections = emptyList(),
- errorMessage = null,
+ requestKey = requestKey,
+ )
+ ensureCollectionHeroFallback(
+ addons = addons,
+ force = force,
+ requestKey = requestKey,
)
return
}
@@ -119,6 +134,11 @@ object HomeRepository {
isLoading = false,
requestKey = requestKey,
)
+ ensureCollectionHeroFallback(
+ addons = addons,
+ force = force,
+ requestKey = requestKey,
+ )
}
}
@@ -127,6 +147,11 @@ object HomeRepository {
isLoading = _uiState.value.isLoading,
requestKey = activeRequestKey ?: lastRequestKey,
)
+ ensureCollectionHeroFallback(
+ addons = AddonRepository.uiState.value.addons,
+ force = false,
+ requestKey = activeRequestKey ?: lastRequestKey,
+ )
}
fun clear() {
@@ -136,6 +161,11 @@ object HomeRepository {
lastRequestKey = null
currentDefinitions = emptyList()
cachedSections = emptyMap()
+ cachedCollectionHeroItems = emptyList()
+ collectionHeroJob?.cancel()
+ collectionHeroJob = null
+ collectionHeroRequestKey = null
+ lastPublishedCatalogHeroEmpty = true
lastErrorMessage = null
_uiState.value = HomeUiState()
}
@@ -164,7 +194,7 @@ object HomeRepository {
)
}
- val heroItems = if (snapshot.heroEnabled) {
+ val catalogHeroItems = if (snapshot.heroEnabled) {
val heroRandom = Random((requestKey?.hashCode() ?: 0).absoluteValue + 1)
currentDefinitions
.filter { definition -> preferences[definition.key]?.heroSourceEnabled != false }
@@ -177,6 +207,12 @@ object HomeRepository {
} else {
emptyList()
}
+ lastPublishedCatalogHeroEmpty = snapshot.heroEnabled && catalogHeroItems.isEmpty()
+ val heroItems = if (snapshot.heroEnabled) {
+ catalogHeroItems.ifEmpty { cachedCollectionHeroItems }
+ } else {
+ emptyList()
+ }
_uiState.value = HomeUiState(
isLoading = isLoading,
@@ -222,9 +258,175 @@ object HomeRepository {
supportsPagination = supportsPagination,
)
}
+
+ private fun ensureCollectionHeroFallback(
+ addons: List,
+ force: Boolean,
+ requestKey: String?,
+ ) {
+ if (!lastPublishedCatalogHeroEmpty) return
+ val snapshot = HomeCatalogSettingsRepository.snapshot()
+ if (!snapshot.heroEnabled) return
+ val collections = enabledCollectionsForHero(snapshot)
+ if (collections.isEmpty()) {
+ cachedCollectionHeroItems = emptyList()
+ collectionHeroRequestKey = null
+ return
+ }
+
+ val nextRequestKey = collectionHeroRequestKey(
+ collections = collections,
+ addons = addons,
+ snapshot = snapshot,
+ requestKey = requestKey,
+ )
+ if (!force && collectionHeroRequestKey == nextRequestKey) return
+
+ collectionHeroJob?.cancel()
+ collectionHeroRequestKey = nextRequestKey
+ cachedCollectionHeroItems = emptyList()
+ publishCurrentState(
+ isLoading = _uiState.value.isLoading,
+ requestKey = requestKey,
+ )
+
+ collectionHeroJob = scope.launch {
+ val sources = collectionHeroSources(collections)
+ val sourceResults = sources.map { source ->
+ async {
+ runCatching {
+ source.resolveCollectionHeroItems(addons)
+ }.getOrDefault(emptyList())
+ }
+ }.awaitAll()
+ val random = Random((nextRequestKey.hashCode()).absoluteValue + 7)
+ cachedCollectionHeroItems = roundRobinCollectionHeroItems(sourceResults)
+ .distinctBy { item -> item.stableKey() }
+ .shuffled(random)
+ .take(HOME_HERO_ITEM_LIMIT)
+ publishCurrentState(
+ isLoading = _uiState.value.isLoading,
+ requestKey = requestKey,
+ )
+ }
+ }
+
+ private fun enabledCollectionsForHero(snapshot: HomeCatalogSettingsSnapshot): List {
+ val preferences = snapshot.preferences
+ return CollectionRepository.collections.value
+ .filter { collection ->
+ collection.folders.isNotEmpty() &&
+ preferences["collection_${collection.id}"]?.enabled != false
+ }
+ .sortedBy { collection ->
+ preferences["collection_${collection.id}"]?.order ?: Int.MAX_VALUE
+ }
+ }
+
+ private fun collectionHeroSources(collections: List): List =
+ collections
+ .flatMap { collection -> collection.folders }
+ .flatMap { folder -> folder.resolvedSources }
+ .take(HOME_COLLECTION_HERO_SOURCE_LIMIT)
+
+ private suspend fun CollectionSource.resolveCollectionHeroItems(addons: List): List {
+ val page = when {
+ isTmdb -> TmdbCollectionSourceResolver.resolve(source = this, page = 1)
+ isTrakt -> TraktPublicListSourceResolver.resolve(source = this, page = 1)
+ else -> {
+ val catalogSource = addonCatalogSource() ?: return emptyList()
+ val resolvedCatalog = addons.findCollectionCatalog(catalogSource) ?: return emptyList()
+ fetchCatalogPage(
+ manifestUrl = resolvedCatalog.addon.manifestUrl,
+ type = catalogSource.type,
+ catalogId = catalogSource.catalogId,
+ genre = catalogSource.genre,
+ maxItems = HOME_COLLECTION_HERO_SOURCE_ITEM_LIMIT,
+ )
+ }
+ }
+ val items = page.items
+ return if (HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) {
+ items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
+ } else {
+ items
+ }
+ }
+
+ private fun roundRobinCollectionHeroItems(sourceResults: List>): List {
+ val iterators = sourceResults.filter { it.isNotEmpty() }.map { it.iterator() }
+ if (iterators.isEmpty()) return emptyList()
+ val merged = mutableListOf()
+ var hasMore = true
+ while (hasMore && merged.size < HOME_COLLECTION_HERO_SOURCE_LIMIT * HOME_COLLECTION_HERO_SOURCE_ITEM_LIMIT) {
+ hasMore = false
+ iterators.forEach { iterator ->
+ if (iterator.hasNext()) {
+ merged.add(iterator.next())
+ hasMore = true
+ }
+ }
+ }
+ return merged
+ }
+
+ private fun collectionHeroRequestKey(
+ collections: List,
+ addons: List,
+ snapshot: HomeCatalogSettingsSnapshot,
+ requestKey: String?,
+ ): String = buildString {
+ append(requestKey.orEmpty())
+ append("|hideUnreleased=")
+ append(snapshot.hideUnreleasedContent)
+ append("|collections=")
+ collections.forEach { collection ->
+ val preference = snapshot.preferences["collection_${collection.id}"]
+ append(collection.id)
+ append(":")
+ append(preference?.order ?: Int.MAX_VALUE)
+ append(":")
+ collection.folders.forEach { folder ->
+ append(folder.id)
+ append("[")
+ folder.resolvedSources.forEach { source ->
+ append(collectionSourceKey(source))
+ append(",")
+ }
+ append("]")
+ }
+ append(";")
+ }
+ append("|addons=")
+ addons.forEach { addon ->
+ append(addon.manifest?.id.orEmpty())
+ append(":")
+ append(addon.manifestUrl)
+ append(":")
+ append(addon.manifest?.catalogs?.size ?: 0)
+ append(";")
+ }
+ }
+
+ private fun collectionSourceKey(source: CollectionSource): String =
+ listOf(
+ source.provider,
+ source.addonId,
+ source.type,
+ source.catalogId,
+ source.genre,
+ source.tmdbSourceType,
+ source.tmdbId?.toString(),
+ source.traktListId?.toString(),
+ source.mediaType,
+ source.sortBy,
+ source.sortHow,
+ ).joinToString(":") { it.orEmpty() }
}
private const val HOME_HERO_ITEM_LIMIT = 8
+private const val HOME_COLLECTION_HERO_SOURCE_LIMIT = 6
+private const val HOME_COLLECTION_HERO_SOURCE_ITEM_LIMIT = 8
private const val HOME_CATALOG_FETCH_BATCH_SIZE = 4
private const val HOME_CATALOG_PREVIEW_FETCH_LIMIT = 18
private const val HOME_CATALOG_PUBLISH_INTERVAL = 2
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt
index aa4be057..3bf4715b 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt
@@ -35,6 +35,7 @@ import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
import com.nuvio.app.features.trakt.shouldUseTraktProgress
+import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.CachedInProgressItem
import com.nuvio.app.features.watchprogress.CachedNextUpItem
@@ -45,13 +46,19 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode
import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching
import com.nuvio.app.features.watchprogress.nextUpDismissKey
+import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching
+import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching
import com.nuvio.app.features.watchprogress.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressRepository
+import com.nuvio.app.features.watchprogress.WatchProgressSourceLocal
+import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory
+import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback
+import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktShowProgress
import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle
+import com.nuvio.app.features.watchprogress.continueWatchingEntries
import com.nuvio.app.features.watchprogress.toContinueWatchingItem
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
-import com.nuvio.app.features.watching.application.WatchingState
import com.nuvio.app.features.watching.domain.WatchingContentRef
import com.nuvio.app.features.watching.domain.isReleasedBy
import com.nuvio.app.features.collection.CollectionRepository
@@ -164,46 +171,100 @@ fun HomeScreen(
if (isTraktProgressActive) emptyList() else watchedUiState.items
}
- val latestCompletedBySeries = remember(effectiveWatchProgressEntries, effectiveWatchedItems, continueWatchingPreferences.upNextFromFurthestEpisode) {
- WatchingState.latestCompletedBySeries(
- progressEntries = effectiveWatchProgressEntries,
+ val allNextUpSeedEntries = remember(
+ watchProgressUiState.entries,
+ effectiveWatchedItems,
+ isTraktProgressActive,
+ continueWatchingPreferences.upNextFromFurthestEpisode,
+ ) {
+ buildTvParityNextUpSeedEntries(
+ progressEntries = watchProgressUiState.entries,
watchedItems = effectiveWatchedItems,
+ isTraktProgressActive = isTraktProgressActive,
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
+ nowEpochMs = WatchProgressClock.nowEpochMs(),
)
}
- val completedSeriesCandidates = remember(latestCompletedBySeries) {
- latestCompletedBySeries.map { (content, completed) ->
+
+ val recentNextUpSeedEntries = remember(
+ allNextUpSeedEntries,
+ isTraktProgressActive,
+ traktSettingsUiState.continueWatchingDaysCap,
+ ) {
+ filterEntriesForTraktContinueWatchingWindow(
+ entries = allNextUpSeedEntries,
+ isTraktProgressActive = isTraktProgressActive,
+ daysCap = traktSettingsUiState.continueWatchingDaysCap,
+ nowEpochMs = WatchProgressClock.nowEpochMs(),
+ )
+ }
+
+ val activeNextUpSeedContentIds = remember(allNextUpSeedEntries) {
+ allNextUpSeedEntries.mapTo(mutableSetOf()) { entry -> entry.parentMetaId }
+ }
+
+ val currentNextUpSeedByContentId = remember(allNextUpSeedEntries) {
+ allNextUpSeedEntries.mapNotNull { entry ->
+ val season = entry.seasonNumber ?: return@mapNotNull null
+ val episode = entry.episodeNumber ?: return@mapNotNull null
+ entry.parentMetaId to (season to episode)
+ }.toMap()
+ }
+
+ val visibleContinueWatchingEntries = remember(effectiveWatchProgressEntries) {
+ effectiveWatchProgressEntries.continueWatchingEntries()
+ }
+
+ val latestCompletedAtBySeries = remember(allNextUpSeedEntries) {
+ allNextUpSeedEntries
+ .groupBy { entry -> entry.parentMetaId }
+ .mapValues { (_, entries) -> entries.maxOfOrNull { entry -> entry.lastUpdatedEpochMs } ?: Long.MIN_VALUE }
+ }
+
+ val nextUpSuppressedSeriesIds = remember(visibleContinueWatchingEntries, latestCompletedAtBySeries) {
+ visibleContinueWatchingEntries
+ .asSequence()
+ .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
+ .filter { entry ->
+ shouldTreatAsActiveInProgressForNextUpSuppression(
+ progress = entry,
+ latestCompletedAt = latestCompletedAtBySeries[entry.parentMetaId],
+ )
+ }
+ .map { entry -> entry.parentMetaId }
+ .filter(String::isNotBlank)
+ .toSet()
+ }
+
+ val completedSeriesCandidates = remember(recentNextUpSeedEntries, nextUpSuppressedSeriesIds) {
+ recentNextUpSeedEntries.mapNotNull { seed ->
+ val season = seed.seasonNumber ?: return@mapNotNull null
+ val episode = seed.episodeNumber ?: return@mapNotNull null
+ if (season == 0 || seed.parentMetaId in nextUpSuppressedSeriesIds) return@mapNotNull null
CompletedSeriesCandidate(
- content = content,
- seasonNumber = completed.seasonNumber,
- episodeNumber = completed.episodeNumber,
- markedAtEpochMs = completed.markedAtEpochMs,
+ content = WatchingContentRef(type = seed.parentMetaType, id = seed.parentMetaId),
+ seasonNumber = season,
+ episodeNumber = episode,
+ markedAtEpochMs = seed.lastUpdatedEpochMs,
)
}
}
- val completedSeriesContentIds = remember(completedSeriesCandidates) {
- completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id }
- }
- val visibleContinueWatchingEntries = remember(
- effectiveWatchProgressEntries,
- latestCompletedBySeries,
- ) {
- WatchingState.visibleContinueWatchingEntries(
- progressEntries = effectiveWatchProgressEntries,
- latestCompletedBySeries = latestCompletedBySeries,
- )
- }
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val activeProfileId = profileState.activeProfile?.profileIndex ?: 1
var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf